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.
Files changed (111) hide show
  1. {querri-1.0.2 → querri-1.0.7}/CLAUDE.md +23 -0
  2. {querri-1.0.2 → querri-1.0.7}/PKG-INFO +1 -1
  3. querri-1.0.7/TODO.md +53 -0
  4. {querri-1.0.2 → querri-1.0.7}/pyproject.toml +4 -1
  5. {querri-1.0.2 → querri-1.0.7}/querri/_auth.py +74 -34
  6. querri-1.0.7/querri/_version.py +1 -0
  7. {querri-1.0.2 → querri-1.0.7}/querri/cli/_app.py +8 -0
  8. {querri-1.0.2 → querri-1.0.7}/querri/cli/_output.py +7 -0
  9. {querri-1.0.2 → querri-1.0.7}/querri/cli/auth.py +12 -36
  10. {querri-1.0.2 → querri-1.0.7}/querri/cli/embed.py +11 -1
  11. {querri-1.0.2 → querri-1.0.7}/querri/cli/projects.py +10 -2
  12. {querri-1.0.2 → querri-1.0.7}/querri/cli/sharing.py +2 -2
  13. {querri-1.0.2 → querri-1.0.7}/querri/cli/views.py +162 -14
  14. {querri-1.0.2 → querri-1.0.7}/querri/resources/views.py +187 -14
  15. querri-1.0.7/scripts/release.sh +101 -0
  16. {querri-1.0.2 → querri-1.0.7}/skills/querri-cli/SKILL.md +14 -1
  17. {querri-1.0.2 → querri-1.0.7}/tests/test_cli.py +2 -1
  18. {querri-1.0.2 → querri-1.0.7}/tests/test_cli_commands.py +102 -8
  19. {querri-1.0.2 → querri-1.0.7}/tests/test_config.py +2 -1
  20. {querri-1.0.2 → querri-1.0.7}/tests/test_resources.py +156 -11
  21. querri-1.0.2/querri/_version.py +0 -1
  22. {querri-1.0.2 → querri-1.0.7}/.github/CONTRIBUTING.md +0 -0
  23. {querri-1.0.2 → querri-1.0.7}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  24. {querri-1.0.2 → querri-1.0.7}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  25. {querri-1.0.2 → querri-1.0.7}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  26. {querri-1.0.2 → querri-1.0.7}/.github/SECURITY.md +0 -0
  27. {querri-1.0.2 → querri-1.0.7}/.github/workflows/ci.yml +0 -0
  28. {querri-1.0.2 → querri-1.0.7}/.github/workflows/release.yml +0 -0
  29. {querri-1.0.2 → querri-1.0.7}/.gitignore +0 -0
  30. {querri-1.0.2 → querri-1.0.7}/README.md +0 -0
  31. {querri-1.0.2 → querri-1.0.7}/docs/server-sdk.md +0 -0
  32. {querri-1.0.2 → querri-1.0.7}/examples/01_quick_start.py +0 -0
  33. {querri-1.0.2 → querri-1.0.7}/examples/02_user_management.py +0 -0
  34. {querri-1.0.2 → querri-1.0.7}/examples/03_access_policies.py +0 -0
  35. {querri-1.0.2 → querri-1.0.7}/examples/04_embed_session.py +0 -0
  36. {querri-1.0.2 → querri-1.0.7}/examples/05_get_session.py +0 -0
  37. {querri-1.0.2 → querri-1.0.7}/examples/06_projects.py +0 -0
  38. {querri-1.0.2 → querri-1.0.7}/examples/07_dashboards.py +0 -0
  39. {querri-1.0.2 → querri-1.0.7}/examples/08_chat_streaming.py +0 -0
  40. {querri-1.0.2 → querri-1.0.7}/examples/09_async_client.py +0 -0
  41. {querri-1.0.2 → querri-1.0.7}/examples/10_error_handling.py +0 -0
  42. {querri-1.0.2 → querri-1.0.7}/examples/11_user_scoped_client.py +0 -0
  43. {querri-1.0.2 → querri-1.0.7}/examples/12_data_operations.py +0 -0
  44. {querri-1.0.2 → querri-1.0.7}/examples/web-embed/index.html +0 -0
  45. {querri-1.0.2 → querri-1.0.7}/examples/web-embed/server.py +0 -0
  46. {querri-1.0.2 → querri-1.0.7}/querri/__init__.py +0 -0
  47. {querri-1.0.2 → querri-1.0.7}/querri/_base_client.py +0 -0
  48. {querri-1.0.2 → querri-1.0.7}/querri/_client.py +0 -0
  49. {querri-1.0.2 → querri-1.0.7}/querri/_config.py +0 -0
  50. {querri-1.0.2 → querri-1.0.7}/querri/_convenience.py +0 -0
  51. {querri-1.0.2 → querri-1.0.7}/querri/_exceptions.py +0 -0
  52. {querri-1.0.2 → querri-1.0.7}/querri/_pagination.py +0 -0
  53. {querri-1.0.2 → querri-1.0.7}/querri/_streaming.py +0 -0
  54. {querri-1.0.2 → querri-1.0.7}/querri/_user_client.py +0 -0
  55. {querri-1.0.2 → querri-1.0.7}/querri/cli/__init__.py +0 -0
  56. {querri-1.0.2 → querri-1.0.7}/querri/cli/_context.py +0 -0
  57. {querri-1.0.2 → querri-1.0.7}/querri/cli/_image.py +0 -0
  58. {querri-1.0.2 → querri-1.0.7}/querri/cli/audit.py +0 -0
  59. {querri-1.0.2 → querri-1.0.7}/querri/cli/chat.py +0 -0
  60. {querri-1.0.2 → querri-1.0.7}/querri/cli/chats.py +0 -0
  61. {querri-1.0.2 → querri-1.0.7}/querri/cli/dashboards.py +0 -0
  62. {querri-1.0.2 → querri-1.0.7}/querri/cli/files.py +0 -0
  63. {querri-1.0.2 → querri-1.0.7}/querri/cli/keys.py +0 -0
  64. {querri-1.0.2 → querri-1.0.7}/querri/cli/policies.py +0 -0
  65. {querri-1.0.2 → querri-1.0.7}/querri/cli/sources.py +0 -0
  66. {querri-1.0.2 → querri-1.0.7}/querri/cli/steps.py +0 -0
  67. {querri-1.0.2 → querri-1.0.7}/querri/cli/usage.py +0 -0
  68. {querri-1.0.2 → querri-1.0.7}/querri/cli/users.py +0 -0
  69. {querri-1.0.2 → querri-1.0.7}/querri/cli/whoami.py +0 -0
  70. {querri-1.0.2 → querri-1.0.7}/querri/py.typed +0 -0
  71. {querri-1.0.2 → querri-1.0.7}/querri/resources/__init__.py +0 -0
  72. {querri-1.0.2 → querri-1.0.7}/querri/resources/audit.py +0 -0
  73. {querri-1.0.2 → querri-1.0.7}/querri/resources/dashboards.py +0 -0
  74. {querri-1.0.2 → querri-1.0.7}/querri/resources/embed.py +0 -0
  75. {querri-1.0.2 → querri-1.0.7}/querri/resources/files.py +0 -0
  76. {querri-1.0.2 → querri-1.0.7}/querri/resources/keys.py +0 -0
  77. {querri-1.0.2 → querri-1.0.7}/querri/resources/policies.py +0 -0
  78. {querri-1.0.2 → querri-1.0.7}/querri/resources/projects.py +0 -0
  79. {querri-1.0.2 → querri-1.0.7}/querri/resources/sharing.py +0 -0
  80. {querri-1.0.2 → querri-1.0.7}/querri/resources/sources.py +0 -0
  81. {querri-1.0.2 → querri-1.0.7}/querri/resources/usage.py +0 -0
  82. {querri-1.0.2 → querri-1.0.7}/querri/resources/users.py +0 -0
  83. {querri-1.0.2 → querri-1.0.7}/querri/types/__init__.py +0 -0
  84. {querri-1.0.2 → querri-1.0.7}/querri/types/audit.py +0 -0
  85. {querri-1.0.2 → querri-1.0.7}/querri/types/chat.py +0 -0
  86. {querri-1.0.2 → querri-1.0.7}/querri/types/dashboard.py +0 -0
  87. {querri-1.0.2 → querri-1.0.7}/querri/types/data.py +0 -0
  88. {querri-1.0.2 → querri-1.0.7}/querri/types/embed.py +0 -0
  89. {querri-1.0.2 → querri-1.0.7}/querri/types/file.py +0 -0
  90. {querri-1.0.2 → querri-1.0.7}/querri/types/key.py +0 -0
  91. {querri-1.0.2 → querri-1.0.7}/querri/types/policy.py +0 -0
  92. {querri-1.0.2 → querri-1.0.7}/querri/types/project.py +0 -0
  93. {querri-1.0.2 → querri-1.0.7}/querri/types/sharing.py +0 -0
  94. {querri-1.0.2 → querri-1.0.7}/querri/types/usage.py +0 -0
  95. {querri-1.0.2 → querri-1.0.7}/querri/types/user.py +0 -0
  96. {querri-1.0.2 → querri-1.0.7}/skills/README.md +0 -0
  97. {querri-1.0.2 → querri-1.0.7}/skills/querri-cli/references/json-shapes.md +0 -0
  98. {querri-1.0.2 → querri-1.0.7}/skills/querri-cli/references/troubleshooting.md +0 -0
  99. {querri-1.0.2 → querri-1.0.7}/tests/__init__.py +0 -0
  100. {querri-1.0.2 → querri-1.0.7}/tests/conftest.py +0 -0
  101. {querri-1.0.2 → querri-1.0.7}/tests/rls-integration-results.md +0 -0
  102. {querri-1.0.2 → querri-1.0.7}/tests/test_client.py +0 -0
  103. {querri-1.0.2 → querri-1.0.7}/tests/test_convenience.py +0 -0
  104. {querri-1.0.2 → querri-1.0.7}/tests/test_exceptions.py +0 -0
  105. {querri-1.0.2 → querri-1.0.7}/tests/test_integration.py +0 -0
  106. {querri-1.0.2 → querri-1.0.7}/tests/test_new_features.py +0 -0
  107. {querri-1.0.2 → querri-1.0.7}/tests/test_pagination.py +0 -0
  108. {querri-1.0.2 → querri-1.0.7}/tests/test_rls_integration.py +0 -0
  109. {querri-1.0.2 → querri-1.0.7}/tests/test_security.py +0 -0
  110. {querri-1.0.2 → querri-1.0.7}/tests/test_streaming.py +0 -0
  111. {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__`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: querri
3
- Version: 1.0.2
3
+ Version: 1.0.7
4
4
  Summary: Python SDK and CLI for the Querri data analysis platform
5
5
  Project-URL: Homepage, https://querri.com
6
6
  Project-URL: Documentation, https://docs.querri.com/sdk/python
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
- version = "1.0.2"
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 for WorkOS redirect URI registration
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 with 0600 permissions, then rename
181
- tmp_path_obj = self.STORE_DIR / ".tokens_tmp"
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:hidden;
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 &amp; upload</div>
595
594
  <div class="cmd typing">
596
595
  <span class="prompt">$</span>
597
- <span class="command"> querri projects create</span>
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 chats create</span>
612
- <span class="arg"> &lt;project-id&gt;</span>
613
- </div>
614
- <div class="cmd typing">
615
- <span class="prompt">$</span>
616
- <span class="command"> querri chats stream</span>
617
- <span class="arg"> &lt;project&gt; &lt;chat&gt;</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. Starts a local HTTP server on 127.0.0.1:11847 (fixed port)
672
- 2. Opens the browser to the WorkOS authorize URL
673
- 3. Waits for the callback with the authorization code
674
- 4. Exchanges the code for tokens via the Querri server proxy
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 WorkOS organization ID to scope the login.
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
- # Generate state for CSRF protection
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": client_id,
747
+ "client_id": "querri-cli",
708
748
  "redirect_uri": redirect_uri,
709
749
  "response_type": "code",
710
750
  "state": state,
711
- "provider": "authkit",
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
- authorize_params = urlencode(params)
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 authorization code for tokens
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
- token_url,
787
+ token_endpoint,
748
788
  json={
749
789
  "grant_type": "authorization_code",
750
790
  "code": server.auth_code,
751
- "redirect_uri": redirect_uri,
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
- result.data,
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
- raw = "1"
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}/shares/org",
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: