querri 1.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. querri/__init__.py +51 -0
  2. querri/_auth.py +818 -0
  3. querri/_base_client.py +379 -0
  4. querri/_client.py +395 -0
  5. querri/_config.py +140 -0
  6. querri/_convenience.py +538 -0
  7. querri/_exceptions.py +180 -0
  8. querri/_pagination.py +278 -0
  9. querri/_streaming.py +663 -0
  10. querri/_user_client.py +193 -0
  11. querri/_version.py +1 -0
  12. querri/cli/__init__.py +21 -0
  13. querri/cli/_app.py +246 -0
  14. querri/cli/_context.py +216 -0
  15. querri/cli/_image.py +234 -0
  16. querri/cli/_output.py +293 -0
  17. querri/cli/audit.py +68 -0
  18. querri/cli/auth.py +556 -0
  19. querri/cli/chat.py +1255 -0
  20. querri/cli/chats.py +358 -0
  21. querri/cli/dashboards.py +294 -0
  22. querri/cli/embed.py +220 -0
  23. querri/cli/files.py +214 -0
  24. querri/cli/keys.py +304 -0
  25. querri/cli/policies.py +424 -0
  26. querri/cli/projects.py +916 -0
  27. querri/cli/sharing.py +403 -0
  28. querri/cli/sources.py +532 -0
  29. querri/cli/steps.py +123 -0
  30. querri/cli/usage.py +99 -0
  31. querri/cli/users.py +223 -0
  32. querri/cli/views.py +449 -0
  33. querri/cli/whoami.py +78 -0
  34. querri/py.typed +0 -0
  35. querri/resources/__init__.py +1 -0
  36. querri/resources/audit.py +119 -0
  37. querri/resources/dashboards.py +295 -0
  38. querri/resources/embed.py +268 -0
  39. querri/resources/files.py +145 -0
  40. querri/resources/keys.py +191 -0
  41. querri/resources/policies.py +492 -0
  42. querri/resources/projects.py +808 -0
  43. querri/resources/sharing.py +330 -0
  44. querri/resources/sources.py +511 -0
  45. querri/resources/usage.py +106 -0
  46. querri/resources/users.py +303 -0
  47. querri/resources/views.py +347 -0
  48. querri/types/__init__.py +104 -0
  49. querri/types/audit.py +21 -0
  50. querri/types/chat.py +51 -0
  51. querri/types/dashboard.py +51 -0
  52. querri/types/data.py +61 -0
  53. querri/types/embed.py +38 -0
  54. querri/types/file.py +18 -0
  55. querri/types/key.py +36 -0
  56. querri/types/policy.py +105 -0
  57. querri/types/project.py +158 -0
  58. querri/types/sharing.py +19 -0
  59. querri/types/usage.py +39 -0
  60. querri/types/user.py +33 -0
  61. querri-1.0.2.dist-info/METADATA +310 -0
  62. querri-1.0.2.dist-info/RECORD +64 -0
  63. querri-1.0.2.dist-info/WHEEL +4 -0
  64. querri-1.0.2.dist-info/entry_points.txt +2 -0
querri/_user_client.py ADDED
@@ -0,0 +1,193 @@
1
+ """User-scoped client that uses embed session auth and FGA-filtered resources.
2
+
3
+ Calls the internal API (``/api``) instead of the public API (``/api/v1``).
4
+ Resources are automatically filtered by the session user's access policies.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from ._base_client import AsyncHTTPClient, SyncHTTPClient
13
+ from ._config import ClientConfig
14
+
15
+ if TYPE_CHECKING:
16
+ from .resources.dashboards import AsyncDashboards, Dashboards
17
+ from .resources.projects import AsyncChats, AsyncProjects, Chats, Projects
18
+ from .resources.sources import AsyncSources, Sources
19
+
20
+
21
+ def _session_config(
22
+ session: dict[str, Any], parent_config: ClientConfig
23
+ ) -> ClientConfig:
24
+ """Build a config for session-mode HTTP clients.
25
+
26
+ Derives the ``/api`` base URL from the parent's ``/api/v1`` URL and
27
+ sets the session token for ``X-Embed-Session`` auth.
28
+ """
29
+ # Strip /api/v1 suffix to get the host, then append /api
30
+ host = re.sub(r"/api/v1$", "", parent_config.base_url)
31
+ return ClientConfig(
32
+ api_key="", # Not used in session mode.
33
+ org_id="", # Not used in session mode.
34
+ base_url=f"{host}/api",
35
+ timeout=parent_config.timeout,
36
+ max_retries=parent_config.max_retries,
37
+ session_token=session["session_token"],
38
+ )
39
+
40
+
41
+ class UserQuerri:
42
+ """User-scoped synchronous client with FGA-filtered resources.
43
+
44
+ Calls the internal API (``/api``) using an embed session token.
45
+ Only exposes resources visible to the session user.
46
+
47
+ Usage::
48
+
49
+ session = client.embed.get_session(user="ext_123")
50
+ user_client = client.as_user(session)
51
+ for project in user_client.projects.list():
52
+ print(project.name)
53
+
54
+ Create via :meth:`Querri.as_user`.
55
+ """
56
+
57
+ def __init__(self, session: dict[str, Any], parent_config: ClientConfig) -> None:
58
+ """Initialize with session token and parent client config.
59
+
60
+ Args:
61
+ session: Result from ``get_session()`` containing ``session_token``.
62
+ parent_config: Config from the parent ``Querri`` client.
63
+ """
64
+ self._config = _session_config(session, parent_config)
65
+ self._http = SyncHTTPClient(self._config)
66
+
67
+ # Resource namespaces — lazily initialized on first access.
68
+ # Deferred imports keep client creation fast and avoid circular imports.
69
+ self._projects: object | None = None
70
+ self._dashboards: object | None = None
71
+ self._sources: object | None = None
72
+ self._chats: object | None = None
73
+
74
+ @property
75
+ def projects(self) -> Projects:
76
+ if self._projects is None:
77
+ from .resources.projects import Projects
78
+
79
+ self._projects = Projects(self._http)
80
+ return self._projects # type: ignore[return-value]
81
+
82
+ @property
83
+ def dashboards(self) -> Dashboards:
84
+ if self._dashboards is None:
85
+ from .resources.dashboards import Dashboards
86
+
87
+ self._dashboards = Dashboards(self._http)
88
+ return self._dashboards # type: ignore[return-value]
89
+
90
+ @property
91
+ def sources(self) -> Sources:
92
+ if self._sources is None:
93
+ from .resources.sources import Sources
94
+
95
+ self._sources = Sources(self._http)
96
+ return self._sources # type: ignore[return-value]
97
+
98
+ @property
99
+ def chats(self) -> Chats:
100
+ if self._chats is None:
101
+ from .resources.projects import Chats
102
+
103
+ self._chats = Chats(self._http)
104
+ return self._chats # type: ignore[return-value]
105
+
106
+ def close(self) -> None:
107
+ """Close the underlying HTTP client."""
108
+ self._http.close()
109
+
110
+ def __enter__(self) -> UserQuerri:
111
+ """Enter context manager for automatic resource cleanup."""
112
+ return self
113
+
114
+ def __exit__(self, *args: object) -> None:
115
+ """Close the HTTP client on context manager exit."""
116
+ self.close()
117
+
118
+
119
+ class AsyncUserQuerri:
120
+ """User-scoped asynchronous client with FGA-filtered resources.
121
+
122
+ Calls the internal API (``/api``) using an embed session token.
123
+ Only exposes resources visible to the session user.
124
+
125
+ Usage::
126
+
127
+ session = await client.embed.get_session(user="ext_123")
128
+ user_client = client.as_user(session)
129
+ async for project in user_client.projects.list():
130
+ print(project.name)
131
+
132
+ Create via :meth:`AsyncQuerri.as_user`.
133
+ """
134
+
135
+ def __init__(self, session: dict[str, Any], parent_config: ClientConfig) -> None:
136
+ """Initialize with session token and parent client config.
137
+
138
+ Args:
139
+ session: Result from ``get_session()`` containing ``session_token``.
140
+ parent_config: Config from the parent ``AsyncQuerri`` client.
141
+ """
142
+ self._config = _session_config(session, parent_config)
143
+ self._http = AsyncHTTPClient(self._config)
144
+
145
+ # Resource namespaces — lazily initialized on first access.
146
+ self._projects: object | None = None
147
+ self._dashboards: object | None = None
148
+ self._sources: object | None = None
149
+ self._chats: object | None = None
150
+
151
+ @property
152
+ def projects(self) -> AsyncProjects:
153
+ if self._projects is None:
154
+ from .resources.projects import AsyncProjects
155
+
156
+ self._projects = AsyncProjects(self._http)
157
+ return self._projects # type: ignore[return-value]
158
+
159
+ @property
160
+ def dashboards(self) -> AsyncDashboards:
161
+ if self._dashboards is None:
162
+ from .resources.dashboards import AsyncDashboards
163
+
164
+ self._dashboards = AsyncDashboards(self._http)
165
+ return self._dashboards # type: ignore[return-value]
166
+
167
+ @property
168
+ def sources(self) -> AsyncSources:
169
+ if self._sources is None:
170
+ from .resources.sources import AsyncSources
171
+
172
+ self._sources = AsyncSources(self._http)
173
+ return self._sources # type: ignore[return-value]
174
+
175
+ @property
176
+ def chats(self) -> AsyncChats:
177
+ if self._chats is None:
178
+ from .resources.projects import AsyncChats
179
+
180
+ self._chats = AsyncChats(self._http)
181
+ return self._chats # type: ignore[return-value]
182
+
183
+ async def close(self) -> None:
184
+ """Close the underlying HTTP client."""
185
+ await self._http.close()
186
+
187
+ async def __aenter__(self) -> AsyncUserQuerri:
188
+ """Enter async context manager for automatic resource cleanup."""
189
+ return self
190
+
191
+ async def __aexit__(self, *args: object) -> None:
192
+ """Close the HTTP client on async context manager exit."""
193
+ await self.close()
querri/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
querri/cli/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """Querri CLI — command-line interface for the Querri data analysis platform.
2
+
3
+ Entry point guard: if typer is not installed, prints a clean error message
4
+ and exits with code 1. No tracebacks, no file paths, no module names.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ def app() -> None:
11
+ """Entry point registered in pyproject.toml as ``querri = "querri.cli:app"``."""
12
+ try:
13
+ from querri.cli._app import main_app
14
+
15
+ main_app()
16
+ except ImportError:
17
+ import sys
18
+
19
+ print("Querri CLI requires additional dependencies.", file=sys.stderr)
20
+ print("Install with: pip install 'querri[cli]'", file=sys.stderr)
21
+ sys.exit(1)
querri/cli/_app.py ADDED
@@ -0,0 +1,246 @@
1
+ """Main Typer application with global options and sub-app registration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import typer
8
+
9
+ from querri._version import __version__
10
+ from querri.cli.audit import audit_app
11
+ from querri.cli.auth import auth_app
12
+ from querri.cli.chats import chats_app
13
+ from querri.cli.dashboards import dashboards_app
14
+ from querri.cli.embed import embed_app
15
+ from querri.cli.files import files_app
16
+ from querri.cli.keys import keys_app
17
+ from querri.cli.policies import policies_app
18
+ from querri.cli.projects import projects_app
19
+ from querri.cli.sharing import sharing_app
20
+ from querri.cli.sources import sources_app
21
+ from querri.cli.steps import steps_app
22
+ from querri.cli.usage import usage_app
23
+ from querri.cli.users import users_app
24
+ from querri.cli.views import view_app
25
+ from querri.cli.whoami import whoami_app
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # TTY detection — drives output behavior across the CLI
29
+ # ---------------------------------------------------------------------------
30
+
31
+ IS_INTERACTIVE = sys.stdout.isatty()
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Main app
36
+ # ---------------------------------------------------------------------------
37
+
38
+ main_app = typer.Typer(
39
+ name="querri",
40
+ help=(
41
+ "[bold #f15a24]Querri CLI[/bold #f15a24] — command-line interface for the "
42
+ "[#f15a24]Querri[/#f15a24] data analysis platform.\n\n"
43
+ "Get started:\n"
44
+ " [#f15a24]querri auth login[/#f15a24]"
45
+ " Authenticate via browser\n"
46
+ " [#f15a24]querri project new[/#f15a24]"
47
+ " Create a new project\n"
48
+ " [#f15a24]querri file upload data.csv[/#f15a24] Upload a data file\n"
49
+ ' [#f15a24]querri project chat -m "analyze this"[/#f15a24]'
50
+ ' Chat with your data'
51
+ ),
52
+ no_args_is_help=True,
53
+ rich_markup_mode="rich",
54
+ pretty_exceptions_enable=False,
55
+ )
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Version callback
60
+ # ---------------------------------------------------------------------------
61
+
62
+
63
+ def _version_callback(value: bool) -> None:
64
+ if value:
65
+ if IS_INTERACTIVE:
66
+ from rich.console import Console
67
+
68
+ Console(stderr=True).print(
69
+ f"[bold #f15a24]querri[/bold #f15a24] {__version__}"
70
+ )
71
+ else:
72
+ print(f"querri {__version__}")
73
+ raise typer.Exit()
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Global options callback
78
+ # ---------------------------------------------------------------------------
79
+
80
+
81
+ @main_app.callback()
82
+ def _global_options(
83
+ ctx: typer.Context,
84
+ host: str | None = typer.Option(
85
+ None,
86
+ "--host",
87
+ envvar="QUERRI_HOST",
88
+ help="Querri server host (default: https://app.querri.com).",
89
+ show_default=False,
90
+ ),
91
+ api_key: str | None = typer.Option(
92
+ None,
93
+ "--api-key",
94
+ envvar="QUERRI_API_KEY",
95
+ help="API key (prefer QUERRI_API_KEY env var).",
96
+ show_default=False,
97
+ ),
98
+ org_id: str | None = typer.Option(
99
+ None,
100
+ "--org-id",
101
+ envvar="QUERRI_ORG_ID",
102
+ help="Organization ID.",
103
+ show_default=False,
104
+ ),
105
+ profile: str | None = typer.Option(
106
+ None,
107
+ "--profile",
108
+ help="Named auth profile (v0.2.1+).",
109
+ hidden=True,
110
+ ),
111
+ json_output: bool = typer.Option(
112
+ False,
113
+ "--json",
114
+ help="Output as JSON.",
115
+ ),
116
+ quiet: bool = typer.Option(
117
+ False,
118
+ "--quiet",
119
+ "-q",
120
+ help="Minimal output (IDs only).",
121
+ ),
122
+ verbose: bool = typer.Option(
123
+ False,
124
+ "--verbose",
125
+ "-v",
126
+ help="Verbose output.",
127
+ ),
128
+ interactive: bool | None = typer.Option(
129
+ None,
130
+ "--interactive/--no-interactive",
131
+ help="Force interactive/non-interactive mode.",
132
+ show_default=False,
133
+ ),
134
+ project: str | None = typer.Option(
135
+ None,
136
+ "--project",
137
+ "-p",
138
+ envvar="QUERRI_PROJECT_ID",
139
+ help="Project ID (overrides active project).",
140
+ show_default=False,
141
+ ),
142
+ chat: str | None = typer.Option(
143
+ None,
144
+ "--chat",
145
+ envvar="QUERRI_CHAT_ID",
146
+ help="Chat ID (overrides active chat).",
147
+ show_default=False,
148
+ ),
149
+ version: bool = typer.Option(
150
+ False,
151
+ "--version",
152
+ callback=_version_callback,
153
+ is_eager=True,
154
+ help="Show version and exit.",
155
+ ),
156
+ ) -> None:
157
+ """[#f15a24]Querri CLI[/#f15a24] — manage projects,
158
+ data, and AI chats from the terminal."""
159
+ # Determine interactive mode
160
+ is_interactive = interactive if interactive is not None else IS_INTERACTIVE
161
+
162
+ # Warn about --api-key in shell history (only in interactive TTY mode)
163
+ if api_key and is_interactive:
164
+ import sys as _sys
165
+
166
+ print(
167
+ "Warning: API key passed as command argument may appear in shell history. "
168
+ "Consider using QUERRI_API_KEY env var instead.",
169
+ file=_sys.stderr,
170
+ )
171
+
172
+ # Store options in context for downstream commands
173
+ ctx.ensure_object(dict)
174
+ ctx.obj["host"] = host
175
+ ctx.obj["api_key"] = api_key
176
+ ctx.obj["org_id"] = org_id
177
+ ctx.obj["profile"] = profile
178
+ ctx.obj["project"] = project
179
+ ctx.obj["chat"] = chat
180
+ ctx.obj["json"] = json_output
181
+ ctx.obj["quiet"] = quiet
182
+ ctx.obj["verbose"] = verbose
183
+ ctx.obj["interactive"] = is_interactive
184
+
185
+
186
+ # ---------------------------------------------------------------------------
187
+ # Sub-app registration with rich_help_panel groupings
188
+ # ---------------------------------------------------------------------------
189
+
190
+ # ── Getting Started ──────────────────────────────────────────────────────
191
+ main_app.add_typer(
192
+ auth_app, name="auth", rich_help_panel="[#f15a24]Getting Started[/#f15a24]"
193
+ )
194
+ main_app.add_typer(
195
+ whoami_app, name="whoami", rich_help_panel="[#f15a24]Getting Started[/#f15a24]"
196
+ )
197
+
198
+ # ── Projects & Data ─────────────────────────────────────────────────────
199
+ main_app.add_typer(
200
+ projects_app, name="project", rich_help_panel="[#f15a24]Projects & Data[/#f15a24]"
201
+ )
202
+ main_app.add_typer(
203
+ chats_app, name="chat", rich_help_panel="[#f15a24]Projects & Data[/#f15a24]"
204
+ )
205
+ main_app.add_typer(
206
+ steps_app, name="step", rich_help_panel="[#f15a24]Projects & Data[/#f15a24]"
207
+ )
208
+ main_app.add_typer(
209
+ files_app, name="file", rich_help_panel="[#f15a24]Projects & Data[/#f15a24]"
210
+ )
211
+ main_app.add_typer(
212
+ sources_app, name="source", rich_help_panel="[#f15a24]Projects & Data[/#f15a24]"
213
+ )
214
+ main_app.add_typer(
215
+ view_app, name="view", rich_help_panel="[#f15a24]Projects & Data[/#f15a24]"
216
+ )
217
+ main_app.add_typer(
218
+ dashboards_app,
219
+ name="dashboard",
220
+ rich_help_panel="[#f15a24]Projects & Data[/#f15a24]",
221
+ )
222
+
223
+ # ── Administration ──────────────────────────────────────────────────────
224
+ main_app.add_typer(
225
+ users_app, name="user", rich_help_panel="[#f15a24]Administration[/#f15a24]"
226
+ )
227
+ main_app.add_typer(
228
+ keys_app, name="key", rich_help_panel="[#f15a24]Administration[/#f15a24]"
229
+ )
230
+ main_app.add_typer(
231
+ policies_app, name="policy", rich_help_panel="[#f15a24]Administration[/#f15a24]"
232
+ )
233
+ main_app.add_typer(
234
+ sharing_app, name="share", rich_help_panel="[#f15a24]Administration[/#f15a24]"
235
+ )
236
+
237
+ # ── Advanced ────────────────────────────────────────────────────────────
238
+ main_app.add_typer(
239
+ embed_app, name="session", rich_help_panel="[#f15a24]Advanced[/#f15a24]"
240
+ )
241
+ main_app.add_typer(
242
+ usage_app, name="usage", rich_help_panel="[#f15a24]Advanced[/#f15a24]"
243
+ )
244
+ main_app.add_typer(
245
+ audit_app, name="audit", rich_help_panel="[#f15a24]Advanced[/#f15a24]"
246
+ )
querri/cli/_context.py ADDED
@@ -0,0 +1,216 @@
1
+ """CLI context helpers — construct SDK clients from CLI args and env vars."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ import typer
10
+
11
+ if TYPE_CHECKING:
12
+ from querri._auth import TokenProfile
13
+
14
+ from querri._client import Querri
15
+ from querri._exceptions import ConfigError
16
+
17
+
18
+ def get_client(ctx: typer.Context) -> Querri:
19
+ """Construct a Querri client from CLI context options.
20
+
21
+ Resolution order:
22
+ 1. Explicit ``--api-key`` flag (wins)
23
+ 2. Environment variables (``QUERRI_API_KEY``, ``QUERRI_ACCESS_TOKEN``)
24
+ 3. Token store (``~/.querri/tokens.json`` — active profile, auto-refresh)
25
+ 4. Error with guidance
26
+
27
+ Returns:
28
+ Configured ``Querri`` client instance.
29
+
30
+ Raises:
31
+ typer.Exit: With code 2 on authentication/config errors.
32
+ """
33
+ obj: dict[str, Any] = ctx.ensure_object(dict)
34
+
35
+ # 1. Explicit --api-key wins
36
+ api_key = obj.get("api_key")
37
+ org_id = obj.get("org_id")
38
+ host = obj.get("host")
39
+
40
+ if api_key:
41
+ try:
42
+ return Querri(api_key=api_key, org_id=org_id, host=host)
43
+ except ConfigError as exc:
44
+ _handle_config_error(obj, exc)
45
+ raise typer.Exit(code=2) from None
46
+
47
+ # 2. Environment variables — let resolve_config handle them
48
+ if os.environ.get("QUERRI_API_KEY") or os.environ.get("QUERRI_ACCESS_TOKEN"):
49
+ try:
50
+ return Querri(org_id=org_id, host=host)
51
+ except ConfigError as exc:
52
+ _handle_config_error(obj, exc)
53
+ raise typer.Exit(code=2) from None
54
+
55
+ # 3. Token store — load active profile, auto-refresh if needed
56
+ try:
57
+ from querri._auth import TokenStore, needs_refresh, refresh_tokens
58
+ from querri._config import DEFAULT_HOST
59
+
60
+ profile_name = obj.get("profile") or "default"
61
+ store = TokenStore.load()
62
+ profile = store.profiles.get(profile_name)
63
+
64
+ if profile and profile.access_token:
65
+ # Use the host stored in the profile (from login), CLI flag, env, or default
66
+ resolved_host = (
67
+ host or os.environ.get("QUERRI_HOST") or profile.host or DEFAULT_HOST
68
+ )
69
+
70
+ # Auto-refresh if near expiry
71
+ if needs_refresh(profile):
72
+ try:
73
+ profile = refresh_tokens(profile, resolved_host)
74
+ store.save_profile(profile_name, profile)
75
+ except Exception:
76
+ # Refresh failed (RuntimeError, timeout, network error, etc.)
77
+ # but the token may still be valid — needs_refresh fires
78
+ # early as a buffer. Fall through to use it anyway.
79
+ pass
80
+
81
+ return Querri(
82
+ access_token=profile.access_token,
83
+ org_id=profile.org_id or org_id,
84
+ host=resolved_host,
85
+ )
86
+ except Exception as exc:
87
+ # Token store error — print debug info in verbose mode and fall through
88
+ if obj.get("verbose"):
89
+ print(f"Token store error: {exc}", file=sys.stderr)
90
+ pass
91
+
92
+ # 4. Nothing worked
93
+ _handle_config_error(
94
+ obj,
95
+ ConfigError(
96
+ "No credentials found. Run 'querri auth login' or set QUERRI_API_KEY."
97
+ ),
98
+ )
99
+ raise typer.Exit(code=2)
100
+
101
+
102
+ def _handle_config_error(obj: dict[str, Any], exc: ConfigError) -> None:
103
+ """Print a user-friendly config error message."""
104
+ is_json = obj.get("json", False)
105
+
106
+ if is_json:
107
+ import json
108
+
109
+ error_obj = {
110
+ "error": "auth_failed",
111
+ "message": str(exc),
112
+ "hint": "Run 'querri auth login' or set QUERRI_API_KEY and QUERRI_ORG_ID "
113
+ "environment variables.",
114
+ "code": 2,
115
+ }
116
+ print(json.dumps(error_obj))
117
+ else:
118
+ from querri.cli._output import print_error
119
+
120
+ print_error(str(exc))
121
+ print(
122
+ "\nTo get started:\n"
123
+ " querri auth login # Browser-based login\n"
124
+ " export QUERRI_API_KEY=qk_... # API key auth\n"
125
+ " export QUERRI_ORG_ID=org_...\n"
126
+ "\nOr pass them as flags:\n"
127
+ " querri --api-key qk_... --org-id org_... <command>",
128
+ file=sys.stderr,
129
+ )
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Workspace state helpers
134
+ # ---------------------------------------------------------------------------
135
+
136
+
137
+ def _get_profile(ctx: typer.Context) -> TokenProfile | None:
138
+ """Load the active TokenProfile (or None)."""
139
+ from querri._auth import TokenStore
140
+
141
+ obj = ctx.ensure_object(dict)
142
+ profile_name = obj.get("profile") or "default"
143
+ store = TokenStore.load()
144
+ return store.profiles.get(profile_name)
145
+
146
+
147
+ def _save_profile(ctx: typer.Context, profile: "TokenProfile") -> None:
148
+ """Persist updates to the active TokenProfile."""
149
+ from querri._auth import TokenStore
150
+
151
+ obj = ctx.ensure_object(dict)
152
+ profile_name = obj.get("profile") or "default"
153
+ store = TokenStore.load()
154
+ store.save_profile(profile_name, profile)
155
+
156
+
157
+ def resolve_project_id(ctx: typer.Context) -> str:
158
+ """Resolve project ID from --project flag → stored state → error."""
159
+ obj = ctx.ensure_object(dict)
160
+ is_json = obj.get("json", False)
161
+
162
+ # 1. Explicit --project flag
163
+ project: str | None = obj.get("project")
164
+ if project:
165
+ return project
166
+
167
+ # 2. Stored active project
168
+ profile = _get_profile(ctx)
169
+ if profile and profile.active_project_id:
170
+ return str(profile.active_project_id)
171
+
172
+ # 3. Error
173
+ from querri.cli._output import print_error
174
+
175
+ if is_json:
176
+ from querri.cli._output import print_json_error
177
+
178
+ print_json_error(
179
+ "no_project",
180
+ "No active project. Run 'querri project select <name>' or pass --project.",
181
+ 1,
182
+ )
183
+ else:
184
+ print_error(
185
+ "No active project.\n"
186
+ " querri project select <name> # Set active project\n"
187
+ " querri project new # Create a new project\n"
188
+ " --project <id> # Pass explicitly"
189
+ )
190
+ raise typer.Exit(code=1)
191
+
192
+
193
+ def resolve_user_id(ctx: typer.Context) -> str:
194
+ """Resolve user ID from env → stored profile → error."""
195
+ env_user = os.environ.get("QUERRI_USER_ID")
196
+ if env_user:
197
+ return env_user
198
+
199
+ profile = _get_profile(ctx)
200
+ if profile and profile.user_id:
201
+ return str(profile.user_id)
202
+
203
+ obj = ctx.ensure_object(dict)
204
+ from querri.cli._output import print_error
205
+
206
+ if obj.get("json"):
207
+ from querri.cli._output import print_json_error
208
+
209
+ print_json_error("no_user_id", "Could not determine user ID.", 1)
210
+ else:
211
+ print_error(
212
+ "Could not determine user ID. "
213
+ "Set QUERRI_USER_ID or log in with "
214
+ "'querri auth login'."
215
+ )
216
+ raise typer.Exit(code=1)