onehouse-cli 0.2.0__tar.gz → 0.4.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 (41) hide show
  1. {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/PKG-INFO +72 -4
  2. {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/README.md +69 -2
  3. onehouse_cli-0.4.0/onehouse_cli/__init__.py +32 -0
  4. onehouse_cli-0.4.0/onehouse_cli/ai_gateway_proxy.py +229 -0
  5. {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/cli.py +25 -3
  6. {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/client.py +13 -1
  7. onehouse_cli-0.4.0/onehouse_cli/commands/ai_gateway.py +321 -0
  8. onehouse_cli-0.4.0/onehouse_cli/commands/auth.py +75 -0
  9. onehouse_cli-0.4.0/onehouse_cli/commands/install.py +77 -0
  10. onehouse_cli-0.4.0/onehouse_cli/commands/skill.py +86 -0
  11. {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/configure.py +30 -21
  12. onehouse_cli-0.4.0/onehouse_cli/profile.py +53 -0
  13. onehouse_cli-0.4.0/onehouse_cli/session_store.py +111 -0
  14. onehouse_cli-0.4.0/onehouse_cli/skills/onehouse-cli/SKILL.md +525 -0
  15. onehouse_cli-0.4.0/onehouse_cli/skills/onehouse-cli/references/command-map.md +824 -0
  16. onehouse_cli-0.4.0/onehouse_cli/skills/onehouse-cli/references/docs-links.md +134 -0
  17. {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli.egg-info/PKG-INFO +72 -4
  18. {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli.egg-info/SOURCES.txt +11 -1
  19. {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli.egg-info/entry_points.txt +1 -0
  20. {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli.egg-info/requires.txt +2 -1
  21. {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/pyproject.toml +10 -2
  22. onehouse_cli-0.2.0/onehouse_cli/__init__.py +0 -21
  23. {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/commands/__init__.py +0 -0
  24. {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/commands/_base.py +0 -0
  25. {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/commands/api_token.py +0 -0
  26. {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/commands/catalog.py +0 -0
  27. {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/commands/cluster.py +0 -0
  28. {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/commands/database.py +0 -0
  29. {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/commands/flow.py +0 -0
  30. {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/commands/job.py +0 -0
  31. {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/commands/lake.py +0 -0
  32. {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/commands/service_principal.py +0 -0
  33. {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/commands/source.py +0 -0
  34. {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/commands/sql.py +0 -0
  35. {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/commands/table.py +0 -0
  36. {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/commands/transformation.py +0 -0
  37. {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/commands/validation.py +0 -0
  38. {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli/output.py +0 -0
  39. {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli.egg-info/dependency_links.txt +0 -0
  40. {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/onehouse_cli.egg-info/top_level.txt +0 -0
  41. {onehouse_cli-0.2.0 → onehouse_cli-0.4.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: onehouse-cli
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: A command-line tool for the full Onehouse API
5
5
  Author-email: Sagar Lakshmipathy <18vidhyasagar@gmail.com>
6
6
  License: Proprietary
@@ -16,7 +16,8 @@ Requires-Python: >=3.9
16
16
  Description-Content-Type: text/markdown
17
17
  Requires-Dist: click>=8.0
18
18
  Requires-Dist: requests>=2.28
19
- Requires-Dist: onehouse-python-sdk[resources]
19
+ Requires-Dist: onehouse-python-sdk[resources]>=0.3.0
20
+ Requires-Dist: keyring>=24
20
21
  Provides-Extra: dev
21
22
  Requires-Dist: pytest>=7.0; extra == "dev"
22
23
  Requires-Dist: pytest-mock>=3.0; extra == "dev"
@@ -33,6 +34,20 @@ pip install onehouse-cli
33
34
 
34
35
  The SDK dependency (`onehouse-python-sdk[resources]`) is installed automatically.
35
36
 
37
+ ## AI agent integration
38
+
39
+ The package bundles a skill that teaches AI coding agents how to use `oh`. Install it for your agent:
40
+
41
+ ```bash
42
+ oh install agent-skill --claude # ~/.claude/skills/onehouse-cli
43
+ oh install agent-skill --cursor # ~/.cursor/skills/onehouse-cli
44
+ oh install agent-skill --codex # ~/.agents/skills/onehouse-cli
45
+ oh install agent-skill --all # all of the above
46
+ oh install agent-skill --all --dry-run
47
+ ```
48
+
49
+ Re-run the command after `pip install --upgrade onehouse-cli` to refresh the skill. To read the skill without installing it, run `oh skill`.
50
+
36
51
  ## Setup
37
52
 
38
53
  **Configure credentials:**
@@ -46,11 +61,12 @@ Fields collected:
46
61
  - Profile name
47
62
  - Account UID
48
63
  - Project UID
49
- - API Key / API Secret
64
+ - API Key / API Secret (blank if you use `oh login`)
50
65
  - Project Region
51
66
  - User ID
52
67
  - Request ID (link UID)
53
- - API Endpoint (default: `https://api.onehouse.ai/v1`)
68
+ - API Endpoint (default: `https://api.onehouse.ai`)
69
+ - AI Gateway URL (optional; only `oh ai-gateway` reads it)
54
70
 
55
71
  **Credentials are stored in:** `~/.onehouse/credentials`
56
72
 
@@ -66,6 +82,58 @@ export ONEHOUSE_LINK_UID=...
66
82
  export ONEHOUSE_PROFILE=myprofile # select a profile from the credentials file
67
83
  ```
68
84
 
85
+ ## Sign in instead of an API key (`oh login`)
86
+
87
+ ```bash
88
+ oh login # opens your browser; commands then run as you
89
+ oh logout # revokes the session and forgets it
90
+ ```
91
+
92
+ - Your organization must have OAuth sign-in for the API turned on. Ask your Onehouse contact.
93
+ - The profile still needs Account UID, Project UID, Region and Request ID (link UID). It does not need an API key, secret or User ID: `oh-configure` accepts them blank.
94
+ - While you are signed in, commands use your session instead of the profile's API key and secret. `oh logout` switches back.
95
+ - The session renews itself for 30 days. The refresh token is kept in your OS keychain. A machine with no keychain, such as headless Linux, keeps it in `~/.onehouse/sessions/` with mode `0600`. `ONEHOUSE_SESSION_STORE=file` forces the file.
96
+ - A machine with no browser uses a service principal's API key, or `oh login --port <n>` over an SSH forward.
97
+ - `oh login` signs in to the Onehouse API only, not to an AI Gateway; that is `oh ai-gateway login`.
98
+
99
+ ## AI Gateway: Claude Code and Codex with no pasted token
100
+
101
+ `oh ai-gateway` keeps an AI Gateway token fresh for the whole session. Set the gateway once with `oh-configure` (AI Gateway URL) or pass `--gateway-url`. Everything here needs the VPN or your own network.
102
+
103
+ As a person:
104
+
105
+ ```bash
106
+ oh ai-gateway login # once; prints the one-time MCP setup for both clients
107
+ oh ai-gateway run -- claude # every session
108
+ oh ai-gateway run -- codex -m <model alias>
109
+ ```
110
+
111
+ As a service principal: use a profile with its API key and secret, skip `login`, and add the MCP entry `oh ai-gateway login` would print. In short:
112
+
113
+ ```bash
114
+ claude mcp add-json --scope user oh-ai-gateway \
115
+ '{"type":"http","url":"<gateway>/mcp","headersHelper":"oh ai-gateway token --format mcp-headers --gateway-url <gateway>"}'
116
+ oh ai-gateway run -- claude
117
+ ```
118
+
119
+ For Codex 0.148.0 or later, add to `~/.codex/config.toml`:
120
+
121
+ ```toml
122
+ [mcp_servers.oh-ai-gateway]
123
+ url = "<gateway>/mcp"
124
+ http_headers_helper = "oh ai-gateway token --format mcp-headers --gateway-url <gateway>"
125
+ ```
126
+
127
+ - Model calls go through a local proxy that `run` starts on `127.0.0.1` and stops when the client exits. It sets a fresh `X-Onehouse-Authorization` on every request and retries once after a 401. The client gets the proxy's address and a per-run secret in its environment; nothing is copied by hand or written to disk.
128
+ - MCP tools use the `headersHelper` (Claude Code) or `http_headers_helper` (Codex). The client runs it on every connection and again after a 401.
129
+ - Codex runs `http_headers_helper` without your shell's `ONEHOUSE_*` variables. The helper `oh ai-gateway login` prints carries `--profile` and, as an `env` prefix (not on Windows), any `ONEHOUSE_CREDENTIALS_FILE` or `ONEHOUSE_SESSION_STORE` you set. Writing the entry by hand, do the same, and keep a service principal's key and secret in the profile rather than in `ONEHOUSE_API_KEY` and `ONEHOUSE_API_SECRET`.
130
+ - Codex sends only `/v1/responses`, which the gateway passes to the provider as is, so `-m` must name a model alias served by an OpenAI model with the Responses API, such as `gpt-5`. An alias served by Anthropic returns 404, and `gpt-4` fails: it rejects Codex's `web_search_preview` tool and its 8K context is too small.
131
+ - Add the MCP entry at user scope: a project or local-scope `headersHelper` does not run until you accept the folder trust prompt.
132
+ - `oh ai-gateway proxy` runs the proxy on its own for other clients and prints the two variables to set.
133
+ - `oh ai-gateway token` prints a token, and `--format mcp-headers` prints the JSON a headers helper returns.
134
+ - `oh ai-gateway logout` ends the sign-in; the profile's service principal, if any, applies again. Tokens already issued to a person stay valid until they expire, up to 7 days.
135
+ - `run` warns when `ANTHROPIC_API_KEY` is set: it displaces your subscription, and the gateway forwards it upstream as your own key.
136
+
69
137
  ## Global Options
70
138
 
71
139
  ```
@@ -10,6 +10,20 @@ pip install onehouse-cli
10
10
 
11
11
  The SDK dependency (`onehouse-python-sdk[resources]`) is installed automatically.
12
12
 
13
+ ## AI agent integration
14
+
15
+ The package bundles a skill that teaches AI coding agents how to use `oh`. Install it for your agent:
16
+
17
+ ```bash
18
+ oh install agent-skill --claude # ~/.claude/skills/onehouse-cli
19
+ oh install agent-skill --cursor # ~/.cursor/skills/onehouse-cli
20
+ oh install agent-skill --codex # ~/.agents/skills/onehouse-cli
21
+ oh install agent-skill --all # all of the above
22
+ oh install agent-skill --all --dry-run
23
+ ```
24
+
25
+ Re-run the command after `pip install --upgrade onehouse-cli` to refresh the skill. To read the skill without installing it, run `oh skill`.
26
+
13
27
  ## Setup
14
28
 
15
29
  **Configure credentials:**
@@ -23,11 +37,12 @@ Fields collected:
23
37
  - Profile name
24
38
  - Account UID
25
39
  - Project UID
26
- - API Key / API Secret
40
+ - API Key / API Secret (blank if you use `oh login`)
27
41
  - Project Region
28
42
  - User ID
29
43
  - Request ID (link UID)
30
- - API Endpoint (default: `https://api.onehouse.ai/v1`)
44
+ - API Endpoint (default: `https://api.onehouse.ai`)
45
+ - AI Gateway URL (optional; only `oh ai-gateway` reads it)
31
46
 
32
47
  **Credentials are stored in:** `~/.onehouse/credentials`
33
48
 
@@ -43,6 +58,58 @@ export ONEHOUSE_LINK_UID=...
43
58
  export ONEHOUSE_PROFILE=myprofile # select a profile from the credentials file
44
59
  ```
45
60
 
61
+ ## Sign in instead of an API key (`oh login`)
62
+
63
+ ```bash
64
+ oh login # opens your browser; commands then run as you
65
+ oh logout # revokes the session and forgets it
66
+ ```
67
+
68
+ - Your organization must have OAuth sign-in for the API turned on. Ask your Onehouse contact.
69
+ - The profile still needs Account UID, Project UID, Region and Request ID (link UID). It does not need an API key, secret or User ID: `oh-configure` accepts them blank.
70
+ - While you are signed in, commands use your session instead of the profile's API key and secret. `oh logout` switches back.
71
+ - The session renews itself for 30 days. The refresh token is kept in your OS keychain. A machine with no keychain, such as headless Linux, keeps it in `~/.onehouse/sessions/` with mode `0600`. `ONEHOUSE_SESSION_STORE=file` forces the file.
72
+ - A machine with no browser uses a service principal's API key, or `oh login --port <n>` over an SSH forward.
73
+ - `oh login` signs in to the Onehouse API only, not to an AI Gateway; that is `oh ai-gateway login`.
74
+
75
+ ## AI Gateway: Claude Code and Codex with no pasted token
76
+
77
+ `oh ai-gateway` keeps an AI Gateway token fresh for the whole session. Set the gateway once with `oh-configure` (AI Gateway URL) or pass `--gateway-url`. Everything here needs the VPN or your own network.
78
+
79
+ As a person:
80
+
81
+ ```bash
82
+ oh ai-gateway login # once; prints the one-time MCP setup for both clients
83
+ oh ai-gateway run -- claude # every session
84
+ oh ai-gateway run -- codex -m <model alias>
85
+ ```
86
+
87
+ As a service principal: use a profile with its API key and secret, skip `login`, and add the MCP entry `oh ai-gateway login` would print. In short:
88
+
89
+ ```bash
90
+ claude mcp add-json --scope user oh-ai-gateway \
91
+ '{"type":"http","url":"<gateway>/mcp","headersHelper":"oh ai-gateway token --format mcp-headers --gateway-url <gateway>"}'
92
+ oh ai-gateway run -- claude
93
+ ```
94
+
95
+ For Codex 0.148.0 or later, add to `~/.codex/config.toml`:
96
+
97
+ ```toml
98
+ [mcp_servers.oh-ai-gateway]
99
+ url = "<gateway>/mcp"
100
+ http_headers_helper = "oh ai-gateway token --format mcp-headers --gateway-url <gateway>"
101
+ ```
102
+
103
+ - Model calls go through a local proxy that `run` starts on `127.0.0.1` and stops when the client exits. It sets a fresh `X-Onehouse-Authorization` on every request and retries once after a 401. The client gets the proxy's address and a per-run secret in its environment; nothing is copied by hand or written to disk.
104
+ - MCP tools use the `headersHelper` (Claude Code) or `http_headers_helper` (Codex). The client runs it on every connection and again after a 401.
105
+ - Codex runs `http_headers_helper` without your shell's `ONEHOUSE_*` variables. The helper `oh ai-gateway login` prints carries `--profile` and, as an `env` prefix (not on Windows), any `ONEHOUSE_CREDENTIALS_FILE` or `ONEHOUSE_SESSION_STORE` you set. Writing the entry by hand, do the same, and keep a service principal's key and secret in the profile rather than in `ONEHOUSE_API_KEY` and `ONEHOUSE_API_SECRET`.
106
+ - Codex sends only `/v1/responses`, which the gateway passes to the provider as is, so `-m` must name a model alias served by an OpenAI model with the Responses API, such as `gpt-5`. An alias served by Anthropic returns 404, and `gpt-4` fails: it rejects Codex's `web_search_preview` tool and its 8K context is too small.
107
+ - Add the MCP entry at user scope: a project or local-scope `headersHelper` does not run until you accept the folder trust prompt.
108
+ - `oh ai-gateway proxy` runs the proxy on its own for other clients and prints the two variables to set.
109
+ - `oh ai-gateway token` prints a token, and `--format mcp-headers` prints the JSON a headers helper returns.
110
+ - `oh ai-gateway logout` ends the sign-in; the profile's service principal, if any, applies again. Tokens already issued to a person stay valid until they expire, up to 7 days.
111
+ - `run` warns when `ANTHROPIC_API_KEY` is set: it displaces your subscription, and the gateway forwards it upstream as your own key.
112
+
46
113
  ## Global Options
47
114
 
48
115
  ```
@@ -0,0 +1,32 @@
1
+ """
2
+ Onehouse CLI - A command-line tool for executing SQL commands against the Onehouse API.
3
+ """
4
+
5
+ try:
6
+ # For Python 3.8+
7
+ from importlib.metadata import version
8
+ except ImportError:
9
+ # For Python 3.7
10
+ from importlib_metadata import version
11
+
12
+ try:
13
+ __version__ = version("onehouse-cli")
14
+ except Exception:
15
+ # Fallback version if package is not installed
16
+ __version__ = "0.0.0.dev"
17
+
18
+ __all__ = ["cli_main", "configure_main"]
19
+
20
+
21
+ # Imported lazily (PEP 562) so that pulling in a leaf module does not drag in
22
+ # the whole command tree — and with it the SDK dependency. This keeps
23
+ # `oh-skill` readable on an install where onehouse-python-sdk is missing,
24
+ # which is precisely when someone needs the usage guide.
25
+ def __getattr__(name):
26
+ if name == "cli_main":
27
+ from .cli import main
28
+ return main
29
+ if name == "configure_main":
30
+ from .configure import main
31
+ return main
32
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,229 @@
1
+ """A local forwarding proxy that keeps an AI Gateway token fresh for model calls.
2
+
3
+ Claude Code reads ``ANTHROPIC_CUSTOM_HEADERS`` once per session, and the gateway's
4
+ ``/`` route reads only ``X-Onehouse-Authorization``, so a long session outlives any
5
+ token set in the environment. Pointed at this proxy, a client needs no token at all:
6
+ the proxy sets a fresh one on every request and replays once after a 401.
7
+
8
+ Any local process can reach a loopback port, so a request must carry the per-run
9
+ secret, and the ``Host`` must be the proxy's own (no DNS rebinding from a browser).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hmac
15
+ import http.client
16
+ import http.server
17
+ import secrets
18
+ import ssl
19
+ import sys
20
+ import threading
21
+ import urllib.parse
22
+ from typing import Optional
23
+
24
+ from onehouse_python_sdk._oauth import default_ssl_context
25
+
26
+ #: Carries the per-run secret from the client to the proxy. Stripped before forwarding.
27
+ SECRET_HEADER = "X-Oh-Proxy-Secret"
28
+ GATEWAY_AUTH_HEADER = "X-Onehouse-Authorization"
29
+
30
+ _HOP_BY_HOP = {
31
+ "connection",
32
+ "keep-alive",
33
+ "proxy-authenticate",
34
+ "proxy-authorization",
35
+ "proxy-connection",
36
+ "te",
37
+ "trailer",
38
+ "trailers",
39
+ "transfer-encoding",
40
+ "upgrade",
41
+ }
42
+ _DROPPED_REQUEST_HEADERS = _HOP_BY_HOP | {
43
+ "host",
44
+ "content-length",
45
+ SECRET_HEADER.lower(),
46
+ GATEWAY_AUTH_HEADER.lower(),
47
+ }
48
+ _UPSTREAM_TIMEOUT_SECONDS = 600
49
+ _CHUNK = 8192
50
+
51
+
52
+ def new_secret() -> str:
53
+ return secrets.token_urlsafe(32)
54
+
55
+
56
+ class _Handler(http.server.BaseHTTPRequestHandler):
57
+ protocol_version = "HTTP/1.1"
58
+ # Set per server by AiGatewayProxy.
59
+ proxy: "AiGatewayProxy"
60
+
61
+ def log_message(self, *_args) -> None:
62
+ """The default access log would print request lines; tokens never pass through it,
63
+ but nothing here needs logging either."""
64
+
65
+ def do_CONNECT(self) -> None: # noqa: N802
66
+ self._refuse(405, "CONNECT is not supported")
67
+
68
+ def do_GET(self) -> None: # noqa: N802
69
+ self._forward()
70
+
71
+ do_POST = do_PUT = do_PATCH = do_DELETE = do_HEAD = do_OPTIONS = do_GET # noqa: N815
72
+
73
+ def _refuse(self, status: int, message: str) -> None:
74
+ body = message.encode("utf-8")
75
+ self.send_response(status)
76
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
77
+ self.send_header("Content-Length", str(len(body)))
78
+ self.send_header("Connection", "close")
79
+ self.end_headers()
80
+ self.wfile.write(body)
81
+ self.close_connection = True
82
+
83
+ def _read_body(self) -> Optional[bytes]:
84
+ if self.headers.get("Transfer-Encoding", "").lower() == "chunked":
85
+ chunks = []
86
+ while True:
87
+ size = int(self.rfile.readline().split(b";", 1)[0].strip() or b"0", 16)
88
+ if size == 0:
89
+ while self.rfile.readline() not in (b"\r\n", b"\n", b""):
90
+ pass
91
+ break
92
+ chunks.append(self.rfile.read(size))
93
+ self.rfile.readline()
94
+ return b"".join(chunks)
95
+ length = int(self.headers.get("Content-Length") or 0)
96
+ return self.rfile.read(length) if length else None
97
+
98
+ def _forward(self) -> None:
99
+ proxy = self.proxy
100
+ if self.headers.get("Host", "").lower() not in proxy.allowed_hosts:
101
+ self._refuse(403, "unexpected Host")
102
+ return
103
+ if not hmac.compare_digest(self.headers.get(SECRET_HEADER, ""), proxy.secret):
104
+ self._refuse(403, f"missing or wrong {SECRET_HEADER}")
105
+ return
106
+ # Origin-form only: an absolute-form target would let a caller pick the upstream.
107
+ if not self.path.startswith("/") or self.path.startswith("//"):
108
+ self._refuse(400, "absolute-form request targets are not supported")
109
+ return
110
+
111
+ # Buffered so a request refused for an expired token can be replayed once.
112
+ body = self._read_body()
113
+ try:
114
+ upstream = proxy.send(self.command, self.path, self.headers, body, force=False)
115
+ if upstream.status == 401:
116
+ upstream.close()
117
+ upstream = proxy.send(self.command, self.path, self.headers, body, force=True)
118
+ except Exception as err: # noqa: BLE001 - surfaced to the client, never the token
119
+ self._refuse(502, f"could not reach the AI Gateway: {type(err).__name__}: {err}")
120
+ return
121
+ self._relay(upstream)
122
+
123
+ def _relay(self, upstream: "http.client.HTTPResponse") -> None:
124
+ self.send_response(upstream.status, upstream.reason)
125
+ length = upstream.getheader("Content-Length")
126
+ for name, value in upstream.getheaders():
127
+ if name.lower() in _HOP_BY_HOP or name.lower() == "content-length":
128
+ continue
129
+ self.send_header(name, value)
130
+ # http.client already undid any upstream chunking, so re-chunk when the length
131
+ # is unknown: that is what lets SSE stream through without buffering.
132
+ chunked = length is None and self.command != "HEAD"
133
+ if chunked:
134
+ self.send_header("Transfer-Encoding", "chunked")
135
+ elif length is not None:
136
+ self.send_header("Content-Length", length)
137
+ self.end_headers()
138
+ try:
139
+ while self.command != "HEAD":
140
+ data = upstream.read1(_CHUNK)
141
+ if not data:
142
+ break
143
+ if chunked:
144
+ self.wfile.write(f"{len(data):X}\r\n".encode("ascii") + data + b"\r\n")
145
+ else:
146
+ self.wfile.write(data)
147
+ self.wfile.flush()
148
+ if chunked:
149
+ self.wfile.write(b"0\r\n\r\n")
150
+ self.wfile.flush()
151
+ finally:
152
+ upstream.close()
153
+
154
+
155
+ class _Server(http.server.ThreadingHTTPServer):
156
+ def handle_error(self, request, client_address) -> None:
157
+ # The proxy shares a terminal with the client it runs, so the default traceback
158
+ # lands in that client's UI. A client dropping a kept-alive connection is routine.
159
+ if isinstance(sys.exc_info()[1], ConnectionError):
160
+ return
161
+ super().handle_error(request, client_address)
162
+
163
+
164
+ class AiGatewayProxy:
165
+ """Forwards to one gateway only, setting ``X-Onehouse-Authorization`` from ``token_source``.
166
+
167
+ ``Authorization`` and ``x-api-key`` pass through untouched: a subscription token
168
+ stays on the gateway's passthrough branch, and a client that sends none stays
169
+ mediated.
170
+ """
171
+
172
+ def __init__(
173
+ self,
174
+ gateway_url: str,
175
+ token_source,
176
+ *,
177
+ port: int = 0,
178
+ secret: Optional[str] = None,
179
+ ssl_context: Optional[ssl.SSLContext] = None,
180
+ connection_factory=None,
181
+ ) -> None:
182
+ parts = urllib.parse.urlsplit(gateway_url)
183
+ if parts.scheme != "https" or not parts.netloc:
184
+ raise ValueError(f"AI Gateway URL must be https: {gateway_url!r}")
185
+ self._gateway_host = parts.netloc
186
+ self._gateway_base_path = parts.path.rstrip("/")
187
+ self._token_source = token_source
188
+ self._ssl_context = ssl_context or default_ssl_context()
189
+ # Tests swap in a plain-HTTP connection to a local upstream.
190
+ self._connection_factory = connection_factory or (
191
+ lambda host: http.client.HTTPSConnection(
192
+ host, timeout=_UPSTREAM_TIMEOUT_SECONDS, context=self._ssl_context
193
+ )
194
+ )
195
+ self.secret = secret or new_secret()
196
+ handler = type("_BoundHandler", (_Handler,), {"proxy": self})
197
+ self._server = _Server(("127.0.0.1", port), handler)
198
+ self._server.daemon_threads = True
199
+ self.port = self._server.server_address[1]
200
+ self.allowed_hosts = {f"127.0.0.1:{self.port}", f"localhost:{self.port}"}
201
+ self._thread: Optional[threading.Thread] = None
202
+
203
+ @property
204
+ def base_url(self) -> str:
205
+ return f"http://127.0.0.1:{self.port}"
206
+
207
+ def send(self, method: str, path: str, headers, body: Optional[bytes], *, force: bool):
208
+ token = self._token_source.get(force=force)
209
+ connection = self._connection_factory(self._gateway_host)
210
+ connection.putrequest(method, self._gateway_base_path + path, skip_accept_encoding=True)
211
+ for name, value in headers.items():
212
+ if name.lower() not in _DROPPED_REQUEST_HEADERS:
213
+ connection.putheader(name, value)
214
+ connection.putheader(GATEWAY_AUTH_HEADER, f"Bearer {token}")
215
+ if body is not None:
216
+ connection.putheader("Content-Length", str(len(body)))
217
+ connection.endheaders(body)
218
+ return connection.getresponse()
219
+
220
+ def start(self) -> None:
221
+ self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
222
+ self._thread.start()
223
+
224
+ def serve_forever(self) -> None:
225
+ self._server.serve_forever()
226
+
227
+ def stop(self) -> None:
228
+ self._server.shutdown()
229
+ self._server.server_close()
@@ -5,11 +5,12 @@ Global options (--profile, --timeout, --output) are propagated to every
5
5
  subcommand via the Click context object (ctx.obj).
6
6
  """
7
7
 
8
- import sys
9
8
  import click
10
9
 
11
10
  from . import __version__
12
11
  from .commands import (
12
+ ai_gateway,
13
+ auth,
13
14
  cluster,
14
15
  lake,
15
16
  database,
@@ -22,10 +23,21 @@ from .commands import (
22
23
  job,
23
24
  service_principal,
24
25
  api_token,
26
+ install,
27
+ skill as skill_cmd,
25
28
  sql as sql_cmd,
26
29
  )
27
30
 
28
31
 
32
+ _EPILOG = """\
33
+ Agents and new users: run `oh skill` for a complete usage guide covering
34
+ credential setup, the SQL each command emits, and destructive-operation safety.
35
+
36
+ Note: deletes execute immediately with no confirmation prompt. Use --show-sql
37
+ on any command to preview the statement without running it.
38
+ """
39
+
40
+
29
41
  class CliContext:
30
42
  def __init__(self, profile: str | None, timeout: int, output: str):
31
43
  self.profile = profile
@@ -33,7 +45,10 @@ class CliContext:
33
45
  self.output = output
34
46
 
35
47
 
36
- @click.group(context_settings={"help_option_names": ["-h", "--help"]})
48
+ @click.group(
49
+ context_settings={"help_option_names": ["-h", "--help"]},
50
+ epilog=_EPILOG,
51
+ )
37
52
  @click.version_option(__version__, "-v", "--version")
38
53
  @click.option(
39
54
  "--profile", "-p",
@@ -75,10 +90,17 @@ cli.add_command(job.job)
75
90
  cli.add_command(service_principal.service_principal, name="service-principal")
76
91
  cli.add_command(api_token.api_token, name="api-token")
77
92
  cli.add_command(sql_cmd.sql)
93
+ cli.add_command(skill_cmd.skill)
94
+ cli.add_command(install.install)
95
+ cli.add_command(auth.login)
96
+ cli.add_command(auth.logout)
97
+ cli.add_command(ai_gateway.ai_gateway_group)
78
98
 
79
99
 
80
100
  def main() -> None:
81
- sys.exit(cli(standalone_mode=False) or 0)
101
+ # Standalone mode turns Click errors into a one-line `Error: ...` message
102
+ # and the conventional exit code (1 = failure, 2 = usage error).
103
+ cli()
82
104
 
83
105
 
84
106
  if __name__ == "__main__":
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
  import click
8
8
 
9
9
  try:
10
+ from onehouse_python_sdk.resources import oauth
10
11
  from onehouse_python_sdk.resources.client import OnehouseResources
11
12
  from onehouse_python_sdk.resources.exceptions import AuthError, ResourcesError
12
13
  except ImportError as exc:
@@ -17,9 +18,16 @@ except ImportError as exc:
17
18
  )
18
19
  sys.exit(1)
19
20
 
21
+ from .profile import api_endpoint
22
+ from .session_store import api_session_store
23
+
20
24
 
21
25
  def make_client(profile: str | None, timeout: int) -> OnehouseResources:
22
- """Return an OnehouseResources configured from profile + env vars."""
26
+ """Return an OnehouseResources configured from profile + env vars.
27
+
28
+ After `oh login` the client sends the signed-in user's token, and the profile's
29
+ API key and secret are not used. Without a session it uses them, as before.
30
+ """
23
31
  kwargs: dict = {
24
32
  "default_execute_timeout": float(timeout * 60),
25
33
  }
@@ -32,6 +40,10 @@ def make_client(profile: str | None, timeout: int) -> OnehouseResources:
32
40
  kwargs["profile"] = profile
33
41
 
34
42
  try:
43
+ endpoint = api_endpoint(profile)
44
+ store = api_session_store(oauth.issuer_for(endpoint))
45
+ if store.get() is not None:
46
+ kwargs["token_source"] = oauth.FirebaseRefreshTokenSource(endpoint, store)
35
47
  return OnehouseResources(**kwargs)
36
48
  except AuthError as exc:
37
49
  raise click.ClickException(f"Authentication error: {exc}") from exc