deepsieve-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.
File without changes
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .main import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,149 @@
1
+ """HTTP client for `/v1`, plus the device-authorization login flow.
2
+
3
+ Deliberately a thin httpx wrapper rather than a dependency on the generated
4
+ SDK: the CLI needs the unauthenticated device endpoints before any credential
5
+ exists, and keeping the dependency surface at httpx keeps `uvx deepsieve`
6
+ startup fast. The same choice the stdio MCP package made.
7
+
8
+ Errors are surfaced as the typed `/v1` envelope so the CLI can print the
9
+ message the API actually gave — a 403 must never render as "something failed".
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import time
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+ import httpx
19
+
20
+ USER_AGENT = "deepsieve-cli"
21
+
22
+
23
+ class ApiError(Exception):
24
+ def __init__(self, code: str, message: str, *, request_id: str | None = None) -> None:
25
+ self.code = code
26
+ self.message = message
27
+ self.request_id = request_id
28
+ super().__init__(message)
29
+
30
+ def __str__(self) -> str:
31
+ tail = f" (request id {self.request_id})" if self.request_id else ""
32
+ return f"{self.message}{tail}"
33
+
34
+
35
+ @dataclass
36
+ class DeviceAuthorization:
37
+ device_code: str
38
+ user_code: str
39
+ verification_uri: str
40
+ verification_uri_complete: str
41
+ expires_in: int
42
+ interval: int
43
+
44
+
45
+ class Client:
46
+ def __init__(self, origin: str, api_key: str | None = None, timeout: float = 60.0) -> None:
47
+ self.origin = origin.rstrip("/")
48
+ self.api_key = api_key
49
+ self._timeout = timeout
50
+
51
+ def _headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
52
+ h = {"User-Agent": USER_AGENT, **(extra or {})}
53
+ if self.api_key:
54
+ h["Authorization"] = f"Bearer {self.api_key}"
55
+ return h
56
+
57
+ def request(
58
+ self,
59
+ method: str,
60
+ path: str,
61
+ *,
62
+ params: dict[str, Any] | None = None,
63
+ json_body: dict[str, Any] | None = None,
64
+ headers: dict[str, str] | None = None,
65
+ raw: bool = False,
66
+ ) -> Any:
67
+ url = f"{self.origin}{path}"
68
+ try:
69
+ with httpx.Client(timeout=self._timeout, follow_redirects=True) as c:
70
+ r = c.request(
71
+ method, url, params=params, json=json_body, headers=self._headers(headers)
72
+ )
73
+ except httpx.RequestError as exc:
74
+ raise ApiError("network_error", f"Could not reach {self.origin}: {exc}") from exc
75
+
76
+ if raw and r.is_success:
77
+ return r.text
78
+ try:
79
+ body = r.json()
80
+ except ValueError:
81
+ if r.is_success:
82
+ return {"raw": r.text}
83
+ raise ApiError("http_error", f"HTTP {r.status_code} from {path}") from None
84
+ if not r.is_success:
85
+ err = (body or {}).get("error") or {}
86
+ raise ApiError(
87
+ err.get("code", f"http_{r.status_code}"),
88
+ err.get("message", f"HTTP {r.status_code} from {path}"),
89
+ request_id=err.get("request_id"),
90
+ )
91
+ return body
92
+
93
+ # ── device authorization ────────────────────────────────────────────────
94
+
95
+ def start_device_authorization(
96
+ self, client_name: str, preset: str = "cli"
97
+ ) -> DeviceAuthorization:
98
+ # Ask for the CLI preset, not the broad `agent` one. The consent screen
99
+ # lists what it asks for, and asking a security-attentive developer to
100
+ # approve "manage webhook endpoints" for a tool that has no webhook
101
+ # command is how you lose their trust at the first screen.
102
+ body = self.request(
103
+ "POST",
104
+ "/v1/auth/device/code",
105
+ json_body={"client_name": client_name, "preset": preset},
106
+ )
107
+ return DeviceAuthorization(
108
+ device_code=body["device_code"],
109
+ user_code=body["user_code"],
110
+ verification_uri=body["verification_uri"],
111
+ verification_uri_complete=body.get("verification_uri_complete")
112
+ or body["verification_uri"],
113
+ expires_in=int(body.get("expires_in", 600)),
114
+ interval=int(body.get("interval", 5)),
115
+ )
116
+
117
+ def poll_for_credential(
118
+ self, auth: DeviceAuthorization, *, on_tick=None, sleep=time.sleep
119
+ ) -> dict[str, Any]:
120
+ """Poll until approved, denied, or expired.
121
+
122
+ Honours the server's `interval`, and backs off on `slow_down` as
123
+ RFC 8628 requires — a CLI that ignores that is how a device flow turns
124
+ into a self-inflicted denial of service.
125
+ """
126
+ interval = auth.interval
127
+ deadline = time.monotonic() + auth.expires_in
128
+ while time.monotonic() < deadline:
129
+ sleep(interval)
130
+ if on_tick:
131
+ on_tick()
132
+ try:
133
+ return self.request(
134
+ "POST", "/v1/auth/device/token", json_body={"device_code": auth.device_code}
135
+ )
136
+ except ApiError as exc:
137
+ if exc.code == "authorization_pending":
138
+ continue
139
+ if exc.code == "slow_down":
140
+ interval += 5
141
+ continue
142
+ # A shared limiter bucket or a transient blip must not abort a
143
+ # sign-in the human is in the middle of approving: back off and
144
+ # keep waiting until the authorization itself expires.
145
+ if exc.code in ("rate_limited", "network_error") or exc.code.startswith("http_5"):
146
+ interval = min(interval + 5, 30)
147
+ continue
148
+ raise
149
+ raise ApiError("expired_token", "Timed out waiting for approval. Run login again.")
@@ -0,0 +1,116 @@
1
+ """Profiles and credential storage.
2
+
3
+ A profile is `(origin, api_key)` under a name. That is what makes it safe to
4
+ work against staging and production side by side: they are separate profiles,
5
+ never a global "current login" a developer has to remember the state of.
6
+
7
+ Credentials live in a 0600 file under the user's config directory, never in
8
+ shell history and never in the repo. `DEEPSIEVE_API_KEY` in the environment
9
+ always wins, so CI needs no login and no file.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import stat
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+ DEFAULT_ORIGIN = "https://deepsieve.ai"
21
+ ENV_KEY = "DEEPSIEVE_API_KEY"
22
+ ENV_ORIGIN = "DEEPSIEVE_BASE_URL"
23
+ ENV_PROFILE = "DEEPSIEVE_PROFILE"
24
+
25
+
26
+ def config_path() -> Path:
27
+ """`$XDG_CONFIG_HOME/deepsieve/config.json`, or the OS equivalent."""
28
+ override = os.environ.get("DEEPSIEVE_CONFIG")
29
+ if override:
30
+ return Path(override)
31
+ base = os.environ.get("XDG_CONFIG_HOME")
32
+ root = Path(base) if base else Path.home() / ".config"
33
+ return root / "deepsieve" / "config.json"
34
+
35
+
36
+ @dataclass
37
+ class Profile:
38
+ name: str
39
+ origin: str
40
+ api_key: str | None = None
41
+ key_name: str | None = None
42
+ scopes: list[str] | None = None
43
+
44
+ @property
45
+ def is_env_credential(self) -> bool:
46
+ return bool(os.environ.get(ENV_KEY)) and self.api_key == os.environ.get(ENV_KEY)
47
+
48
+
49
+ def _read() -> dict:
50
+ path = config_path()
51
+ if not path.exists():
52
+ return {"profiles": {}}
53
+ try:
54
+ data = json.loads(path.read_text())
55
+ except (OSError, ValueError):
56
+ return {"profiles": {}}
57
+ data.setdefault("profiles", {})
58
+ return data
59
+
60
+
61
+ def _write(data: dict) -> None:
62
+ path = config_path()
63
+ path.parent.mkdir(parents=True, exist_ok=True)
64
+ # chmod BEFORE the secret is written, so it is never briefly world-readable.
65
+ tmp = path.with_suffix(".tmp")
66
+ tmp.touch(mode=0o600, exist_ok=True)
67
+ os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR)
68
+ tmp.write_text(json.dumps(data, indent=2) + "\n")
69
+ tmp.replace(path)
70
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
71
+
72
+
73
+ def profile_name(explicit: str | None = None) -> str:
74
+ return explicit or os.environ.get(ENV_PROFILE) or "default"
75
+
76
+
77
+ def load_profile(name: str | None = None) -> Profile:
78
+ """Resolve a profile, with environment variables taking precedence.
79
+
80
+ Env-first is what makes the same binary work in CI without a login step —
81
+ and it means a compromised config file cannot silently override an
82
+ explicitly-provided CI credential.
83
+ """
84
+ resolved = profile_name(name)
85
+ stored = _read()["profiles"].get(resolved, {})
86
+ return Profile(
87
+ name=resolved,
88
+ origin=(os.environ.get(ENV_ORIGIN) or stored.get("origin") or DEFAULT_ORIGIN).rstrip("/"),
89
+ api_key=os.environ.get(ENV_KEY) or stored.get("api_key"),
90
+ key_name=stored.get("key_name"),
91
+ scopes=stored.get("scopes"),
92
+ )
93
+
94
+
95
+ def save_profile(profile: Profile) -> None:
96
+ data = _read()
97
+ data["profiles"][profile.name] = {
98
+ "origin": profile.origin,
99
+ "api_key": profile.api_key,
100
+ "key_name": profile.key_name,
101
+ "scopes": profile.scopes,
102
+ }
103
+ _write(data)
104
+
105
+
106
+ def forget_profile(name: str) -> bool:
107
+ data = _read()
108
+ if name not in data["profiles"]:
109
+ return False
110
+ del data["profiles"][name]
111
+ _write(data)
112
+ return True
113
+
114
+
115
+ def list_profiles() -> dict[str, dict]:
116
+ return _read()["profiles"]
deepsieve_cli/main.py ADDED
@@ -0,0 +1,531 @@
1
+ """`deepsieve` — the DeepSieve CLI.
2
+
3
+ Scope is deliberate. This is the operator's tool: sign in, start and watch
4
+ research, read the cited dataset, wire an agent up. It does NOT manage billing,
5
+ organizations, API keys, or edit an active Blueprint — those are decisions a
6
+ human should make in the app, and a terminal (or an agent driving one) is the
7
+ wrong place to make them irreversibly. The credential it holds is ceilinged at
8
+ the `agent` preset, so the restraint is enforced server-side too, not just by
9
+ the absence of a subcommand.
10
+
11
+ Output: human-readable tables on a TTY, `--json` for pipes and agents. Exit
12
+ codes are stable — 0 success, 1 failure, 2 usage, 3 auth, 4 still-running.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import re
21
+ import sys
22
+ import time
23
+ import uuid
24
+ import webbrowser
25
+ from typing import Any
26
+
27
+ from . import config
28
+ from .client import ApiError, Client
29
+
30
+ EXIT_OK, EXIT_FAIL, EXIT_USAGE, EXIT_AUTH, EXIT_PENDING = 0, 1, 2, 3, 4
31
+
32
+ CLIENT_NAME = "DeepSieve CLI"
33
+
34
+
35
+ # ── output helpers ───────────────────────────────────────────────────────────
36
+
37
+
38
+ def _tty() -> bool:
39
+ return sys.stdout.isatty()
40
+
41
+
42
+ def out(data: Any, as_json: bool, human: str | None = None) -> None:
43
+ if as_json or human is None:
44
+ print(json.dumps(data, indent=2, default=str))
45
+ else:
46
+ print(human)
47
+
48
+
49
+ def err(message: str) -> None:
50
+ print(f"error: {message}", file=sys.stderr)
51
+
52
+
53
+ def _table(rows: list[list[str]], headers: list[str]) -> str:
54
+ widths = [len(h) for h in headers]
55
+ for r in rows:
56
+ for i, cell in enumerate(r):
57
+ widths[i] = max(widths[i], len(cell))
58
+ line = " ".join(h.upper().ljust(widths[i]) for i, h in enumerate(headers))
59
+ body = [" ".join(c.ljust(widths[i]) for i, c in enumerate(r)) for r in rows]
60
+ return "\n".join([line, *body])
61
+
62
+
63
+ def _client(args) -> tuple[Client, config.Profile]:
64
+ prof = config.load_profile(getattr(args, "profile", None))
65
+ return Client(prof.origin, prof.api_key), prof
66
+
67
+
68
+ def _require_auth(args) -> tuple[Client, config.Profile]:
69
+ client, prof = _client(args)
70
+ if not prof.api_key:
71
+ err(
72
+ f"not signed in for profile '{prof.name}'. Run: deepsieve login"
73
+ + (f" --profile {prof.name}" if prof.name != "default" else "")
74
+ )
75
+ raise SystemExit(EXIT_AUTH)
76
+ return client, prof
77
+
78
+
79
+ # ── commands ─────────────────────────────────────────────────────────────────
80
+
81
+
82
+ def cmd_login(args) -> int:
83
+ origin = (args.origin or os.environ.get(config.ENV_ORIGIN) or "").rstrip("/")
84
+ name = config.profile_name(args.profile)
85
+ if not origin:
86
+ existing = config.load_profile(name)
87
+ origin = existing.origin
88
+ client = Client(origin)
89
+
90
+ if args.api_key:
91
+ # Headless path. Verify before storing, so a typo fails now rather than
92
+ # on the next command with a confusing 401.
93
+ client.api_key = args.api_key
94
+ try:
95
+ me = client.request("GET", "/v1/me")
96
+ except ApiError as exc:
97
+ err(f"that key did not work against {origin}: {exc}")
98
+ return EXIT_AUTH
99
+ config.save_profile(
100
+ config.Profile(
101
+ name=name,
102
+ origin=origin,
103
+ api_key=args.api_key,
104
+ key_name="(provided)",
105
+ scopes=me.get("scopes"),
106
+ )
107
+ )
108
+ out({"signed_in": True, "profile": name, "origin": origin}, args.json,
109
+ f"Signed in to {origin} as profile '{name}'.")
110
+ return EXIT_OK
111
+
112
+ try:
113
+ auth = client.start_device_authorization(CLIENT_NAME)
114
+ except ApiError as exc:
115
+ err(str(exc))
116
+ return EXIT_FAIL
117
+
118
+ print(f"\n Your code: {auth.user_code}\n")
119
+ print(f" Open: {auth.verification_uri_complete}\n")
120
+ if not args.no_browser:
121
+ try:
122
+ webbrowser.open(auth.verification_uri_complete)
123
+ except Exception: # noqa: BLE001 — a headless box has no browser; the URL is printed
124
+ pass
125
+ print(" Waiting for approval…", end="", flush=True)
126
+
127
+ try:
128
+ cred = client.poll_for_credential(auth, on_tick=lambda: print(".", end="", flush=True))
129
+ except ApiError as exc:
130
+ print()
131
+ err(str(exc))
132
+ return EXIT_AUTH
133
+ print()
134
+
135
+ config.save_profile(
136
+ config.Profile(
137
+ name=name,
138
+ origin=origin,
139
+ api_key=cred["api_key"],
140
+ key_name=cred.get("key_name"),
141
+ scopes=cred.get("scopes"),
142
+ )
143
+ )
144
+ print(f"\n Signed in to {origin} as profile '{name}'.")
145
+ print(f" Credential: {cred.get('key_name')} — revoke any time at {origin}/settings/api-keys\n")
146
+ return EXIT_OK
147
+
148
+
149
+ def cmd_logout(args) -> int:
150
+ name = config.profile_name(args.profile)
151
+ removed = config.forget_profile(name)
152
+ msg = (
153
+ f"Signed out of profile '{name}'. The API key still exists — revoke it at "
154
+ "/settings/api-keys if it should stop working."
155
+ if removed
156
+ else f"No stored credential for profile '{name}'."
157
+ )
158
+ out({"signed_out": removed, "profile": name}, args.json, msg)
159
+ return EXIT_OK
160
+
161
+
162
+ def cmd_whoami(args) -> int:
163
+ client, prof = _require_auth(args)
164
+ try:
165
+ me = client.request("GET", "/v1/me")
166
+ except ApiError as exc:
167
+ err(str(exc))
168
+ return EXIT_AUTH
169
+ if args.json:
170
+ out(me, True)
171
+ return EXIT_OK
172
+ print(f"profile {prof.name}")
173
+ print(f"origin {prof.origin}")
174
+ print(f"org {me.get('org_id')}")
175
+ print(f"workspace {me.get('workspace_schema')}")
176
+ print(f"role {me.get('role')}")
177
+ print(f"scopes {', '.join(me.get('scopes') or []) or '(full)'}")
178
+ return EXIT_OK
179
+
180
+
181
+ def cmd_profiles(args) -> int:
182
+ profiles = config.list_profiles()
183
+ active = config.profile_name(args.profile)
184
+ if args.json:
185
+ out({k: {"origin": v.get("origin"), "active": k == active} for k, v in profiles.items()}, True)
186
+ return EXIT_OK
187
+ if not profiles:
188
+ print("No profiles yet. Run: deepsieve login")
189
+ return EXIT_OK
190
+ rows = [
191
+ [("* " if k == active else " ") + k, v.get("origin", ""), v.get("key_name") or ""]
192
+ for k, v in sorted(profiles.items())
193
+ ]
194
+ print(_table(rows, ["profile", "origin", "credential"]))
195
+ return EXIT_OK
196
+
197
+
198
+ def cmd_runs_list(args) -> int:
199
+ client, _ = _require_auth(args)
200
+ params: dict[str, Any] = {"limit": args.limit}
201
+ if args.status:
202
+ params["status"] = args.status
203
+ try:
204
+ body = client.request("GET", "/v1/research/runs", params=params)
205
+ except ApiError as exc:
206
+ err(str(exc))
207
+ return EXIT_FAIL
208
+ if args.json:
209
+ out(body, True)
210
+ return EXIT_OK
211
+ rows = [
212
+ [r.get("id", "")[:8], r.get("status", ""), str(r.get("created_at", ""))[:19],
213
+ (r.get("title") or r.get("query") or "")[:48]]
214
+ for r in body.get("data", [])
215
+ ]
216
+ print(_table(rows, ["id", "status", "created", "title"]) if rows else "No runs yet.")
217
+ return EXIT_OK
218
+
219
+
220
+ def cmd_runs_get(args) -> int:
221
+ client, _ = _require_auth(args)
222
+ try:
223
+ body = client.request("GET", f"/v1/research/runs/{args.run_id}")
224
+ except ApiError as exc:
225
+ err(str(exc))
226
+ return EXIT_FAIL
227
+ if args.json:
228
+ out(body, True)
229
+ else:
230
+ print(f"id {body.get('id')}")
231
+ print(f"status {body.get('status')} (done={body.get('done')})")
232
+ if body.get("error"):
233
+ print(f"error {body['error'].get('message')}")
234
+ if not body.get("done"):
235
+ return EXIT_PENDING
236
+ return EXIT_OK if body.get("status") == "completed" else EXIT_FAIL
237
+
238
+
239
+ def cmd_runs_create(args) -> int:
240
+ client, prof = _require_auth(args)
241
+ if bool(args.query) == bool(args.seeds):
242
+ err("pass exactly one of --query or --seeds")
243
+ return EXIT_USAGE
244
+
245
+ body: dict[str, Any] = {"depth": args.depth, "dry_run": args.dry_run, "monitored": args.monitored}
246
+ if args.query:
247
+ body["query"] = args.query
248
+ if args.seeds:
249
+ body["seeds"] = [s.strip() for s in args.seeds.split(",") if s.strip()]
250
+
251
+ # A real run spends money. Confirm interactively unless told not to — and
252
+ # never silently in a pipe, where nobody is watching.
253
+ if not args.dry_run and not args.yes:
254
+ if not _tty():
255
+ err("refusing to start a billable run non-interactively; pass --yes (or --dry-run)")
256
+ return EXIT_USAGE
257
+ print(f"This starts a REAL research run on {prof.origin} — it spends credits "
258
+ f"and takes 15-60 minutes.")
259
+ if input("Continue? [y/N] ").strip().lower() not in ("y", "yes"):
260
+ print("Cancelled.")
261
+ return EXIT_OK
262
+
263
+ try:
264
+ created = client.request(
265
+ "POST",
266
+ "/v1/research/runs",
267
+ json_body=body,
268
+ headers={"Idempotency-Key": args.idempotency_key or uuid.uuid4().hex},
269
+ )
270
+ except ApiError as exc:
271
+ err(str(exc))
272
+ return EXIT_FAIL
273
+
274
+ if args.json and not args.wait:
275
+ out(created, True)
276
+ return EXIT_OK
277
+ run_id = created.get("id")
278
+ print(f"started run {run_id} ({'dry run' if args.dry_run else 'billable'})")
279
+ if not args.wait:
280
+ print(f"poll with: deepsieve runs get {run_id}")
281
+ return EXIT_OK
282
+ return _wait(client, run_id, args)
283
+
284
+
285
+ def cmd_runs_wait(args) -> int:
286
+ client, _ = _require_auth(args)
287
+ return _wait(client, args.run_id, args)
288
+
289
+
290
+ def _wait(client: Client, run_id: str, args) -> int:
291
+ """Poll to completion, honouring Retry-After semantics via a fixed floor."""
292
+ deadline = time.monotonic() + args.timeout
293
+ while time.monotonic() < deadline:
294
+ try:
295
+ body = client.request("GET", f"/v1/research/runs/{run_id}")
296
+ except ApiError as exc:
297
+ err(str(exc))
298
+ return EXIT_FAIL
299
+ if body.get("done"):
300
+ if args.json:
301
+ out(body, True)
302
+ else:
303
+ print(f"run {run_id} finished: {body.get('status')}")
304
+ if body.get("error"):
305
+ print(f" {body['error'].get('message')}")
306
+ return EXIT_OK if body.get("status") == "completed" else EXIT_FAIL
307
+ if not args.json and _tty():
308
+ print(f" status: {body.get('status')}…", end="\r", flush=True)
309
+ time.sleep(args.interval)
310
+ err(f"still running after {args.timeout}s; poll with: deepsieve runs get {run_id}")
311
+ return EXIT_PENDING
312
+
313
+
314
+ def cmd_runs_cancel(args) -> int:
315
+ client, _ = _require_auth(args)
316
+ try:
317
+ body = client.request("POST", f"/v1/research/runs/{args.run_id}/cancel")
318
+ except ApiError as exc:
319
+ err(str(exc))
320
+ return EXIT_FAIL
321
+ out(body, args.json, f"cancellation requested for {args.run_id}")
322
+ return EXIT_OK
323
+
324
+
325
+ def cmd_data_catalog(args) -> int:
326
+ client, _ = _require_auth(args)
327
+ try:
328
+ body = client.request("GET", "/v1/data")
329
+ except ApiError as exc:
330
+ err(str(exc))
331
+ return EXIT_FAIL
332
+ if args.json:
333
+ out(body, True)
334
+ return EXIT_OK
335
+ for ent in body.get("entities", []):
336
+ cols = ", ".join(c.get("key", "") for c in ent.get("columns", []))
337
+ print(f"{ent.get('key')} ({ent.get('label', '')})\n {cols}\n")
338
+ return EXIT_OK
339
+
340
+
341
+ def cmd_data_get(args) -> int:
342
+ client, _ = _require_auth(args)
343
+ params: dict[str, Any] = {"limit": args.limit, "receipts": args.receipts}
344
+ if args.cursor:
345
+ params["cursor"] = args.cursor
346
+ if args.updated_since:
347
+ params["updated_since"] = args.updated_since
348
+ try:
349
+ body = client.request("GET", f"/v1/data/{args.entity}", params=params)
350
+ except ApiError as exc:
351
+ err(str(exc))
352
+ return EXIT_FAIL
353
+ out(body, True) # rows are structured either way; --json only stills the notes
354
+ if body.get("truncated") and not args.json:
355
+ print(
356
+ "\nnote: this response is a PREVIEW, not the full dataset "
357
+ f"(preview_row_cap={body.get('preview_row_cap')}).",
358
+ file=sys.stderr,
359
+ )
360
+ return EXIT_OK
361
+
362
+
363
+ def _remote_server_name(prof) -> str:
364
+ """The MCP server name this deployment tells agents to register.
365
+
366
+ Read from the deployment's own published setup payload, which is
367
+ unauthenticated and carries the canonical registration line. That matters:
368
+ the obvious source (`/api/system/status`) requires a session, so it 401s in
369
+ exactly the situation this command exists for — wiring up an agent before
370
+ anyone has signed in.
371
+
372
+ The fallback is the brand default, never a slice of the hostname: emitting
373
+ "staging" for staging.example.com drops the brand and collides with every
374
+ other deployment whose first label happens to match.
375
+ """
376
+ try:
377
+ body = Client(prof.origin).request("GET", "/setup.md", raw=True)
378
+ m = re.search(r"claude mcp add --transport http (\S+) ", body or "")
379
+ if m:
380
+ return m.group(1)
381
+ except Exception: # noqa: BLE001 — naming must never block printing config
382
+ pass
383
+ return "deepsieve"
384
+
385
+
386
+ def cmd_setup_mcp(args) -> int:
387
+ """Print (or install) the MCP registration for this profile's origin."""
388
+ _, prof = _client(args)
389
+ url = f"{prof.origin}/mcp"
390
+ # Ask the deployment what it calls itself rather than assuming "deepsieve":
391
+ # hardcoding it here would print a staging registration that overwrites the
392
+ # production entry — the exact collision the env-aware naming fixed.
393
+ name = args.name or _remote_server_name(prof)
394
+ if args.json:
395
+ out({"mcpServers": {name: {"url": url}}}, True)
396
+ return EXIT_OK
397
+ print(f"Register the {name} MCP server (browser login, no key needed):\n")
398
+ print(f" claude mcp add --transport http {name} {url}\n")
399
+ print("Or add to your agent's MCP config:\n")
400
+ print(json.dumps({"mcpServers": {name: {"url": url}}}, indent=2))
401
+ print(f"\nFull setup, including skills and rules: {prof.origin}/setup.md")
402
+ return EXIT_OK
403
+
404
+
405
+ # ── parser ───────────────────────────────────────────────────────────────────
406
+
407
+
408
+ def build_parser() -> argparse.ArgumentParser:
409
+ # The global flags live on a shared PARENT parser as well as the root, so
410
+ # both `deepsieve --json runs list` and `deepsieve runs list --json` work.
411
+ # argparse subparsers do not inherit root options, and the docs promise
412
+ # "add --json to any command" — the trailing form is the one people type.
413
+ # SUPPRESS is load-bearing: without it the subparser re-applies its own
414
+ # default and CLOBBERS a value given before the subcommand, so
415
+ # `deepsieve --json data get x` would silently print human output.
416
+ common = argparse.ArgumentParser(add_help=False)
417
+ common.add_argument(
418
+ "--profile",
419
+ default=argparse.SUPPRESS,
420
+ help="named profile (default: $DEEPSIEVE_PROFILE or 'default')",
421
+ )
422
+ common.add_argument(
423
+ "--json",
424
+ action="store_true",
425
+ default=argparse.SUPPRESS,
426
+ help="machine-readable output",
427
+ )
428
+
429
+ p = argparse.ArgumentParser(
430
+ prog="deepsieve",
431
+ description="Run cited deep research from your terminal.",
432
+ epilog="Docs: https://deepsieve.ai/developers/cli",
433
+ parents=[common],
434
+ )
435
+ sub = p.add_subparsers(dest="command", required=True)
436
+
437
+ lg = sub.add_parser("login", help="sign in via your browser (device flow)", parents=[common])
438
+ lg.add_argument("--origin", help="deployment URL (e.g. https://staging.deepsieve.ai)")
439
+ lg.add_argument("--api-key", help="headless: use a key instead of the browser flow")
440
+ lg.add_argument("--no-browser", action="store_true", help="print the URL, don't open it")
441
+ lg.set_defaults(func=cmd_login)
442
+
443
+ lo = sub.add_parser("logout", help="forget this profile's stored credential", parents=[common])
444
+ lo.set_defaults(func=cmd_logout)
445
+
446
+ wa = sub.add_parser("whoami", help="show the resolved identity, workspace and scopes", parents=[common])
447
+ wa.set_defaults(func=cmd_whoami)
448
+
449
+ pr = sub.add_parser("profiles", help="list configured profiles", parents=[common])
450
+ pr.set_defaults(func=cmd_profiles)
451
+
452
+ runs = sub.add_parser("runs", help="research runs", parents=[common]).add_subparsers(dest="sub", required=True)
453
+
454
+ rl = runs.add_parser("list", help="recent runs", parents=[common])
455
+ rl.add_argument("--status")
456
+ rl.add_argument("--limit", type=int, default=20)
457
+ rl.set_defaults(func=cmd_runs_list)
458
+
459
+ rg = runs.add_parser("get", help="one run's status", parents=[common])
460
+ rg.add_argument("run_id")
461
+ rg.set_defaults(func=cmd_runs_get)
462
+
463
+ rc = runs.add_parser("create", help="start a run", parents=[common])
464
+ rc.add_argument("--query")
465
+ rc.add_argument("--seeds", help="comma-separated names or URLs")
466
+ rc.add_argument("--depth", default="standard", choices=["standard", "max"])
467
+ rc.add_argument("--dry-run", action="store_true", help="free simulated run (~15s)")
468
+ rc.add_argument("--no-monitor", dest="monitored", action="store_false",
469
+ help="one-off; don't bill ongoing freshness")
470
+ rc.add_argument("--wait", action="store_true", help="poll until it finishes")
471
+ rc.add_argument("--interval", type=int, default=15)
472
+ rc.add_argument("--timeout", type=int, default=3600)
473
+ rc.add_argument("--idempotency-key")
474
+ rc.add_argument("-y", "--yes", action="store_true", help="skip the billable-run confirmation")
475
+ rc.set_defaults(func=cmd_runs_create)
476
+
477
+ rw = runs.add_parser("wait", help="poll an existing run to completion", parents=[common])
478
+ rw.add_argument("run_id")
479
+ rw.add_argument("--interval", type=int, default=15)
480
+ rw.add_argument("--timeout", type=int, default=3600)
481
+ rw.set_defaults(func=cmd_runs_wait)
482
+
483
+ rx = runs.add_parser("cancel", help="cancel an in-flight run", parents=[common])
484
+ rx.add_argument("run_id")
485
+ rx.set_defaults(func=cmd_runs_cancel)
486
+
487
+ data = sub.add_parser("data", help="your cited dataset", parents=[common]).add_subparsers(dest="sub", required=True)
488
+
489
+ dc = data.add_parser("catalog", help="entities and columns (never guess these)", parents=[common])
490
+ dc.set_defaults(func=cmd_data_catalog)
491
+
492
+ dg = data.add_parser("get", help="rows for one entity", parents=[common])
493
+ dg.add_argument("entity")
494
+ dg.add_argument("--limit", type=int, default=25)
495
+ dg.add_argument("--cursor")
496
+ dg.add_argument("--updated-since")
497
+ dg.add_argument("--receipts", action="store_true", help="include per-cell citations")
498
+ dg.set_defaults(func=cmd_data_get)
499
+
500
+ setup = sub.add_parser("setup", help="connect other tools", parents=[common]).add_subparsers(
501
+ dest="sub", required=True
502
+ )
503
+ sm = setup.add_parser("mcp", help="MCP server registration for this origin", parents=[common])
504
+ sm.add_argument("--name", help="server name to register")
505
+ sm.set_defaults(func=cmd_setup_mcp)
506
+
507
+ return p
508
+
509
+
510
+ def main(argv: list[str] | None = None) -> int:
511
+ parser = build_parser()
512
+ args = parser.parse_args(argv)
513
+ # SUPPRESS means the attrs only exist when passed; normalise once here so
514
+ # every command can read them unconditionally.
515
+ for attr, default in (("json", False), ("profile", None)):
516
+ if not hasattr(args, attr):
517
+ setattr(args, attr, default)
518
+ try:
519
+ return args.func(args)
520
+ except SystemExit as exc: # _require_auth and friends
521
+ return int(exc.code) if exc.code is not None else EXIT_FAIL
522
+ except KeyboardInterrupt:
523
+ print()
524
+ return EXIT_FAIL
525
+ except ApiError as exc:
526
+ err(str(exc))
527
+ return EXIT_FAIL
528
+
529
+
530
+ if __name__ == "__main__": # pragma: no cover
531
+ sys.exit(main())
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: deepsieve-cli
3
+ Version: 0.1.0
4
+ Summary: DeepSieve CLI — run cited deep research from your terminal
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: httpx>=0.27
7
+ Description-Content-Type: text/markdown
8
+
9
+ # deepsieve-cli
10
+
11
+ Run cited deep research from your terminal.
12
+
13
+ ```bash
14
+ uv tool install deepsieve-cli # or: pipx install deepsieve-cli
15
+ deepsieve login
16
+ ```
17
+
18
+ `login` opens your browser, you approve a short code, and the CLI stores a
19
+ scoped API key. Nothing to copy, nothing pasted into your shell history. The
20
+ credential appears in **Settings → API keys** on your deployment and can be
21
+ revoked there at any time.
22
+
23
+ ## Commands
24
+
25
+ ```bash
26
+ deepsieve whoami # identity, workspace, scopes
27
+ deepsieve runs create --query "..." --dry-run # free simulated run (~15s)
28
+ deepsieve runs create --query "..." --wait # real run: spends credits
29
+ deepsieve runs list
30
+ deepsieve runs get <id> # exit 4 while still running
31
+ deepsieve runs cancel <id>
32
+ deepsieve data catalog # entities + columns (never guess)
33
+ deepsieve data get companies --receipts # rows with per-cell citations
34
+ deepsieve setup mcp # MCP registration for this origin
35
+ ```
36
+
37
+ Add `--json` to anything for machine-readable output.
38
+
39
+ ## Profiles: several deployments side by side
40
+
41
+ ```bash
42
+ deepsieve login --profile staging --origin https://staging.deepsieve.ai
43
+ deepsieve --profile staging runs list
44
+ ```
45
+
46
+ Each profile is its own `(origin, credential)` pair, so staging and production
47
+ never overwrite one another. `DEEPSIEVE_PROFILE` sets the default.
48
+
49
+ ## CI and containers
50
+
51
+ Skip `login` entirely — set `DEEPSIEVE_API_KEY` (and `DEEPSIEVE_BASE_URL` if not
52
+ production). Environment variables take precedence over any stored profile.
53
+
54
+ ```bash
55
+ DEEPSIEVE_API_KEY=ds_live_... deepsieve --json data get companies
56
+ ```
57
+
58
+ ## What it deliberately cannot do
59
+
60
+ No billing, no organization or member management, no API-key management, and it
61
+ cannot edit an active Blueprint. Those are decisions for a human in the app —
62
+ and the restriction is enforced server-side by the credential's scopes, not just
63
+ by the absence of a subcommand.
64
+
65
+ Starting a real (billable) run asks for confirmation, and refuses outright in a
66
+ non-interactive shell unless you pass `--yes`.
67
+
68
+ ## Exit codes
69
+
70
+ | Code | Meaning |
71
+ |---|---|
72
+ | 0 | success |
73
+ | 1 | failure |
74
+ | 2 | usage error |
75
+ | 3 | not authenticated |
76
+ | 4 | run still in progress |
77
+
78
+ Full docs: <https://deepsieve.ai/developers/cli>
@@ -0,0 +1,9 @@
1
+ deepsieve_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ deepsieve_cli/__main__.py,sha256=I0WVsFDX16-IxsgTiX1tUcx9ypuiTkokIaLK2fU5RpI,53
3
+ deepsieve_cli/client.py,sha256=jcQAZvMm_mLkC8iIBkSSP_94zY5ry8bVdXQwOw6NkYo,5679
4
+ deepsieve_cli/config.py,sha256=YygQfIs0u03BXuU-NQvL0GaDhkmRd2_uvOpis4i74xw,3453
5
+ deepsieve_cli/main.py,sha256=8hL5eFWM420imOIRE7NAulMRCakxHgMauDhdr3fTRVI,19937
6
+ deepsieve_cli-0.1.0.dist-info/METADATA,sha256=JYciuIPwIqA1aPYHOqDwbRukx6ajq5B7tfdBIR0STd0,2515
7
+ deepsieve_cli-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
8
+ deepsieve_cli-0.1.0.dist-info/entry_points.txt,sha256=yJCcmxJr4LGXwLTa8j4QfSM_rNx_0fvE0cttFyi9OhM,54
9
+ deepsieve_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ deepsieve = deepsieve_cli.main:main