querri 1.0.2__tar.gz → 1.0.7__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.
- {querri-1.0.2 → querri-1.0.7}/CLAUDE.md +23 -0
- {querri-1.0.2 → querri-1.0.7}/PKG-INFO +1 -1
- querri-1.0.7/TODO.md +53 -0
- {querri-1.0.2 → querri-1.0.7}/pyproject.toml +4 -1
- {querri-1.0.2 → querri-1.0.7}/querri/_auth.py +74 -34
- querri-1.0.7/querri/_version.py +1 -0
- {querri-1.0.2 → querri-1.0.7}/querri/cli/_app.py +8 -0
- {querri-1.0.2 → querri-1.0.7}/querri/cli/_output.py +7 -0
- {querri-1.0.2 → querri-1.0.7}/querri/cli/auth.py +12 -36
- {querri-1.0.2 → querri-1.0.7}/querri/cli/embed.py +11 -1
- {querri-1.0.2 → querri-1.0.7}/querri/cli/projects.py +10 -2
- {querri-1.0.2 → querri-1.0.7}/querri/cli/sharing.py +2 -2
- {querri-1.0.2 → querri-1.0.7}/querri/cli/views.py +162 -14
- {querri-1.0.2 → querri-1.0.7}/querri/resources/views.py +187 -14
- querri-1.0.7/scripts/release.sh +101 -0
- {querri-1.0.2 → querri-1.0.7}/skills/querri-cli/SKILL.md +14 -1
- {querri-1.0.2 → querri-1.0.7}/tests/test_cli.py +2 -1
- {querri-1.0.2 → querri-1.0.7}/tests/test_cli_commands.py +102 -8
- {querri-1.0.2 → querri-1.0.7}/tests/test_config.py +2 -1
- {querri-1.0.2 → querri-1.0.7}/tests/test_resources.py +156 -11
- querri-1.0.2/querri/_version.py +0 -1
- {querri-1.0.2 → querri-1.0.7}/.github/CONTRIBUTING.md +0 -0
- {querri-1.0.2 → querri-1.0.7}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {querri-1.0.2 → querri-1.0.7}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {querri-1.0.2 → querri-1.0.7}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {querri-1.0.2 → querri-1.0.7}/.github/SECURITY.md +0 -0
- {querri-1.0.2 → querri-1.0.7}/.github/workflows/ci.yml +0 -0
- {querri-1.0.2 → querri-1.0.7}/.github/workflows/release.yml +0 -0
- {querri-1.0.2 → querri-1.0.7}/.gitignore +0 -0
- {querri-1.0.2 → querri-1.0.7}/README.md +0 -0
- {querri-1.0.2 → querri-1.0.7}/docs/server-sdk.md +0 -0
- {querri-1.0.2 → querri-1.0.7}/examples/01_quick_start.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/examples/02_user_management.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/examples/03_access_policies.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/examples/04_embed_session.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/examples/05_get_session.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/examples/06_projects.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/examples/07_dashboards.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/examples/08_chat_streaming.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/examples/09_async_client.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/examples/10_error_handling.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/examples/11_user_scoped_client.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/examples/12_data_operations.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/examples/web-embed/index.html +0 -0
- {querri-1.0.2 → querri-1.0.7}/examples/web-embed/server.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/__init__.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/_base_client.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/_client.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/_config.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/_convenience.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/_exceptions.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/_pagination.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/_streaming.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/_user_client.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/cli/__init__.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/cli/_context.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/cli/_image.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/cli/audit.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/cli/chat.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/cli/chats.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/cli/dashboards.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/cli/files.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/cli/keys.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/cli/policies.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/cli/sources.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/cli/steps.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/cli/usage.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/cli/users.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/cli/whoami.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/py.typed +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/resources/__init__.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/resources/audit.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/resources/dashboards.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/resources/embed.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/resources/files.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/resources/keys.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/resources/policies.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/resources/projects.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/resources/sharing.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/resources/sources.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/resources/usage.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/resources/users.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/types/__init__.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/types/audit.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/types/chat.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/types/dashboard.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/types/data.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/types/embed.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/types/file.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/types/key.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/types/policy.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/types/project.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/types/sharing.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/types/usage.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/querri/types/user.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/skills/README.md +0 -0
- {querri-1.0.2 → querri-1.0.7}/skills/querri-cli/references/json-shapes.md +0 -0
- {querri-1.0.2 → querri-1.0.7}/skills/querri-cli/references/troubleshooting.md +0 -0
- {querri-1.0.2 → querri-1.0.7}/tests/__init__.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/tests/conftest.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/tests/rls-integration-results.md +0 -0
- {querri-1.0.2 → querri-1.0.7}/tests/test_client.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/tests/test_convenience.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/tests/test_exceptions.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/tests/test_integration.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/tests/test_new_features.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/tests/test_pagination.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/tests/test_rls_integration.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/tests/test_security.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/tests/test_streaming.py +0 -0
- {querri-1.0.2 → querri-1.0.7}/tests/test_streaming_v2.py +0 -0
|
@@ -91,3 +91,26 @@ Every resource, pagination class, and stream has mirrored sync/async implementat
|
|
|
91
91
|
- **mypy**: Strict mode, target Python 3.9
|
|
92
92
|
- **ruff**: Rules E, F, I, N, UP, B, SIM (target Python 3.9)
|
|
93
93
|
- **Tests**: pytest + pytest-asyncio (mode: auto) + respx for HTTP mocking
|
|
94
|
+
|
|
95
|
+
## Releasing
|
|
96
|
+
|
|
97
|
+
Single source of truth: `querri/_version.py`. `pyproject.toml` reads it via `[tool.hatch.version]` — never edit a version string anywhere else.
|
|
98
|
+
|
|
99
|
+
To cut a release:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
./scripts/release.sh X.Y.Z
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The script: stashes uncommitted work, bumps `_version.py`, runs ruff + mypy + pytest, commits as "Bump version to X.Y.Z", tags `vX.Y.Z`, pushes main + tag, restores the stash. Pushing the tag triggers `.github/workflows/release.yml`, which re-runs CI and (on green) publishes to PyPI via Trusted Publisher.
|
|
106
|
+
|
|
107
|
+
Verify after release:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
gh run watch $(gh run list --workflow=release.yml --limit 1 --json databaseId -q '.[0].databaseId')
|
|
111
|
+
curl -s https://pypi.org/pypi/querri/json | python3 -c "import json,sys;print(json.load(sys.stdin)['info']['version'])"
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
If `release.yml` fails, fix forward and bump to the next patch — never re-tag a published version. PyPI rejects republishing the same version even if the previous attempt failed mid-flight.
|
|
115
|
+
|
|
116
|
+
Tests assert the CLI's `--version` output and `user_agent` against `__version__` dynamically (`tests/test_cli.py`, `tests/test_config.py`) — if you ever see a hardcoded version string in a test, that's a bug; replace it with `__version__`.
|
querri-1.0.7/TODO.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# CLI QA Findings
|
|
2
|
+
|
|
3
|
+
Issues found during end-to-end QA of every CLI command (2026-04-14).
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Fixed
|
|
8
|
+
|
|
9
|
+
### ✅ `querri session list` — timestamp displayed as Unix epoch float
|
|
10
|
+
`created_at` was a float (e.g. `1776171557.8591616`). Now formatted as `2026-04-14 15:42 UTC`.
|
|
11
|
+
**Fix:** `querri/cli/embed.py` — pre-process row dicts before passing to `print_table`.
|
|
12
|
+
|
|
13
|
+
### ✅ `querri project select` — multiple matches broke non-interactive mode
|
|
14
|
+
Was falling through to the interactive picker loop and auto-selecting [1] via `EOFError` catch,
|
|
15
|
+
printing a noisy prompt. Now errors cleanly: "Ambiguous name '…' matches N projects. Use a project UUID."
|
|
16
|
+
**Fix:** `querri/cli/projects.py` — check `obj["interactive"]` before showing the picker.
|
|
17
|
+
|
|
18
|
+
### ✅ `querri share source list` / `remove` — HTTP 405
|
|
19
|
+
GET and DELETE endpoints for `/sources/{id}/shares` were never wired up on the server,
|
|
20
|
+
even though the `_list_shares` and `_revoke_share` helpers existed.
|
|
21
|
+
**Fix:** `Querri/server-api/api/public_api/routes/sharing.py` — added `list_source_shares` (GET)
|
|
22
|
+
and `revoke_source_share` (DELETE) routes, mirroring the project/dashboard pattern.
|
|
23
|
+
|
|
24
|
+
### ✅ `querri share source org` — HTTP 404
|
|
25
|
+
CLI was calling `/sources/{id}/shares/org` but the server route is `/sources/{id}/org-share`.
|
|
26
|
+
Also the request body was missing the `enabled` field that the server expects.
|
|
27
|
+
**Fix:** `querri/cli/sharing.py` — corrected path and added `"enabled": True` to the body.
|
|
28
|
+
|
|
29
|
+
### ✅ 501 errors — cryptic "HTTP 501" message
|
|
30
|
+
`dashboard new`, `dashboard delete`, `source sync` all hit intentional 501s (FGA warrant
|
|
31
|
+
creation/cleanup and Redis job queue not yet implemented server-side). Error message was
|
|
32
|
+
just "Error: HTTP 501 / Request ID: …".
|
|
33
|
+
**Fix:** `querri/cli/_output.py` — 501 now prints:
|
|
34
|
+
"This feature is not yet available via the API. Use the Querri web app instead."
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Needs Server Infrastructure Work (out of scope for CLI)
|
|
39
|
+
|
|
40
|
+
### ⏳ `querri dashboard new` / `dashboard delete` — HTTP 501
|
|
41
|
+
Intentional. Server has FGA warrant creation (`fga.create_warrant()`) and cleanup
|
|
42
|
+
(`fga.delete_warrants_for_resource()`) not yet implemented. Until the FGA service is
|
|
43
|
+
updated, dashboard create/delete must go through the web app.
|
|
44
|
+
|
|
45
|
+
### ⏳ `querri source sync` — HTTP 501
|
|
46
|
+
Intentional. Server is missing `RedisStreamsClient.enqueue_source_job()`. The infrastructure
|
|
47
|
+
for submitting sync jobs to the Redis queue hasn't been built out yet.
|
|
48
|
+
|
|
49
|
+
### ⏳ `querri step data` — HTTP 404 (qdf_not_found)
|
|
50
|
+
`step list` correctly reports `has_data: True` but `/steps/{id}/data` returns 404.
|
|
51
|
+
The QDF metadata or parquet file is absent in storage despite the step showing data.
|
|
52
|
+
Likely a garbage collection or race condition in the data pipeline — needs investigation
|
|
53
|
+
in the server's QDF save/load path.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "querri"
|
|
7
|
-
|
|
7
|
+
dynamic = ["version"]
|
|
8
8
|
description = "Python SDK and CLI for the Querri data analysis platform"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -57,6 +57,9 @@ Homepage = "https://querri.com"
|
|
|
57
57
|
Documentation = "https://docs.querri.com/sdk/python"
|
|
58
58
|
Repository = "https://github.com/querri-ai/querri-python"
|
|
59
59
|
|
|
60
|
+
[tool.hatch.version]
|
|
61
|
+
path = "querri/_version.py"
|
|
62
|
+
|
|
60
63
|
[tool.hatch.build.targets.wheel]
|
|
61
64
|
packages = ["querri"]
|
|
62
65
|
|
|
@@ -23,10 +23,8 @@ import httpx
|
|
|
23
23
|
# Constants
|
|
24
24
|
# ---------------------------------------------------------------------------
|
|
25
25
|
|
|
26
|
-
WORKOS_CLIENT_ID = "client_01HGV989QK3KDNN4JJNGCD87RH"
|
|
27
|
-
WORKOS_AUTHORIZE_URL = "https://api.workos.com/user_management/authorize"
|
|
28
26
|
OAUTH_TIMEOUT_SECONDS = 120
|
|
29
|
-
OAUTH_CALLBACK_PORT = 11847 # Fixed port
|
|
27
|
+
OAUTH_CALLBACK_PORT = 11847 # Fixed port; registered as allowed redirect URI
|
|
30
28
|
REFRESH_BUFFER_SECONDS = 300 # 5 minutes
|
|
31
29
|
|
|
32
30
|
|
|
@@ -177,8 +175,9 @@ class TokenStore:
|
|
|
177
175
|
}
|
|
178
176
|
content = json.dumps(data, indent=2)
|
|
179
177
|
|
|
180
|
-
# Atomic write: create temp file
|
|
181
|
-
|
|
178
|
+
# Atomic write: create a unique temp file (pid-stamped to avoid races
|
|
179
|
+
# when multiple querri processes run concurrently), then rename.
|
|
180
|
+
tmp_path_obj = self.STORE_DIR / f".tokens_tmp_{os.getpid()}"
|
|
182
181
|
try:
|
|
183
182
|
# Create file with restricted permissions — no race window
|
|
184
183
|
fd = os.open(
|
|
@@ -438,7 +437,7 @@ rel="stylesheet">
|
|
|
438
437
|
color:#c5c8cb;
|
|
439
438
|
display:flex;align-items:center;justify-content:center;
|
|
440
439
|
min-height:100vh;
|
|
441
|
-
overflow:
|
|
440
|
+
overflow:auto;
|
|
442
441
|
}
|
|
443
442
|
/* animated grid background */
|
|
444
443
|
body::before{
|
|
@@ -594,8 +593,7 @@ rel="stylesheet">
|
|
|
594
593
|
<div class="section-label typing">create & upload</div>
|
|
595
594
|
<div class="cmd typing">
|
|
596
595
|
<span class="prompt">$</span>
|
|
597
|
-
<span class="command"> querri
|
|
598
|
-
<span class="flag"> --name</span>
|
|
596
|
+
<span class="command"> querri project new</span>
|
|
599
597
|
<span class="string"> "Q1 Analysis"</span>
|
|
600
598
|
</div>
|
|
601
599
|
<div class="cmd typing">
|
|
@@ -608,14 +606,8 @@ rel="stylesheet">
|
|
|
608
606
|
<div class="section-label typing">chat with your data</div>
|
|
609
607
|
<div class="cmd typing">
|
|
610
608
|
<span class="prompt">$</span>
|
|
611
|
-
<span class="command"> querri
|
|
612
|
-
<span class="
|
|
613
|
-
</div>
|
|
614
|
-
<div class="cmd typing">
|
|
615
|
-
<span class="prompt">$</span>
|
|
616
|
-
<span class="command"> querri chats stream</span>
|
|
617
|
-
<span class="arg"> <project> <chat></span>
|
|
618
|
-
<span class="flag"> --prompt</span>
|
|
609
|
+
<span class="command"> querri project chat</span>
|
|
610
|
+
<span class="flag"> -m</span>
|
|
619
611
|
<span class="string"> "summarize revenue by region"</span>
|
|
620
612
|
</div>
|
|
621
613
|
|
|
@@ -660,6 +652,43 @@ class _OAuthCallbackServer(HTTPServer):
|
|
|
660
652
|
self.handle_request()
|
|
661
653
|
|
|
662
654
|
|
|
655
|
+
def _fetch_well_known(host: str) -> dict[str, Any]:
|
|
656
|
+
"""Fetch RFC 8414 OAuth authorization server metadata from the Querri server.
|
|
657
|
+
|
|
658
|
+
Args:
|
|
659
|
+
host: The Querri server host (e.g. ``https://app.querri.com``).
|
|
660
|
+
|
|
661
|
+
Returns:
|
|
662
|
+
Parsed JSON dict with ``authorization_endpoint``, ``token_endpoint``, etc.
|
|
663
|
+
|
|
664
|
+
Raises:
|
|
665
|
+
RuntimeError: If the metadata endpoint cannot be reached or returns an error.
|
|
666
|
+
"""
|
|
667
|
+
url = host.rstrip("/") + "/.well-known/oauth-authorization-server"
|
|
668
|
+
try:
|
|
669
|
+
response = httpx.get(url, timeout=10.0)
|
|
670
|
+
response.raise_for_status()
|
|
671
|
+
return response.json() # type: ignore[no-any-return]
|
|
672
|
+
except Exception as exc:
|
|
673
|
+
raise RuntimeError(
|
|
674
|
+
f"Failed to fetch server metadata from {url}: {exc}"
|
|
675
|
+
) from exc
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _generate_pkce() -> tuple[str, str]:
|
|
679
|
+
"""Generate a PKCE code_verifier and S256 code_challenge pair.
|
|
680
|
+
|
|
681
|
+
Returns:
|
|
682
|
+
Tuple of ``(code_verifier, code_challenge)``.
|
|
683
|
+
"""
|
|
684
|
+
import hashlib
|
|
685
|
+
|
|
686
|
+
code_verifier = secrets.token_urlsafe(64)
|
|
687
|
+
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
|
|
688
|
+
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
689
|
+
return code_verifier, code_challenge
|
|
690
|
+
|
|
691
|
+
|
|
663
692
|
def start_oauth_flow(
|
|
664
693
|
host: str,
|
|
665
694
|
callback: Any = None,
|
|
@@ -668,19 +697,21 @@ def start_oauth_flow(
|
|
|
668
697
|
) -> dict[str, Any]:
|
|
669
698
|
"""Run the browser-based OAuth login flow.
|
|
670
699
|
|
|
671
|
-
1.
|
|
672
|
-
2.
|
|
673
|
-
3.
|
|
674
|
-
4.
|
|
700
|
+
1. Fetches ``/.well-known/oauth-authorization-server`` to discover endpoints
|
|
701
|
+
2. Starts a local HTTP server on 127.0.0.1:11847 (fixed port)
|
|
702
|
+
3. Opens the browser to the Querri authorize endpoint (which proxies to WorkOS)
|
|
703
|
+
4. Waits for the server to redirect back with an ``mcp_``-prefixed code
|
|
704
|
+
5. Exchanges the code for tokens using PKCE (no client secret needed)
|
|
675
705
|
|
|
676
706
|
Args:
|
|
677
707
|
host: The Querri server host (e.g. ``https://app.querri.com``).
|
|
678
708
|
callback: Unused — reserved for future progress callbacks.
|
|
679
|
-
organization_id: Optional
|
|
709
|
+
organization_id: Optional organization ID to scope the login. Required
|
|
710
|
+
for users who belong to multiple organizations.
|
|
680
711
|
|
|
681
712
|
Returns:
|
|
682
713
|
Dict with ``access_token``, ``refresh_token``, ``expires_at``,
|
|
683
|
-
``user_id``, ``org_id``, ``user_email``.
|
|
714
|
+
``user_id``, ``org_id``, ``user_email``, ``all_organizations``.
|
|
684
715
|
|
|
685
716
|
Raises:
|
|
686
717
|
RuntimeError: If not in an interactive terminal, or if the flow
|
|
@@ -692,28 +723,38 @@ def start_oauth_flow(
|
|
|
692
723
|
"For CI/scripts, use QUERRI_API_KEY instead."
|
|
693
724
|
)
|
|
694
725
|
|
|
695
|
-
#
|
|
726
|
+
# Discover endpoints from the server
|
|
727
|
+
well_known = _fetch_well_known(host)
|
|
728
|
+
authorize_endpoint = well_known.get("authorization_endpoint", "")
|
|
729
|
+
token_endpoint = well_known.get("token_endpoint", "")
|
|
730
|
+
if not authorize_endpoint or not token_endpoint:
|
|
731
|
+
raise RuntimeError(
|
|
732
|
+
f"Server at {host} did not return valid OAuth metadata "
|
|
733
|
+
"(missing authorization_endpoint or token_endpoint)."
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
# Generate PKCE and CSRF state
|
|
737
|
+
code_verifier, code_challenge = _generate_pkce()
|
|
696
738
|
state = secrets.token_urlsafe(32)
|
|
697
739
|
|
|
698
740
|
# Start local callback server
|
|
699
741
|
server = _OAuthCallbackServer(expected_state=state)
|
|
700
742
|
port = server.server_address[1]
|
|
701
|
-
|
|
702
743
|
redirect_uri = f"http://127.0.0.1:{port}/callback"
|
|
703
744
|
|
|
704
|
-
# Build authorize URL
|
|
705
|
-
client_id = os.environ.get("QUERRI_WORKOS_CLIENT_ID", WORKOS_CLIENT_ID)
|
|
745
|
+
# Build authorize URL — the server proxies to WorkOS using its own client_id
|
|
706
746
|
params: dict[str, str] = {
|
|
707
|
-
"client_id":
|
|
747
|
+
"client_id": "querri-cli",
|
|
708
748
|
"redirect_uri": redirect_uri,
|
|
709
749
|
"response_type": "code",
|
|
710
750
|
"state": state,
|
|
711
|
-
"
|
|
751
|
+
"scope": "",
|
|
752
|
+
"code_challenge": code_challenge,
|
|
753
|
+
"code_challenge_method": "S256",
|
|
712
754
|
}
|
|
713
755
|
if organization_id:
|
|
714
756
|
params["organization_id"] = organization_id
|
|
715
|
-
|
|
716
|
-
authorize_url = f"{WORKOS_AUTHORIZE_URL}?{authorize_params}"
|
|
757
|
+
authorize_url = f"{authorize_endpoint}?{urlencode(params)}"
|
|
717
758
|
|
|
718
759
|
# Open browser
|
|
719
760
|
print("Opening browser for authentication...", file=sys.stderr)
|
|
@@ -740,15 +781,14 @@ def start_oauth_flow(
|
|
|
740
781
|
f"{OAUTH_TIMEOUT_SECONDS} seconds."
|
|
741
782
|
)
|
|
742
783
|
|
|
743
|
-
# Exchange
|
|
744
|
-
token_url = host.rstrip("/") + "/api/v1/auth/cli/token"
|
|
784
|
+
# Exchange the mcp_ code for tokens using PKCE (no redirect_uri needed)
|
|
745
785
|
try:
|
|
746
786
|
response = httpx.post(
|
|
747
|
-
|
|
787
|
+
token_endpoint,
|
|
748
788
|
json={
|
|
749
789
|
"grant_type": "authorization_code",
|
|
750
790
|
"code": server.auth_code,
|
|
751
|
-
"
|
|
791
|
+
"code_verifier": code_verifier,
|
|
752
792
|
},
|
|
753
793
|
timeout=30.0,
|
|
754
794
|
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.7"
|
|
@@ -159,6 +159,14 @@ def _global_options(
|
|
|
159
159
|
# Determine interactive mode
|
|
160
160
|
is_interactive = interactive if interactive is not None else IS_INTERACTIVE
|
|
161
161
|
|
|
162
|
+
# Validate --host has a scheme
|
|
163
|
+
if host and not host.startswith(("http://", "https://")):
|
|
164
|
+
print(
|
|
165
|
+
f"Error: --host must include a scheme (e.g. https://{host}).",
|
|
166
|
+
file=sys.stderr,
|
|
167
|
+
)
|
|
168
|
+
raise typer.Exit(code=2)
|
|
169
|
+
|
|
162
170
|
# Warn about --api-key in shell history (only in interactive TTY mode)
|
|
163
171
|
if api_key and is_interactive:
|
|
164
172
|
import sys as _sys
|
|
@@ -253,6 +253,13 @@ def handle_api_error(exc: Exception, *, is_json: bool | None = False) -> int:
|
|
|
253
253
|
exit_code = EXIT_SERVER_ERROR
|
|
254
254
|
error_type = "server_error"
|
|
255
255
|
message = str(exc)
|
|
256
|
+
elif isinstance(exc, APIError) and exc.status == 501:
|
|
257
|
+
exit_code = EXIT_ERROR
|
|
258
|
+
error_type = "not_implemented"
|
|
259
|
+
message = (
|
|
260
|
+
"This feature is not yet available via the API. "
|
|
261
|
+
"Use the Querri web app instead."
|
|
262
|
+
)
|
|
256
263
|
else:
|
|
257
264
|
exit_code = EXIT_ERROR
|
|
258
265
|
error_type = "api_error"
|
|
@@ -124,6 +124,9 @@ def login(
|
|
|
124
124
|
``--organization org_01JBETJ7PYNGXVMXV0BD3CFNA8``
|
|
125
125
|
"""
|
|
126
126
|
host = host or _get_host(ctx)
|
|
127
|
+
if not host.startswith(("http://", "https://")):
|
|
128
|
+
print_error(f"--host must include a scheme (e.g. https://{host}).")
|
|
129
|
+
raise typer.Exit(code=2)
|
|
127
130
|
profile_name = _get_profile_name(ctx)
|
|
128
131
|
is_json = _is_json(ctx)
|
|
129
132
|
|
|
@@ -159,40 +162,6 @@ def login(
|
|
|
159
162
|
chosen_org_id = result["org_id"]
|
|
160
163
|
chosen_org_name = result.get("org_name", "")
|
|
161
164
|
|
|
162
|
-
# If the user didn't pass --organization and they belong to multiple orgs,
|
|
163
|
-
# show a terminal picker so they can choose which org to work in.
|
|
164
|
-
if not organization and len(all_orgs) > 1 and not is_json:
|
|
165
|
-
chosen_org_id, chosen_org_name = _pick_organization(
|
|
166
|
-
all_orgs,
|
|
167
|
-
current_org_id=result["org_id"],
|
|
168
|
-
)
|
|
169
|
-
# If they picked a different org, refresh tokens scoped to that org
|
|
170
|
-
if chosen_org_id != result["org_id"]:
|
|
171
|
-
try:
|
|
172
|
-
temp_profile = TokenProfile(
|
|
173
|
-
auth_type="jwt",
|
|
174
|
-
access_token=result["access_token"],
|
|
175
|
-
refresh_token=result["refresh_token"],
|
|
176
|
-
expires_at=result["expires_at"],
|
|
177
|
-
org_id=chosen_org_id,
|
|
178
|
-
org_name=chosen_org_name,
|
|
179
|
-
user_email=result["user_email"],
|
|
180
|
-
user_id=result["user_id"],
|
|
181
|
-
user_name=result.get("user_name", ""),
|
|
182
|
-
host=host,
|
|
183
|
-
)
|
|
184
|
-
temp_profile = refresh_tokens(
|
|
185
|
-
temp_profile,
|
|
186
|
-
host,
|
|
187
|
-
organization_id=chosen_org_id,
|
|
188
|
-
)
|
|
189
|
-
result["access_token"] = temp_profile.access_token
|
|
190
|
-
result["refresh_token"] = temp_profile.refresh_token
|
|
191
|
-
result["expires_at"] = temp_profile.expires_at
|
|
192
|
-
except RuntimeError as exc:
|
|
193
|
-
print_error(f"Failed to switch organization: {exc}")
|
|
194
|
-
raise typer.Exit(code=2) from None
|
|
195
|
-
|
|
196
165
|
# Save profile
|
|
197
166
|
profile = TokenProfile(
|
|
198
167
|
auth_type="jwt",
|
|
@@ -226,9 +195,16 @@ def login(
|
|
|
226
195
|
name = result.get("user_name")
|
|
227
196
|
if name:
|
|
228
197
|
identity = f"{name} ({identity})"
|
|
229
|
-
if chosen_org_name:
|
|
230
|
-
identity = f"{identity} — {chosen_org_name}"
|
|
231
198
|
print_success(f"Logged in as {identity}")
|
|
199
|
+
org_label = chosen_org_name or chosen_org_id or "(unknown)"
|
|
200
|
+
if chosen_org_id and chosen_org_name:
|
|
201
|
+
org_label = f"{chosen_org_name} ({chosen_org_id})"
|
|
202
|
+
print(f" Organization: {org_label}", file=sys.stderr)
|
|
203
|
+
if len(all_orgs) > 1:
|
|
204
|
+
print(
|
|
205
|
+
" To switch organizations, run: querri auth switch-org",
|
|
206
|
+
file=sys.stderr,
|
|
207
|
+
)
|
|
232
208
|
|
|
233
209
|
|
|
234
210
|
# ---------------------------------------------------------------------------
|
|
@@ -126,8 +126,18 @@ def list_sessions(
|
|
|
126
126
|
if obj.get("json"):
|
|
127
127
|
print_json(result)
|
|
128
128
|
else:
|
|
129
|
+
from datetime import datetime, timezone
|
|
130
|
+
|
|
131
|
+
rows = []
|
|
132
|
+
for s in result.data:
|
|
133
|
+
row = dict(s) if isinstance(s, dict) else s.__dict__.copy()
|
|
134
|
+
ts = row.get("created_at")
|
|
135
|
+
if isinstance(ts, (int, float)):
|
|
136
|
+
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
|
|
137
|
+
row["created_at"] = dt.strftime("%Y-%m-%d %H:%M UTC")
|
|
138
|
+
rows.append(row)
|
|
129
139
|
print_table(
|
|
130
|
-
|
|
140
|
+
rows,
|
|
131
141
|
[
|
|
132
142
|
("session_token", "Token"),
|
|
133
143
|
("user_id", "User ID"),
|
|
@@ -213,6 +213,13 @@ def select_project(
|
|
|
213
213
|
if obj.get("json"):
|
|
214
214
|
print_json([{"id": p.id, "name": p.name} for p in matches])
|
|
215
215
|
return
|
|
216
|
+
is_interactive = obj.get("interactive", sys.stdin.isatty())
|
|
217
|
+
if not is_interactive:
|
|
218
|
+
print_error(
|
|
219
|
+
f"Ambiguous name '{name_or_id}' matches {len(matches)} projects. "
|
|
220
|
+
"Use a project UUID or pass --json to list matches."
|
|
221
|
+
)
|
|
222
|
+
raise typer.Exit(code=1)
|
|
216
223
|
print(f"\nMultiple projects match '{name_or_id}':\n", file=sys.stderr)
|
|
217
224
|
for i, p in enumerate(matches, 1):
|
|
218
225
|
print(f" [{i}] {p.name} ({p.id})", file=sys.stderr)
|
|
@@ -221,7 +228,8 @@ def select_project(
|
|
|
221
228
|
try:
|
|
222
229
|
raw = input("Select [1]: ").strip() or "1"
|
|
223
230
|
except (EOFError, KeyboardInterrupt):
|
|
224
|
-
|
|
231
|
+
print(file=sys.stderr)
|
|
232
|
+
raise typer.Exit(code=0) from None
|
|
225
233
|
try:
|
|
226
234
|
idx = int(raw)
|
|
227
235
|
if 1 <= idx <= len(matches):
|
|
@@ -707,7 +715,7 @@ def _get_full_project(client: object, project_id: str) -> Project | None:
|
|
|
707
715
|
http = client._http # type: ignore[attr-defined]
|
|
708
716
|
# The SDK's request() method handles auth headers and retries
|
|
709
717
|
base_url = str(http._client.base_url)
|
|
710
|
-
internal_base = base_url.replace("/api/v1", "/api")
|
|
718
|
+
internal_base = base_url.replace("/api/v1", "/api").rstrip("/")
|
|
711
719
|
|
|
712
720
|
import httpx as _httpx
|
|
713
721
|
|
|
@@ -390,8 +390,8 @@ def org_source_share(
|
|
|
390
390
|
client = get_client(ctx)
|
|
391
391
|
try:
|
|
392
392
|
resp = client._http.post(
|
|
393
|
-
f"/sources/{source_id}/
|
|
394
|
-
json={"permission": permission},
|
|
393
|
+
f"/sources/{source_id}/org-share",
|
|
394
|
+
json={"enabled": True, "permission": permission},
|
|
395
395
|
)
|
|
396
396
|
result = resp.json()
|
|
397
397
|
except Exception as exc:
|