deepsieve-cli 0.1.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.
@@ -0,0 +1,116 @@
1
+ # =========================================================================
2
+ # AI Project Template — .gitignore
3
+ # Trim or extend per project, but treat the secrets/build/data sections as
4
+ # load-bearing: removing them is what causes accidental credential commits.
5
+ # =========================================================================
6
+
7
+ # --- Secrets & local config ---
8
+ .env
9
+ .env.local
10
+ .env.*.local
11
+ .env.development
12
+ .env.staging
13
+ .env.production
14
+ .env.backup.*
15
+
16
+ # --- AWS credentials ---
17
+ .aws/
18
+
19
+ # --- Node / Next.js (site) ---
20
+ site/node_modules/
21
+ site/.next/
22
+ site/.next-*/
23
+ site/out/
24
+ site/public/assets/*
25
+ !site/public/assets/.gitkeep
26
+ *.tsbuildinfo
27
+
28
+ # --- Python (api) ---
29
+ __pycache__/
30
+ *.pyc
31
+ *.pyo
32
+ *.pyd
33
+ .Python
34
+ .venv/
35
+ venv/
36
+ env/
37
+ .pytest_cache/
38
+ .mypy_cache/
39
+ .ruff_cache/
40
+ *.egg-info/
41
+ dist/
42
+ build/
43
+
44
+ # --- App database / local data ---
45
+ *.db
46
+ *.sqlite
47
+ *.sqlite3
48
+ app-db/data/
49
+ data/
50
+
51
+ # --- Docker / LocalStack ---
52
+ ops/localstack/volume/
53
+ localstack-volume/
54
+
55
+ # --- Langfuse local artifacts ---
56
+ ops/langfuse/storage/
57
+ ops/langfuse/turbo-cache/
58
+
59
+ # --- CloudFormation outputs / task defs ---
60
+ task-def*.json
61
+ new-task-def*.json
62
+ task_def_out.json
63
+ cloudformation-outputs/
64
+ backups/cloudformation-*.yaml
65
+ scripts/aws/*.log
66
+
67
+ # --- OS / editor noise ---
68
+ .DS_Store
69
+ Thumbs.db
70
+ .vscode/
71
+ .idea/
72
+ *.swp
73
+ *:Zone.Identifier
74
+
75
+ # --- Cursor / Claude local settings (keep settings.json, drop settings.local.json) ---
76
+ .cursor/*
77
+ .claude/settings.local.json
78
+ .claude/scheduled_tasks.lock
79
+
80
+ # --- Test outputs ---
81
+ coverage/
82
+ .coverage
83
+ htmlcov/
84
+ junit.xml
85
+
86
+ # --- Tooling caches ---
87
+ .next/
88
+ .turbo/
89
+ .playwright-mcp/
90
+
91
+ # --- Manual-testing artifacts (screenshots, diff captures, etc.) ---
92
+ # These accumulate at repo-root during ad-hoc UI testing and shouldn't
93
+ # land in commits. If you need to keep a screenshot, move it under
94
+ # docs/ or supplemental/ with an explicit name.
95
+ /*.png
96
+ /*.jpg
97
+ /*.jpeg
98
+
99
+ # Playwright snapshot artifacts
100
+ *.playwright-mcp/
101
+ editor-*.md
102
+ builder*.md
103
+
104
+ # onboarding stress-test scratch harness
105
+ _stress/
106
+ api/_stress/
107
+
108
+ # Local user-testing artifacts (reports, screenshots, transcripts) — not shipped.
109
+ # Root-anchored: an unanchored `user-testing/` would also swallow the reusable
110
+ # skill at .claude/skills/user-testing/, which IS committed.
111
+ /user-testing/
112
+
113
+ # Stray Playwright accessibility-snapshot dumps from browser-driven QA.
114
+ ctx*-run.md
115
+ page-*.yml
116
+ user-testing/
@@ -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,70 @@
1
+ # deepsieve-cli
2
+
3
+ Run cited deep research from your terminal.
4
+
5
+ ```bash
6
+ uv tool install deepsieve-cli # or: pipx install deepsieve-cli
7
+ deepsieve login
8
+ ```
9
+
10
+ `login` opens your browser, you approve a short code, and the CLI stores a
11
+ scoped API key. Nothing to copy, nothing pasted into your shell history. The
12
+ credential appears in **Settings → API keys** on your deployment and can be
13
+ revoked there at any time.
14
+
15
+ ## Commands
16
+
17
+ ```bash
18
+ deepsieve whoami # identity, workspace, scopes
19
+ deepsieve runs create --query "..." --dry-run # free simulated run (~15s)
20
+ deepsieve runs create --query "..." --wait # real run: spends credits
21
+ deepsieve runs list
22
+ deepsieve runs get <id> # exit 4 while still running
23
+ deepsieve runs cancel <id>
24
+ deepsieve data catalog # entities + columns (never guess)
25
+ deepsieve data get companies --receipts # rows with per-cell citations
26
+ deepsieve setup mcp # MCP registration for this origin
27
+ ```
28
+
29
+ Add `--json` to anything for machine-readable output.
30
+
31
+ ## Profiles: several deployments side by side
32
+
33
+ ```bash
34
+ deepsieve login --profile staging --origin https://staging.deepsieve.ai
35
+ deepsieve --profile staging runs list
36
+ ```
37
+
38
+ Each profile is its own `(origin, credential)` pair, so staging and production
39
+ never overwrite one another. `DEEPSIEVE_PROFILE` sets the default.
40
+
41
+ ## CI and containers
42
+
43
+ Skip `login` entirely — set `DEEPSIEVE_API_KEY` (and `DEEPSIEVE_BASE_URL` if not
44
+ production). Environment variables take precedence over any stored profile.
45
+
46
+ ```bash
47
+ DEEPSIEVE_API_KEY=ds_live_... deepsieve --json data get companies
48
+ ```
49
+
50
+ ## What it deliberately cannot do
51
+
52
+ No billing, no organization or member management, no API-key management, and it
53
+ cannot edit an active Blueprint. Those are decisions for a human in the app —
54
+ and the restriction is enforced server-side by the credential's scopes, not just
55
+ by the absence of a subcommand.
56
+
57
+ Starting a real (billable) run asks for confirmation, and refuses outright in a
58
+ non-interactive shell unless you pass `--yes`.
59
+
60
+ ## Exit codes
61
+
62
+ | Code | Meaning |
63
+ |---|---|
64
+ | 0 | success |
65
+ | 1 | failure |
66
+ | 2 | usage error |
67
+ | 3 | not authenticated |
68
+ | 4 | run still in progress |
69
+
70
+ Full docs: <https://deepsieve.ai/developers/cli>
@@ -0,0 +1,17 @@
1
+ [project]
2
+ name = "deepsieve-cli"
3
+ version = "0.1.0"
4
+ description = "DeepSieve CLI — run cited deep research from your terminal"
5
+ requires-python = ">=3.11"
6
+ readme = "README.md"
7
+ dependencies = ["httpx>=0.27"]
8
+
9
+ [project.scripts]
10
+ deepsieve = "deepsieve_cli.main:main"
11
+
12
+ [build-system]
13
+ requires = ["hatchling"]
14
+ build-backend = "hatchling.build"
15
+
16
+ [tool.hatch.build.targets.wheel]
17
+ packages = ["src/deepsieve_cli"]
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"]