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.
- deepsieve_cli-0.1.0/.gitignore +116 -0
- deepsieve_cli-0.1.0/PKG-INFO +78 -0
- deepsieve_cli-0.1.0/README.md +70 -0
- deepsieve_cli-0.1.0/pyproject.toml +17 -0
- deepsieve_cli-0.1.0/src/deepsieve_cli/__init__.py +0 -0
- deepsieve_cli-0.1.0/src/deepsieve_cli/__main__.py +5 -0
- deepsieve_cli-0.1.0/src/deepsieve_cli/client.py +149 -0
- deepsieve_cli-0.1.0/src/deepsieve_cli/config.py +116 -0
- deepsieve_cli-0.1.0/src/deepsieve_cli/main.py +531 -0
- deepsieve_cli-0.1.0/tests/test_cli.py +356 -0
|
@@ -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,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"]
|