orchid-cli 1.1.4__tar.gz → 1.2.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.
Files changed (66) hide show
  1. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/AGENTS.md +12 -5
  2. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/CHANGELOG.md +25 -0
  3. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/PKG-INFO +15 -11
  4. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/README.md +13 -9
  5. orchid_cli-1.2.0/orchid_cli/_typer_async.py +40 -0
  6. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/auth/config.py +14 -0
  7. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/auth/flow.py +1 -0
  8. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/auth/middleware.py +60 -6
  9. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/auth/pkce.py +81 -2
  10. orchid_cli-1.2.0/orchid_cli/auth/token_store.py +234 -0
  11. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/bootstrap.py +19 -0
  12. orchid_cli-1.2.0/orchid_cli/commands/_chat_helpers.py +32 -0
  13. orchid_cli-1.2.0/orchid_cli/commands/_chat_interactive.py +192 -0
  14. orchid_cli-1.2.0/orchid_cli/commands/_chat_send.py +249 -0
  15. orchid_cli-1.2.0/orchid_cli/commands/_session.py +74 -0
  16. orchid_cli-1.2.0/orchid_cli/commands/_skill_guardrails.py +113 -0
  17. orchid_cli-1.2.0/orchid_cli/commands/_skill_md.py +197 -0
  18. orchid_cli-1.2.0/orchid_cli/commands/_skill_text.py +16 -0
  19. orchid_cli-1.2.0/orchid_cli/commands/_skill_tools.py +216 -0
  20. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/commands/auth.py +26 -10
  21. orchid_cli-1.2.0/orchid_cli/commands/chat.py +238 -0
  22. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/commands/index.py +28 -54
  23. orchid_cli-1.2.0/orchid_cli/commands/mcp.py +124 -0
  24. orchid_cli-1.2.0/orchid_cli/commands/skill.py +87 -0
  25. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/pyproject.toml +2 -2
  26. orchid_cli-1.2.0/tests/test_bootstrap_warm.py +62 -0
  27. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/test_commands_chat.py +13 -13
  28. orchid_cli-1.2.0/tests/test_resolve_session.py +109 -0
  29. orchid_cli-1.2.0/tests/test_token_store_security.py +156 -0
  30. orchid_cli-1.1.4/orchid_cli/auth/token_store.py +0 -112
  31. orchid_cli-1.1.4/orchid_cli/commands/chat.py +0 -714
  32. orchid_cli-1.1.4/orchid_cli/commands/mcp.py +0 -300
  33. orchid_cli-1.1.4/orchid_cli/commands/skill.py +0 -630
  34. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/.editorconfig +0 -0
  35. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/.github/workflows/ci.yml +0 -0
  36. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/.gitignore +0 -0
  37. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/.gitlint +0 -0
  38. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/.pre-commit-config.yaml +0 -0
  39. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/CLAUDE.md +0 -0
  40. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/CONTRIBUTING.md +0 -0
  41. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/LICENSE +0 -0
  42. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/icon.svg +0 -0
  43. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/__init__.py +0 -0
  44. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/_output.py +0 -0
  45. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/auth/__init__.py +0 -0
  46. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/auth/oidc.py +0 -0
  47. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/commands/__init__.py +0 -0
  48. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/commands/_tool_metadata.py +0 -0
  49. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/commands/config.py +0 -0
  50. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/main.py +0 -0
  51. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/orchid_cli/slash_commands.py +0 -0
  52. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/conftest.py +0 -0
  53. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/fixtures/__init__.py +0 -0
  54. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/fixtures/fake_tools_dates.py +0 -0
  55. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/fixtures/fake_tools_math.py +0 -0
  56. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/test_auth.py +0 -0
  57. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/test_bootstrap.py +0 -0
  58. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/test_browser_opener.py +0 -0
  59. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/test_chat_builtin_slash.py +0 -0
  60. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/test_commands_config.py +0 -0
  61. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/test_commands_skill.py +0 -0
  62. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/test_middleware_seams.py +0 -0
  63. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/test_output.py +0 -0
  64. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/test_pkce.py +0 -0
  65. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/test_slash_commands.py +0 -0
  66. {orchid_cli-1.1.4 → orchid_cli-1.2.0}/tests/test_tool_metadata.py +0 -0
@@ -17,11 +17,14 @@ orchid-cli/
17
17
  token_store.py Secure token persistence (~/.orchid/tokens.json)
18
18
  middleware.py Token refresh + OrchidAuthContext builder
19
19
  commands/
20
+ _session.py resolve_session(config_path) -> (Orchid, OrchidAuthContext) +
21
+ warm_for_user — single chokepoint for bootstrap + auth + MCP warm
20
22
  auth.py login, logout, status subcommands
21
23
  chat.py Full CRUD: create, list, delete, history, send, interactive, rename, share
22
24
  config.py validate command (checks agents.yaml)
23
- index.py seed command (batch-index RAG data)
24
- mcp.py MCP OAuth: authorize, status, revoke per-server tokens
25
+ index.py file/dir/text/json-file commands (RAG ingestion)
26
+ mcp.py MCP OAuth: status, revoke per-server tokens
27
+ (authorize flow runs through the API gateway, not the CLI)
25
28
  skill.py generate command (Claude Code skills from agents.yaml)
26
29
  pyproject.toml
27
30
  ```
@@ -52,10 +55,12 @@ orchid-cli/
52
55
 
53
56
  6. **Config resolution:** CLI args > env vars > `orchid.yml` > hardcoded defaults.
54
57
 
55
- 7. **OAuth auth is self-contained in `auth/`.** The `auth/` subpackage handles the full OAuth 2.0 Authorization Code + PKCE flow. No OAuth logic in `chat.py`, `bootstrap.py`, or any other module. Chat commands call `get_auth_context(config_path)` which returns either a real OAuth-backed `OrchidAuthContext` or the dev fallback — callers don't know or care which.
58
+ 7. **OAuth auth is self-contained in `auth/`.** The `auth/` subpackage handles the full OAuth 2.0 Authorization Code + PKCE flow. No OAuth logic in `chat.py`, `bootstrap.py`, or any other module. Chat commands call `get_auth_context(config_path)` which returns either a real OAuth-backed `OrchidAuthContext` or the dev fallback — callers don't know or care which. **The CLI is an independent OAuth client** — it runs its own dance against the upstream IdP and is NOT one of the downstream consumers covered by the [auth-centralisation roadmap](../.knowledge/auth-centralisation.md) (which targets the MCP gateway + Next.js frontends).
56
59
 
57
60
  8. **Token storage at `~/.orchid/tokens.json`.** Permissions set to `0o600` (owner-only). Tokens are keyed by `client_id`, supporting multiple providers. Refresh tokens are used automatically when the access token expires.
58
61
 
62
+ 9. **Commands go through `commands/_session.py:resolve_session`.** Every command that needs both an `Orchid` instance and a per-user `OrchidAuthContext` calls `resolve_session(config_path)` (or its `session_context` async-context-manager wrapper), which bootstraps the framework, resolves auth, and warms `passthrough` / `oauth` MCP capability caches once per `(tenant_key, user_id)`. The interactive REPL stays alive across many turns; the warmer's idempotency check makes subsequent loop iterations a no-op. Failures in the per-user warm are logged and ignored — the chat still works, it just pays the lazy discovery cost on first tool call. `bootstrap()` itself warms `auth.mode: none` servers up front (no user identity needed).
63
+
59
64
  ## Commands
60
65
 
61
66
  ```bash
@@ -76,14 +81,16 @@ orchid chat share --config <path> <id> # Promote RAG to user scope
76
81
 
77
82
  # MCP server OAuth
78
83
  orchid mcp status --config <path> # Show OAuth status for MCP servers
79
- orchid mcp authorize <server> --config <path> # Authorize via browser (PKCE)
80
84
  orchid mcp revoke <server> --config <path> # Revoke stored token
81
85
 
82
86
  # Config
83
87
  orchid config validate <agents.yaml> # Validate agent config
84
88
 
85
89
  # RAG indexing
86
- orchid index seed --config <path> # Batch-index RAG data
90
+ orchid index file <path> -n <namespace> --config <path> # Index a single file
91
+ orchid index dir <path> -n <namespace> --config <path> # Index a directory
92
+ orchid index text "..." -n <namespace> --config <path> # Index inline text
93
+ orchid index json-file <path> -n <namespace> --config <path> # Bulk-index JSON entries
87
94
 
88
95
  # Skill generation (Claude Code)
89
96
  orchid skill generate <agents.yaml> # Generate Claude Code skills
@@ -2,6 +2,31 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v1.2.0 (2026-04-29)
6
+
7
+ ### Bug Fixes
8
+
9
+ - **cli**: Centralize session handling and warm-up lifecycle
10
+ ([`cfe9c2d`](https://github.com/gadz82/orchid-cli/commit/cfe9c2de1cb39cd6002ff9a49bda2253ea25eee9))
11
+
12
+ - **cli**: Ensure lock file path respects test monkeypatching of _ORCHID_DIR, enhance test for
13
+ temporary file cleanup
14
+ ([`471de32`](https://github.com/gadz82/orchid-cli/commit/471de32d515a54a34d568f2c87a74f34c0b66ca1))
15
+
16
+ ### Documentation
17
+
18
+ - **cli**: Clarify CLI OAuth independence and auth-centralisation roadmap context
19
+ ([`da14aa6`](https://github.com/gadz82/orchid-cli/commit/da14aa6dc5b571e63ce7bd10a633133979ceba82))
20
+
21
+ ### Features
22
+
23
+ - Bump orchid-ai dependency to >=1.4.0 in CLI and API
24
+ ([`9ed4940`](https://github.com/gadz82/orchid-cli/commit/9ed49401fe3c5337bef84a5c235e9bdc4bdaac3f))
25
+
26
+ - **cli**: Enhance chat commands and RAG indexing, improve security for token storage
27
+ ([`74dac43`](https://github.com/gadz82/orchid-cli/commit/74dac433ce7731374f4bd4366cbee6c7ecf1dfc8))
28
+
29
+
5
30
  ## v1.1.4 (2026-04-22)
6
31
 
7
32
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: orchid-cli
3
- Version: 1.1.4
3
+ Version: 1.2.0
4
4
  Summary: Orchid CLI — command-line interface for the Orchid agent framework
5
5
  Project-URL: Homepage, https://github.com/gadz82/orchid
6
6
  Project-URL: Repository, https://github.com/gadz82/orchid
@@ -26,7 +26,7 @@ Classifier: Typing :: Typed
26
26
  Requires-Python: >=3.11
27
27
  Requires-Dist: httpx>=0.28.0
28
28
  Requires-Dist: langchain-core>=0.3.0
29
- Requires-Dist: orchid-ai>=1.3.5
29
+ Requires-Dist: orchid-ai>=1.4.0
30
30
  Requires-Dist: pydantic-settings>=2.7.0
31
31
  Requires-Dist: pyyaml>=6.0
32
32
  Requires-Dist: rich>=13.0
@@ -164,24 +164,17 @@ Manage per-server OAuth for MCP servers that declare `auth.mode: oauth` in `agen
164
164
  # Show authorization status for every OAuth-enabled MCP server
165
165
  orchid mcp status -c orchid.yml
166
166
 
167
- # Authorize a specific MCP server via browser (PKCE flow)
168
- orchid mcp authorize <server-name> -c orchid.yml
169
- orchid mcp authorize <server-name> -c orchid.yml --timeout 180
170
-
171
167
  # Revoke the stored token for a server
172
168
  orchid mcp revoke <server-name> -c orchid.yml
173
169
  ```
174
170
 
175
- Chat commands also **auto-authorize** any unauthorized MCP servers on first use — the CLI opens the browser, waits for consent, stores the token, and proceeds.
171
+ Authorization itself runs through the API gateway's OAuth callback — the CLI's job is only to surface status and let the user revoke a stored token.
176
172
 
177
173
  ### RAG Indexing
178
174
 
179
- Seed the vector store with data, **on startup or any time later**:
175
+ Index documents into the vector store, **on startup or any time later**:
180
176
 
181
177
  ```bash
182
- # Run the registered StaticIndexer (consumer-provided seed data)
183
- orchid index seed -c orchid.yml --tenant my-tenant
184
-
185
178
  # Index a single document (PDF, DOCX, XLSX, CSV, TXT, MD, PNG, JPG)
186
179
  orchid index file ./docs/faq.pdf -n support -c orchid.yml
187
180
 
@@ -354,6 +347,17 @@ When the access token expires and a refresh token is available, the CLI refreshe
354
347
 
355
348
  When `identity_resolver_class` is configured, the CLI calls the resolver after login to populate `tenant_key` and `user_id` from the OAuth token. These identity fields are cached in the token file so subsequent commands don't need the resolver. See the [orchid OrchidIdentityResolver ABC](../orchid/orchid_ai/core/identity.py) for the interface.
356
349
 
350
+ > **Note:** the CLI is an **independent OAuth client** — it runs its own
351
+ > authorization-code + PKCE dance against the upstream IdP and calls the
352
+ > identity resolver locally. It does NOT use the
353
+ > centralised `/auth/exchange-code` / `/auth/resolve-identity` /
354
+ > `/auth/refresh-token` endpoints that the MCP gateway uses (Phases 1–5
355
+ > of the [auth-centralisation roadmap](../.knowledge/auth-centralisation.md)).
356
+ > The CLI ships with the upstream secret `client_id` baked into its config
357
+ > because it's a desktop app, not a network service. A future migration
358
+ > could route the CLI through the same endpoints to remove the
359
+ > CLI-side `client_secret` / userinfo coupling — out of scope today.
360
+
357
361
  ### Dev Fallback
358
362
 
359
363
  When `auth.dev_bypass: true` or `auth.cli` is absent, the CLI uses a dummy token (`cli-token`, tenant=`cli`, user=`cli-user`). This is fully backward compatible -- existing configs without OAuth continue to work unchanged.
@@ -122,24 +122,17 @@ Manage per-server OAuth for MCP servers that declare `auth.mode: oauth` in `agen
122
122
  # Show authorization status for every OAuth-enabled MCP server
123
123
  orchid mcp status -c orchid.yml
124
124
 
125
- # Authorize a specific MCP server via browser (PKCE flow)
126
- orchid mcp authorize <server-name> -c orchid.yml
127
- orchid mcp authorize <server-name> -c orchid.yml --timeout 180
128
-
129
125
  # Revoke the stored token for a server
130
126
  orchid mcp revoke <server-name> -c orchid.yml
131
127
  ```
132
128
 
133
- Chat commands also **auto-authorize** any unauthorized MCP servers on first use — the CLI opens the browser, waits for consent, stores the token, and proceeds.
129
+ Authorization itself runs through the API gateway's OAuth callback — the CLI's job is only to surface status and let the user revoke a stored token.
134
130
 
135
131
  ### RAG Indexing
136
132
 
137
- Seed the vector store with data, **on startup or any time later**:
133
+ Index documents into the vector store, **on startup or any time later**:
138
134
 
139
135
  ```bash
140
- # Run the registered StaticIndexer (consumer-provided seed data)
141
- orchid index seed -c orchid.yml --tenant my-tenant
142
-
143
136
  # Index a single document (PDF, DOCX, XLSX, CSV, TXT, MD, PNG, JPG)
144
137
  orchid index file ./docs/faq.pdf -n support -c orchid.yml
145
138
 
@@ -312,6 +305,17 @@ When the access token expires and a refresh token is available, the CLI refreshe
312
305
 
313
306
  When `identity_resolver_class` is configured, the CLI calls the resolver after login to populate `tenant_key` and `user_id` from the OAuth token. These identity fields are cached in the token file so subsequent commands don't need the resolver. See the [orchid OrchidIdentityResolver ABC](../orchid/orchid_ai/core/identity.py) for the interface.
314
307
 
308
+ > **Note:** the CLI is an **independent OAuth client** — it runs its own
309
+ > authorization-code + PKCE dance against the upstream IdP and calls the
310
+ > identity resolver locally. It does NOT use the
311
+ > centralised `/auth/exchange-code` / `/auth/resolve-identity` /
312
+ > `/auth/refresh-token` endpoints that the MCP gateway uses (Phases 1–5
313
+ > of the [auth-centralisation roadmap](../.knowledge/auth-centralisation.md)).
314
+ > The CLI ships with the upstream secret `client_id` baked into its config
315
+ > because it's a desktop app, not a network service. A future migration
316
+ > could route the CLI through the same endpoints to remove the
317
+ > CLI-side `client_secret` / userinfo coupling — out of scope today.
318
+
315
319
  ### Dev Fallback
316
320
 
317
321
  When `auth.dev_bypass: true` or `auth.cli` is absent, the CLI uses a dummy token (`cli-token`, tenant=`cli`, user=`cli-user`). This is fully backward compatible -- existing configs without OAuth continue to work unchanged.
@@ -0,0 +1,40 @@
1
+ """Tiny decorator that lets Typer call ``async def`` command bodies.
2
+
3
+ Vanilla Typer (and the underlying Click) don't await coroutines — an
4
+ ``async def`` command would print ``<coroutine ...>`` and exit. The
5
+ ``async_command`` decorator wraps a coroutine in a sync trampoline that
6
+ Typer can register as a normal command, so command files stop carrying
7
+ the boilerplate pair of ``def cmd(...): asyncio.run(_cmd(...))`` plus
8
+ ``async def _cmd(...)``.
9
+
10
+ Usage
11
+ -----
12
+
13
+ @app.command()
14
+ @async_command
15
+ async def create(title: str = "") -> None:
16
+ ...
17
+
18
+ The two decorators must be in this order: ``@app.command()`` outermost
19
+ (Typer registers the wrapper), ``@async_command`` inside (returns a
20
+ sync callable for Typer to register).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import functools
27
+ from collections.abc import Awaitable
28
+ from typing import Any, Callable, TypeVar
29
+
30
+ T = TypeVar("T")
31
+
32
+
33
+ def async_command(func: Callable[..., Awaitable[T]]) -> Callable[..., T]:
34
+ """Wrap an ``async def`` Typer command body in :func:`asyncio.run`."""
35
+
36
+ @functools.wraps(func)
37
+ def wrapper(*args: Any, **kwargs: Any) -> T:
38
+ return asyncio.run(func(*args, **kwargs))
39
+
40
+ return wrapper
@@ -36,6 +36,13 @@ class OAuthProviderConfig:
36
36
  identity_resolver_class: str = ""
37
37
  # Optional domain (passed to OrchidIdentityResolver.resolve).
38
38
  domain: str = ""
39
+ # Optional ``client_secret`` for OAuth servers that register the CLI
40
+ # as a CONFIDENTIAL client (PKCE alone is not enough — the
41
+ # authorization server requires both the verifier AND the secret on
42
+ # the token endpoint). Pure-public PKCE setups leave this empty.
43
+ # Mirrored to refresh-token grants too — see
44
+ # :func:`orchid_cli.auth.middleware._refresh_token`.
45
+ client_secret: str = ""
39
46
 
40
47
 
41
48
  def load_oauth_config(config_path: str) -> OAuthProviderConfig | None:
@@ -76,6 +83,11 @@ def load_oauth_config(config_path: str) -> OAuthProviderConfig | None:
76
83
  issuer = cli_section.get("issuer", "")
77
84
  auth_endpoint = cli_section.get("authorization_endpoint", "")
78
85
  token_endpoint = cli_section.get("token_endpoint", "")
86
+ # Optional secret for OAuth servers that require the CLI to
87
+ # authenticate as a CONFIDENTIAL client (PKCE alone is not enough
88
+ # — the token endpoint demands ``client_secret`` too). Empty
89
+ # string for pure-public PKCE clients.
90
+ client_secret = cli_section.get("client_secret", "")
79
91
 
80
92
  # Carry forward top-level auth fields for identity resolution.
81
93
  identity_resolver_class = auth_section.get("identity_resolver_class", "")
@@ -89,6 +101,7 @@ def load_oauth_config(config_path: str) -> OAuthProviderConfig | None:
89
101
  issuer=issuer,
90
102
  identity_resolver_class=identity_resolver_class,
91
103
  domain=domain,
104
+ client_secret=client_secret,
92
105
  )
93
106
 
94
107
 
@@ -136,6 +149,7 @@ async def discover_oidc_endpoints(config: OAuthProviderConfig) -> OAuthProviderC
136
149
  issuer=config.issuer,
137
150
  identity_resolver_class=config.identity_resolver_class,
138
151
  domain=config.domain,
152
+ client_secret=config.client_secret,
139
153
  )
140
154
 
141
155
 
@@ -52,6 +52,7 @@ async def run_login_flow(
52
52
  "auth_endpoint": config.authorization_endpoint,
53
53
  "token_endpoint": config.token_endpoint,
54
54
  "client_id": config.client_id,
55
+ "client_secret": config.client_secret,
55
56
  "scopes": config.scopes,
56
57
  "timeout": timeout,
57
58
  "success_title": "Authentication successful",
@@ -101,17 +101,62 @@ async def get_auth_context(
101
101
  logger.warning("[CLI Auth] Token expired and no refresh token. Run 'orchid auth login'.")
102
102
  return _DEV_AUTH
103
103
 
104
- # Build OrchidAuthContextuse stored identity if available.
104
+ # ── Fast path rebuild the typed subclass from the cache ──
105
+ #
106
+ # When the most recent login resolved an identity, both
107
+ # ``auth_class`` (FQN) and ``auth_state`` (the dict produced by
108
+ # ``OrchidAuthContext.to_storage_dict()``) were persisted on the
109
+ # token. We rebuild the typed instance directly here — no
110
+ # network round-trip — and pass the fresh ``access_token`` /
111
+ # ``expires_at`` from the (possibly just-refreshed) token in
112
+ # case they rolled over.
113
+ if token.auth_class and token.auth_state:
114
+ try:
115
+ from orchid_ai.utils import import_class
116
+
117
+ cls = import_class(token.auth_class)
118
+ return cls.from_storage_dict(
119
+ access_token=token.access_token,
120
+ expires_at=token.expires_at,
121
+ state=dict(token.auth_state),
122
+ )
123
+ except Exception as exc:
124
+ # Cache restoration failed — most likely the resolver's
125
+ # subclass moved or its ``from_storage_dict`` got a
126
+ # breaking change. Log loudly and fall through to the
127
+ # resolver (slow path); the operator will see the
128
+ # warning and can ``orchid auth login`` once to refresh
129
+ # the cache shape.
130
+ logger.warning(
131
+ "[CLI Auth] Cached identity rebuild failed (%s: %s) — falling back to live resolver",
132
+ type(exc).__name__,
133
+ exc,
134
+ )
135
+
136
+ # ── Slow path — call the resolver, then cache for next time ──
137
+ #
138
+ # Used on first login (cache empty), after a manual ``orchid
139
+ # auth logout``, OR when the cached subclass-class moved and we
140
+ # had to bail out of the fast path above.
105
141
  auth = OrchidAuthContext(
106
142
  access_token=token.access_token,
107
143
  tenant_key=token.tenant_key or "default",
108
144
  user_id=token.user_id or "cli-user",
109
145
  expires_at=token.expires_at,
146
+ extra=dict(token.extra) if token.extra else None,
110
147
  )
111
-
112
- # Optionally resolve identity via OrchidIdentityResolver.
113
- if cfg.identity_resolver_class and (not token.tenant_key or not token.user_id):
148
+ if cfg.identity_resolver_class:
114
149
  auth = await _resolve_identity(cfg, token, auth)
150
+ # Persist the freshly-resolved identity so the NEXT command
151
+ # takes the fast path above. Mirror the same logic as
152
+ # ``commands/auth.py:_resolve_and_store_identity`` so a
153
+ # one-off ``orchid chat`` against a token that lost its
154
+ # cache (e.g. legacy tokens written before this feature
155
+ # existed) self-heals on first use.
156
+ if type(auth) is not OrchidAuthContext:
157
+ token.auth_class = f"{type(auth).__module__}.{type(auth).__qualname__}"
158
+ token.auth_state = auth.to_storage_dict()
159
+ saver(cfg.client_id, token)
115
160
 
116
161
  return auth
117
162
 
@@ -120,12 +165,21 @@ async def _refresh_token(
120
165
  config: OAuthProviderConfig,
121
166
  token: StoredToken,
122
167
  ) -> StoredToken:
123
- """Use the refresh_token grant to obtain a new access token."""
124
- payload = {
168
+ """Use the refresh_token grant to obtain a new access token.
169
+
170
+ Mirrors :func:`exchange_code_for_tokens` — when the OAuth server
171
+ treats the CLI as a confidential client, the refresh grant ALSO
172
+ needs ``client_secret`` in the form body. Pure-public PKCE
173
+ deployments leave ``config.client_secret`` empty and the field is
174
+ omitted.
175
+ """
176
+ payload: dict[str, str] = {
125
177
  "grant_type": "refresh_token",
126
178
  "refresh_token": token.refresh_token,
127
179
  "client_id": config.client_id,
128
180
  }
181
+ if config.client_secret:
182
+ payload["client_secret"] = config.client_secret
129
183
 
130
184
  async with httpx.AsyncClient(timeout=30) as client:
131
185
  resp = await client.post(
@@ -28,7 +28,9 @@ import base64
28
28
  import hashlib
29
29
  import html
30
30
  import logging
31
+ import os
31
32
  import secrets
33
+ import shlex
32
34
  import socket
33
35
  import webbrowser
34
36
  from dataclasses import dataclass
@@ -188,20 +190,36 @@ async def exchange_code_for_tokens(
188
190
  code_verifier: str,
189
191
  redirect_uri: str,
190
192
  client_id: str,
193
+ client_secret: str = "",
191
194
  timeout: float = 30.0,
192
195
  ) -> dict:
193
196
  """POST the authorization code to the token endpoint; return the JSON body.
194
197
 
198
+ ``client_secret`` is optional — populate it when the OAuth server
199
+ treats the CLI as a CONFIDENTIAL client (PKCE alone isn't enough
200
+ — the token endpoint demands the secret as well). Sent as
201
+ ``client_secret_post`` (form-body field) per RFC 6749 §2.3.1
202
+ because that's what every OAuth server we've seen accepts and it
203
+ doesn't require pre-flight knowledge of which auth method the
204
+ server supports.
205
+
195
206
  Raises ``httpx.HTTPStatusError`` on non-2xx responses so callers can
196
207
  distinguish transport errors from OAuth errors.
208
+
209
+ Note: the equivalent ``curl`` invocation is rendered by
210
+ :func:`run_pkce_flow` (pre-flight when ``ORCHID_AUTH_DEBUG_CURL``
211
+ is set, post-mortem on failure regardless). Putting the diagnostic
212
+ one level up keeps this primitive HTTP-only.
197
213
  """
198
- payload = {
214
+ payload: dict[str, str] = {
199
215
  "grant_type": "authorization_code",
200
216
  "code": code,
201
217
  "redirect_uri": redirect_uri,
202
218
  "client_id": client_id,
203
219
  "code_verifier": code_verifier,
204
220
  }
221
+ if client_secret:
222
+ payload["client_secret"] = client_secret
205
223
 
206
224
  async with httpx.AsyncClient(timeout=timeout) as client:
207
225
  resp = await client.post(
@@ -213,6 +231,33 @@ async def exchange_code_for_tokens(
213
231
  return resp.json()
214
232
 
215
233
 
234
+ def _build_curl(url: str, body: dict[str, str]) -> str:
235
+ """Render the equivalent ``curl`` command for a form-encoded POST.
236
+
237
+ Each field uses ``--data-urlencode`` so ``=`` / ``&`` / spaces
238
+ inside values can't break the line. Output is single-line and
239
+ backslash-continued so it pastes cleanly into a terminal.
240
+ """
241
+ lines = [
242
+ f"curl -i -X POST {shlex.quote(url)} \\",
243
+ " -H 'Accept: application/json' \\",
244
+ " -H 'Content-Type: application/x-www-form-urlencoded' \\",
245
+ ]
246
+ items = list(body.items())
247
+ for i, (k, v) in enumerate(items):
248
+ sep = " \\" if i < len(items) - 1 else ""
249
+ lines.append(f" --data-urlencode {shlex.quote(f'{k}={v}')}{sep}")
250
+ return "\n".join(
251
+ [
252
+ "",
253
+ "── orchid-cli token-exchange curl ─────────────",
254
+ *lines,
255
+ "───────────────────────────────────────────────",
256
+ "",
257
+ ]
258
+ )
259
+
260
+
216
261
  # ── High-level orchestration ──────────────────────────────────
217
262
 
218
263
 
@@ -238,6 +283,7 @@ async def run_pkce_flow(
238
283
  token_endpoint: str,
239
284
  client_id: str,
240
285
  scopes: str,
286
+ client_secret: str = "",
241
287
  timeout: float = 120.0,
242
288
  browser_opener: BrowserOpener = webbrowser.open,
243
289
  success_title: str = "Authorization complete",
@@ -293,6 +339,24 @@ async def run_pkce_flow(
293
339
  if callback.state != state:
294
340
  return PKCEFlowResult(success=False, error="State mismatch — possible CSRF")
295
341
 
342
+ # Build the exact request payload up-front so we can render the
343
+ # equivalent curl on failure (or when the debug env var is set).
344
+ exchange_payload: dict[str, str] = {
345
+ "grant_type": "authorization_code",
346
+ "code": callback.code,
347
+ "redirect_uri": redirect_uri,
348
+ "client_id": client_id,
349
+ "code_verifier": code_verifier,
350
+ }
351
+ if client_secret:
352
+ exchange_payload["client_secret"] = client_secret
353
+
354
+ debug_curl = os.environ.get("ORCHID_AUTH_DEBUG_CURL", "").strip().lower() not in ("", "0", "false", "no")
355
+ if debug_curl:
356
+ # Pre-flight dump — visible BEFORE the request goes out so the
357
+ # operator can copy it into another terminal and replay there.
358
+ print(_build_curl(token_endpoint, exchange_payload), flush=True)
359
+
296
360
  try:
297
361
  data = await exchange_code_for_tokens(
298
362
  token_endpoint=token_endpoint,
@@ -300,9 +364,24 @@ async def run_pkce_flow(
300
364
  code_verifier=code_verifier,
301
365
  redirect_uri=redirect_uri,
302
366
  client_id=client_id,
367
+ client_secret=client_secret,
303
368
  )
304
369
  except httpx.HTTPStatusError as exc:
305
- return PKCEFlowResult(success=False, error=f"Token exchange failed (HTTP {exc.response.status_code})")
370
+ # Surface the upstream OAuth error verbatim without it, every
371
+ # 400 looks the same and there's no way to tell ``invalid_grant``
372
+ # apart from ``invalid_client`` apart from ``redirect_uri``
373
+ # mismatch. RFC 6749 §5.2 mandates the body be JSON with
374
+ # ``error`` + ``error_description`` fields, but some servers
375
+ # return text/html — try JSON first, fall back to raw text.
376
+ try:
377
+ body = exc.response.json()
378
+ detail = f"{body.get('error', '?')}: {body.get('error_description', body)}"
379
+ except Exception:
380
+ detail = (exc.response.text or "<empty body>")[:300]
381
+ return PKCEFlowResult(
382
+ success=False,
383
+ error=f"Token exchange failed (HTTP {exc.response.status_code}): {detail}",
384
+ )
306
385
  except Exception as exc: # pragma: no cover — defensive
307
386
  return PKCEFlowResult(success=False, error=f"Token exchange failed: {exc}")
308
387