glm-launch 2026.6.1__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.
@@ -0,0 +1,354 @@
1
+ Metadata-Version: 2.4
2
+ Name: glm-launch
3
+ Version: 2026.6.1
4
+ Summary: Wrap claude/codex/opencode with Z.ai GLM settings
5
+ Requires-Python: >=3.13
6
+ Requires-Dist: typer
7
+ Description-Content-Type: text/markdown
8
+
9
+ # glm-cli-git
10
+
11
+ A Python CLI tool that wraps LLM coding tools (`claude`, `codex`, `opencode`) with [GLM](https://docs.z.ai/) settings. Instead of running a local proxy, it configures environment variables and config files, then exec's the underlying binary directly.
12
+
13
+ Requires Python 3.13+.
14
+
15
+ ## Usage
16
+
17
+ ```bash
18
+ # 1. Set your Z.AI auth token
19
+ export GLM_AUTH_TOKEN="your-zai-api-key"
20
+
21
+ # 2. Launch Claude Code routed through Z.AI (defaults to glm-5.2)
22
+ uv run glm-launch # bare command defaults to `claude`
23
+ uv run glm-launch claude # same thing, explicit
24
+
25
+ # Pick a different model
26
+ uv run glm-launch claude --model glm-5.1 # long-horizon flagship
27
+ uv run glm-launch claude --model glm-5-turbo # fast
28
+ uv run glm-launch claude --model glm-4.5-air # cheap
29
+
30
+ # Bootstrap your current shell so a plain `claude` uses Z.AI
31
+ eval "$(uv run glm-launch shell)"
32
+ claude
33
+
34
+ # See available models (built-in list, or --remote for the live API list)
35
+ uv run glm-launch models
36
+ uv run glm-launch models --remote
37
+
38
+ # Sanity-check connectivity / latency
39
+ uv run glm-launch bench
40
+ ```
41
+
42
+ > Examples use the installed `glm-launch` entrypoint. Before `uv sync` you can run
43
+ > the script directly with `uv run src/main.py …` — the two are interchangeable.
44
+
45
+ ## Installation
46
+
47
+ ```bash
48
+ uv sync
49
+ ```
50
+
51
+ This installs a `glm-launch` entrypoint. Run commands via `uv run glm-launch <command>`, or `uv tool install .` to get `glm-launch` on your PATH directly. You can also run the script without installing via `uv run src/main.py <command>`.
52
+
53
+ ### Run without cloning (`uvx`)
54
+
55
+ You can run `glm-launch` directly with [`uvx`](https://docs.astral.sh/uv/guides/tools/) (`uv tool run`) — no clone or manual install needed.
56
+
57
+ ```bash
58
+ # From GitHub (works today)
59
+ uvx --from git+https://github.com/jefftriplett/glm-launch glm-launch launch claude
60
+
61
+ # Pin to a tag/branch/commit
62
+ uvx --from git+https://github.com/jefftriplett/glm-launch@main glm-launch models
63
+ ```
64
+
65
+ Once published to PyPI, this simplifies to:
66
+
67
+ ```bash
68
+ # Coming soon — not yet on PyPI
69
+ uvx glm-launch launch claude
70
+ ```
71
+
72
+ ## Commands
73
+
74
+ ### `launch claude`
75
+
76
+ Launch [Claude Code](https://docs.anthropic.com/en/docs/claude-code) with GLM environment settings. Sets Anthropic env vars to route requests through Z.AI's Anthropic-compatible endpoint, then exec's the `claude` binary.
77
+
78
+ > The `launch` prefix is optional: `glm-launch claude` is equivalent to `glm-launch launch claude`, and a bare `glm-launch` defaults to `claude`. The same applies to `codex` and `opencode`.
79
+
80
+ ```bash
81
+ uv run glm-launch launch claude
82
+ ```
83
+
84
+ **Options:**
85
+
86
+ | Flag | Env var | Default | Description |
87
+ |------|---------|---------|-------------|
88
+ | `--model` / `-m` | — | `glm-5.2` | Model name passed to `claude --model` |
89
+ | `--base-url` | `GLM_BASE_URL` | `https://api.z.ai/api/anthropic` | API endpoint |
90
+ | `--api-key` | `GLM_API_KEY` | `""` | API key |
91
+ | `--auth-token` | `GLM_AUTH_TOKEN` | **(required)** | Z.AI auth token |
92
+ | `--api-timeout-ms` | `API_TIMEOUT_MS` | `3000000` | Request timeout in milliseconds |
93
+ | `--default-haiku-model` | `ANTHROPIC_DEFAULT_HAIKU_MODEL` | `glm-4.5-air` | Model for Haiku-tier requests |
94
+ | `--default-sonnet-model` | `ANTHROPIC_DEFAULT_SONNET_MODEL` | `glm-5.2` | Model for Sonnet-tier requests |
95
+ | `--default-opus-model` | `ANTHROPIC_DEFAULT_OPUS_MODEL` | `glm-5.2` | Model for Opus-tier requests |
96
+ | `--subagent-model` | `CLAUDE_CODE_SUBAGENT_MODEL` | `glm-4.5-air` | Model used for spawned subagents |
97
+ | `--effort-level` | `CLAUDE_CODE_EFFORT_LEVEL` | `max` | Effort level for the agent loop |
98
+ | `--attribution-header` | `CLAUDE_CODE_ATTRIBUTION_HEADER` | `0` | Attribution header toggle (`0` disables it) |
99
+ | `--auto-compact-window` | `CLAUDE_CODE_AUTO_COMPACT_WINDOW` | `200000` | Auto-compact context window in tokens (empty to leave unset) |
100
+
101
+ The following env vars are set before exec'ing `claude`:
102
+
103
+ - `ANTHROPIC_BASE_URL` — from `--base-url` / `GLM_BASE_URL`
104
+ - `ANTHROPIC_API_KEY` — from `--api-key` / `GLM_API_KEY`
105
+ - `ANTHROPIC_AUTH_TOKEN` — from `--auth-token` / `GLM_AUTH_TOKEN`
106
+ - `API_TIMEOUT_MS` — from `--api-timeout-ms` / `API_TIMEOUT_MS`
107
+ - `ANTHROPIC_DEFAULT_HAIKU_MODEL` — from `--default-haiku-model`
108
+ - `ANTHROPIC_DEFAULT_SONNET_MODEL` — from `--default-sonnet-model`
109
+ - `ANTHROPIC_DEFAULT_OPUS_MODEL` — from `--default-opus-model`
110
+ - `CLAUDE_CODE_SUBAGENT_MODEL` — from `--subagent-model`
111
+ - `CLAUDE_CODE_EFFORT_LEVEL` — from `--effort-level`
112
+ - `CLAUDE_CODE_ATTRIBUTION_HEADER` — from `--attribution-header`
113
+ - `CLAUDE_CODE_AUTO_COMPACT_WINDOW` — from `--auto-compact-window` (only when non-empty)
114
+
115
+ **Examples:**
116
+
117
+ ```bash
118
+ # Use defaults (glm-5.2, Z.AI endpoint)
119
+ uv run glm-launch launch claude
120
+
121
+ # Flagship reasoning/coding model (the default)
122
+ uv run glm-launch launch claude --model glm-5.2
123
+
124
+ # Long-horizon agentic flagship
125
+ uv run glm-launch launch claude --model glm-5.1
126
+
127
+ # Fast, speed-optimized GLM-5 variant
128
+ uv run glm-launch launch claude --model glm-5-turbo
129
+
130
+ # Lightweight, low-cost model for cheaper runs
131
+ uv run glm-launch launch claude --model glm-4.5-air
132
+
133
+ # Tune the model tiers independently (e.g. cheap subagents, flagship main)
134
+ uv run glm-launch launch claude \
135
+ --model glm-5.2 \
136
+ --subagent-model glm-4.5-air \
137
+ --default-haiku-model glm-4.5-air
138
+
139
+ # Pass extra args through to claude
140
+ uv run glm-launch launch claude -- --verbose
141
+
142
+ # Override via env vars
143
+ GLM_AUTH_TOKEN="my-token" uv run glm-launch launch claude
144
+ ```
145
+
146
+ Run `uv run glm-launch models` to see all valid model names (or `--remote` for the live list).
147
+
148
+ If `claude` is not on your PATH, the tool falls back to `~/.claude/local/claude`.
149
+
150
+ ### `launch codex`
151
+
152
+ Launch [Codex](https://github.com/openai/codex) with the `--oss` flag for local Ollama usage.
153
+
154
+ ```bash
155
+ uv run glm-launch launch codex
156
+ ```
157
+
158
+ **Options:**
159
+
160
+ | Flag | Default | Description |
161
+ |------|---------|-------------|
162
+ | `--model` / `-m` | — | Model name passed to `codex -m` |
163
+
164
+ **Examples:**
165
+
166
+ ```bash
167
+ # Launch with default settings
168
+ uv run glm-launch launch codex
169
+
170
+ # Specify a model
171
+ uv run glm-launch launch codex --model "some-model"
172
+
173
+ # Pass extra args through to codex
174
+ uv run glm-launch launch codex -- --some-flag
175
+ ```
176
+
177
+ ### `launch opencode`
178
+
179
+ Launch [opencode](https://opencode.ai/) after writing provider config. Writes an Ollama-compatible provider to `~/.config/opencode/opencode.json` and updates the recent model state at `~/.local/state/opencode/model.json`, then exec's the `opencode` binary.
180
+
181
+ ```bash
182
+ uv run glm-launch launch opencode --model "some-model"
183
+ ```
184
+
185
+ **Options:**
186
+
187
+ | Flag | Env var | Default | Description |
188
+ |------|---------|---------|-------------|
189
+ | `--model` / `-m` | — | — | Model name to configure in opencode |
190
+ | `--base-url` | `GLM_BASE_URL` | **(required)** | Base URL for the API endpoint |
191
+
192
+ **Examples:**
193
+
194
+ ```bash
195
+ # Launch with a model
196
+ GLM_BASE_URL="http://localhost:11434/v1" uv run glm-launch launch opencode --model "llama3"
197
+
198
+ # Pass extra args through to opencode
199
+ uv run glm-launch launch opencode --model "llama3" -- --some-flag
200
+ ```
201
+
202
+ ### `shell`
203
+
204
+ Print `export` lines that bootstrap your current shell with the GLM env vars — without launching anything. Eval the output and a plain `claude` (or any Anthropic SDK tool) will talk to Z.AI.
205
+
206
+ ```bash
207
+ eval "$(uv run glm-launch shell)"
208
+ claude
209
+ ```
210
+
211
+ Accepts the same model/auth options as `launch claude` (`--model`, `--auth-token`, `--default-*-model`, etc.). Secrets are shell-quoted; empty values are skipped. Sets `ANTHROPIC_MODEL` plus all the `ANTHROPIC_*` / `CLAUDE_CODE_*` vars listed under `launch claude`.
212
+
213
+ ```bash
214
+ # Inspect what would be exported
215
+ uv run glm-launch shell
216
+
217
+ # Bootstrap with a specific model
218
+ eval "$(uv run glm-launch shell --model glm-5.1)"
219
+ ```
220
+
221
+ ### `models`
222
+
223
+ List Z.AI GLM models. By default prints a built-in, annotated list; `--remote` fetches the live list from the Z.AI PaaS endpoint.
224
+
225
+ ```bash
226
+ # Built-in list (no token needed)
227
+ uv run glm-launch models
228
+
229
+ # Live list from the API (needs GLM_AUTH_TOKEN)
230
+ uv run glm-launch models --remote
231
+ ```
232
+
233
+ **Options:**
234
+
235
+ | Flag | Env var | Default | Description |
236
+ |------|---------|---------|-------------|
237
+ | `--remote` / `-r` | — | `false` | Fetch the live list from the Z.AI API |
238
+ | `--models-url` | `GLM_MODELS_URL` | `https://api.z.ai/api/paas/v4/models` | PaaS models endpoint (used with `--remote`) |
239
+ | `--auth-token` | `GLM_AUTH_TOKEN` | — | Auth token (required with `--remote`) |
240
+ | `--timeout` | — | `30.0` | Request timeout in seconds |
241
+
242
+ The live endpoint is the OpenAI-compatible PaaS base (`/api/paas/v4/models`) and uses `Authorization: Bearer <token>` — distinct from the Anthropic-style chat base (`/api/anthropic`) used by `launch claude` and `bench`.
243
+
244
+ ### `bench`
245
+
246
+ Time a single `/v1/messages` round-trip against the configured GLM endpoint. Useful as a sanity check that your auth token, base URL, and chosen model are reachable.
247
+
248
+ ```bash
249
+ uv run glm-launch bench
250
+ ```
251
+
252
+ **Options:**
253
+
254
+ | Flag | Env var | Default | Description |
255
+ |------|---------|---------|-------------|
256
+ | `--model` / `-m` | — | `glm-5.2` | Model to benchmark |
257
+ | `--base-url` | `GLM_BASE_URL` | `https://api.z.ai/api/anthropic` | API endpoint |
258
+ | `--auth-token` | `GLM_AUTH_TOKEN` | **(required)** | Auth token for the endpoint |
259
+ | `--timeout` | — | `30.0` | Request timeout in seconds |
260
+
261
+ Sends a minimal 32-token request and prints the round-trip time. Exits non-zero on HTTP error or timeout.
262
+
263
+ **Example output:**
264
+
265
+ ```
266
+ glm-5.2 via https://api.z.ai/api/anthropic
267
+ OK (200) in 412ms
268
+ ```
269
+
270
+ ### `doctor`
271
+
272
+ Check your environment for correct setup. Reports on environment variables, binary availability, and config files.
273
+
274
+ ```bash
275
+ uv run glm-launch doctor
276
+ ```
277
+
278
+ **Checks performed:**
279
+
280
+ - **Environment variables** — Whether `GLM_BASE_URL`, `GLM_API_KEY`, `GLM_AUTH_TOKEN`, `API_TIMEOUT_MS`, and the `ANTHROPIC_DEFAULT_*_MODEL` vars are set. Secrets are masked in output.
281
+ - **Binaries** — Whether `claude`, `codex`, and `opencode` are found on PATH (with fallback to `~/.claude/local/claude` for claude).
282
+ - **Config files** — Whether `~/.config/opencode/opencode.json` and `~/.local/state/opencode/model.json` exist.
283
+
284
+ Exits with code 1 if any binary is missing, 0 otherwise.
285
+
286
+ **Example output:**
287
+
288
+ ```
289
+ Environment variables:
290
+ GLM_BASE_URL: (not set)
291
+ GLM_API_KEY: (not set)
292
+ GLM_AUTH_TOKEN: (not set)
293
+ API_TIMEOUT_MS: (not set)
294
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: (not set)
295
+ ANTHROPIC_DEFAULT_SONNET_MODEL: (not set)
296
+ ANTHROPIC_DEFAULT_OPUS_MODEL: (not set)
297
+
298
+ Binaries:
299
+ claude: /usr/local/bin/claude
300
+ codex: /usr/local/bin/codex
301
+ opencode: /usr/local/bin/opencode
302
+
303
+ Config files:
304
+ /home/user/.config/opencode/opencode.json: exists
305
+ /home/user/.local/state/opencode/model.json: not found
306
+
307
+ All checks passed.
308
+ ```
309
+
310
+ ## Environment variables
311
+
312
+ | Variable | Used by | Description |
313
+ |----------|---------|-------------|
314
+ | `GLM_BASE_URL` | `launch claude`, `launch opencode`, `shell` | API base URL |
315
+ | `GLM_API_KEY` | `launch claude`, `shell` | API key |
316
+ | `GLM_AUTH_TOKEN` | `launch claude`, `shell`, `bench`, `models --remote` | Z.AI auth token (required) |
317
+ | `GLM_MODELS_URL` | `models --remote` | PaaS models endpoint |
318
+ | `API_TIMEOUT_MS` | `launch claude`, `shell` | Request timeout in milliseconds |
319
+ | `ANTHROPIC_DEFAULT_HAIKU_MODEL` | `launch claude`, `shell` | Model for Haiku-tier requests |
320
+ | `ANTHROPIC_DEFAULT_SONNET_MODEL` | `launch claude`, `shell` | Model for Sonnet-tier requests |
321
+ | `ANTHROPIC_DEFAULT_OPUS_MODEL` | `launch claude`, `shell` | Model for Opus-tier requests |
322
+ | `CLAUDE_CODE_SUBAGENT_MODEL` | `launch claude`, `shell` | Model used for spawned subagents |
323
+ | `CLAUDE_CODE_EFFORT_LEVEL` | `launch claude`, `shell` | Effort level for the agent loop |
324
+ | `CLAUDE_CODE_ATTRIBUTION_HEADER` | `launch claude`, `shell` | Attribution header toggle (`0` disables it) |
325
+ | `CLAUDE_CODE_AUTO_COMPACT_WINDOW` | `launch claude`, `shell` | Auto-compact context window in tokens |
326
+
327
+ ## How it works
328
+
329
+ Each provider follows the same pattern:
330
+
331
+ 1. Resolve the binary on PATH (with optional fallback path)
332
+ 2. Set up configuration (env vars for claude, config files for opencode, flags for codex)
333
+ 3. `os.execvpe()` the binary — fully replacing the glm process with the underlying tool for direct stdio passthrough
334
+
335
+ For Claude specifically, Z.AI exposes an Anthropic-compatible endpoint at `https://api.z.ai/api/anthropic`, so no local proxy is needed. The CLI sets the standard `ANTHROPIC_*` env vars and Claude Code talks directly to Z.AI.
336
+
337
+ ## Development
338
+
339
+ Common tasks are wrapped in a [`justfile`](https://github.com/casey/just). Run `just` with no arguments to list them.
340
+
341
+ | Recipe | Description |
342
+ |--------|-------------|
343
+ | `just bootstrap` | Upgrade `pip`/`uv`, then `uv sync` |
344
+ | `just sync` | `uv sync` the project dependencies |
345
+ | `just lock` | `uv lock` the dependency versions |
346
+ | `just build` | `uv build` the wheel and sdist |
347
+ | `just publish` | `uv publish` to PyPI |
348
+ | `just bump *ARGS` | Bump the CalVer version with `bumpver` (e.g. `just bump`) |
349
+ | `just bump-dry *ARGS` | Preview a version bump without writing changes |
350
+ | `just lint *ARGS` | Run the [prek](https://github.com/j178/prek) hooks (defaults to `--all-files`) |
351
+ | `just fmt` | Format the `justfile` itself |
352
+ | `just demo` | Smoke-test the CLI by listing models |
353
+
354
+ Versioning follows [CalVer](https://calver.org/) (`YYYY.MM.INC1`), and lint hooks (ruff, pyupgrade, validate-pyproject) are configured in `.pre-commit-config.yaml` and run with `prek`.
@@ -0,0 +1,5 @@
1
+ main.py,sha256=by1EUAZOJiJTaoIw7_b9ARYmUNmnu4U__emkqx_fym8,21489
2
+ glm_launch-2026.6.1.dist-info/METADATA,sha256=QYF_fOa4nqx2qpmDy50RsQ3Ff8EBNt2pQZT_ZbQmBrY,13600
3
+ glm_launch-2026.6.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
4
+ glm_launch-2026.6.1.dist-info/entry_points.txt,sha256=wRXUbEpSSprL99WcU1Us89QOK7L21OjmrQBED3i3mag,40
5
+ glm_launch-2026.6.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ glm-launch = main:cli
main.py ADDED
@@ -0,0 +1,668 @@
1
+ # /// script
2
+ # requires-python = ">=3.13"
3
+ # dependencies = [
4
+ # "typer",
5
+ # ]
6
+ # ///
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import shutil
13
+
14
+ import typer
15
+
16
+ app = typer.Typer(invoke_without_command=True)
17
+ launch_app = typer.Typer(
18
+ help="Launch an LLM coding tool with GLM settings.",
19
+ invoke_without_command=True,
20
+ )
21
+ app.add_typer(launch_app, name="launch")
22
+
23
+
24
+ @app.callback(invoke_without_command=True)
25
+ def main(ctx: typer.Context) -> None:
26
+ if ctx.invoked_subcommand is None:
27
+ print(ctx.get_help())
28
+ raise typer.Exit()
29
+
30
+
31
+ @launch_app.callback(invoke_without_command=True)
32
+ def launch_main(ctx: typer.Context) -> None:
33
+ if ctx.invoked_subcommand is None:
34
+ print(ctx.get_help())
35
+ raise typer.Exit()
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Z.ai model registry
40
+ # ---------------------------------------------------------------------------
41
+
42
+ # Current Z.ai GLM models (API IDs are lowercase). Kept here so `models` and
43
+ # the help text stay in one place. See https://z.ai/model-api
44
+ ZAI_MODELS: list[tuple[str, str]] = [
45
+ ("glm-5.2", "Flagship — frontier reasoning, coding, and agentic tasks"),
46
+ ("glm-5.1", "Long-horizon agentic flagship (200K context)"),
47
+ ("glm-5", "GLM-5 flagship"),
48
+ ("glm-5-turbo", "Speed-optimized GLM-5 variant"),
49
+ ("glm-4.7", "Balanced cost/performance coding model"),
50
+ ("glm-4.6", "Strong coding model, 200K context"),
51
+ ("glm-4.5", "Previous-gen general model"),
52
+ ("glm-4.5-air", "Lightweight, low-cost (good for subagents/haiku tier)"),
53
+ ]
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Binary resolution helpers
58
+ # ---------------------------------------------------------------------------
59
+
60
+
61
+ def _find_binary(name: str, fallback_path: str | None = None) -> str:
62
+ """Locate *name* on PATH, optionally falling back to *fallback_path*."""
63
+ found = shutil.which(name)
64
+ if found:
65
+ return found
66
+ if fallback_path:
67
+ expanded = os.path.expanduser(fallback_path)
68
+ if os.path.isfile(expanded) and os.access(expanded, os.X_OK):
69
+ return expanded
70
+ install_hint = "Install it or ensure it is on your PATH."
71
+ raise SystemExit(f"{name!r} not found. {install_hint}")
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Claude / GLM environment
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ def _build_claude_env(
80
+ *,
81
+ model: str,
82
+ base_url: str,
83
+ api_key: str,
84
+ auth_token: str,
85
+ api_timeout_ms: str,
86
+ default_haiku_model: str,
87
+ default_sonnet_model: str,
88
+ default_opus_model: str,
89
+ subagent_model: str,
90
+ effort_level: str,
91
+ attribution_header: str = "0",
92
+ auto_compact_window: str = "",
93
+ ) -> dict[str, str]:
94
+ """Build the GLM env vars claude needs to talk to Z.ai."""
95
+ env = {
96
+ "ANTHROPIC_BASE_URL": base_url,
97
+ "ANTHROPIC_API_KEY": api_key,
98
+ "ANTHROPIC_AUTH_TOKEN": auth_token,
99
+ "API_TIMEOUT_MS": api_timeout_ms,
100
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL": default_haiku_model,
101
+ "ANTHROPIC_DEFAULT_SONNET_MODEL": default_sonnet_model,
102
+ "ANTHROPIC_DEFAULT_OPUS_MODEL": default_opus_model,
103
+ "CLAUDE_CODE_SUBAGENT_MODEL": subagent_model,
104
+ "CLAUDE_CODE_EFFORT_LEVEL": effort_level,
105
+ "CLAUDE_CODE_ATTRIBUTION_HEADER": attribution_header,
106
+ }
107
+ if model:
108
+ env["ANTHROPIC_MODEL"] = model
109
+ if auto_compact_window:
110
+ env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = auto_compact_window
111
+ return env
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # launch claude
116
+ # ---------------------------------------------------------------------------
117
+
118
+
119
+ @launch_app.command(
120
+ "claude",
121
+ context_settings={"allow_extra_args": True, "allow_interspersed_args": False},
122
+ )
123
+ def launch_claude(
124
+ ctx: typer.Context,
125
+ model: str = typer.Option(
126
+ "glm-5.2", "--model", "-m", help="Model name to pass to claude"
127
+ ),
128
+ base_url: str = typer.Option(
129
+ "https://api.z.ai/api/anthropic",
130
+ "--base-url",
131
+ envvar="GLM_BASE_URL",
132
+ help="Base URL for the API endpoint",
133
+ ),
134
+ api_key: str = typer.Option(
135
+ "",
136
+ "--api-key",
137
+ envvar="GLM_API_KEY",
138
+ help="API key",
139
+ ),
140
+ auth_token: str = typer.Option(
141
+ ...,
142
+ "--auth-token",
143
+ envvar="GLM_AUTH_TOKEN",
144
+ help="Auth token",
145
+ ),
146
+ api_timeout_ms: str = typer.Option(
147
+ "3000000",
148
+ "--api-timeout-ms",
149
+ envvar="API_TIMEOUT_MS",
150
+ help="API request timeout in milliseconds",
151
+ ),
152
+ default_haiku_model: str = typer.Option(
153
+ "glm-4.5-air",
154
+ "--default-haiku-model",
155
+ envvar="ANTHROPIC_DEFAULT_HAIKU_MODEL",
156
+ help="Default model for Haiku-tier requests",
157
+ ),
158
+ default_sonnet_model: str = typer.Option(
159
+ "glm-5.2",
160
+ "--default-sonnet-model",
161
+ envvar="ANTHROPIC_DEFAULT_SONNET_MODEL",
162
+ help="Default model for Sonnet-tier requests",
163
+ ),
164
+ default_opus_model: str = typer.Option(
165
+ "glm-5.2",
166
+ "--default-opus-model",
167
+ envvar="ANTHROPIC_DEFAULT_OPUS_MODEL",
168
+ help="Default model for Opus-tier requests",
169
+ ),
170
+ subagent_model: str = typer.Option(
171
+ "glm-4.5-air",
172
+ "--subagent-model",
173
+ envvar="CLAUDE_CODE_SUBAGENT_MODEL",
174
+ help="Model used for spawned subagents",
175
+ ),
176
+ effort_level: str = typer.Option(
177
+ "max",
178
+ "--effort-level",
179
+ envvar="CLAUDE_CODE_EFFORT_LEVEL",
180
+ help="Effort level (e.g. max)",
181
+ ),
182
+ attribution_header: str = typer.Option(
183
+ "0",
184
+ "--attribution-header",
185
+ envvar="CLAUDE_CODE_ATTRIBUTION_HEADER",
186
+ help="Attribution header toggle (0 disables it)",
187
+ ),
188
+ auto_compact_window: str = typer.Option(
189
+ "200000",
190
+ "--auto-compact-window",
191
+ envvar="CLAUDE_CODE_AUTO_COMPACT_WINDOW",
192
+ help="Auto-compact context window (token count); empty to leave unset",
193
+ ),
194
+ ) -> None:
195
+ """Launch claude with GLM environment settings."""
196
+ binary = _find_binary("claude", "~/.claude/local/claude")
197
+
198
+ env = os.environ.copy()
199
+ env.update(
200
+ _build_claude_env(
201
+ model=model,
202
+ base_url=base_url,
203
+ api_key=api_key,
204
+ auth_token=auth_token,
205
+ api_timeout_ms=api_timeout_ms,
206
+ default_haiku_model=default_haiku_model,
207
+ default_sonnet_model=default_sonnet_model,
208
+ default_opus_model=default_opus_model,
209
+ subagent_model=subagent_model,
210
+ effort_level=effort_level,
211
+ attribution_header=attribution_header,
212
+ auto_compact_window=auto_compact_window,
213
+ )
214
+ )
215
+
216
+ cmd_args = [binary]
217
+ if model:
218
+ cmd_args.extend(["--model", model])
219
+ cmd_args.extend(ctx.args)
220
+
221
+ os.execvpe(binary, cmd_args, env)
222
+
223
+
224
+ # ---------------------------------------------------------------------------
225
+ # launch codex
226
+ # ---------------------------------------------------------------------------
227
+
228
+
229
+ @launch_app.command(
230
+ "codex",
231
+ context_settings={"allow_extra_args": True, "allow_interspersed_args": False},
232
+ )
233
+ def launch_codex(
234
+ ctx: typer.Context,
235
+ model: str = typer.Option(
236
+ None, "--model", "-m", help="Model name to pass to codex"
237
+ ),
238
+ ) -> None:
239
+ """Launch codex with --oss flag for local Ollama usage."""
240
+ binary = _find_binary("codex")
241
+
242
+ cmd_args = [binary, "--oss"]
243
+ if model:
244
+ cmd_args.extend(["-m", model])
245
+ cmd_args.extend(ctx.args)
246
+
247
+ os.execvpe(binary, cmd_args, os.environ)
248
+
249
+
250
+ # ---------------------------------------------------------------------------
251
+ # launch opencode
252
+ # ---------------------------------------------------------------------------
253
+
254
+
255
+ def _write_opencode_config(model: str | None, base_url: str) -> None:
256
+ """Write (or update) ~/.config/opencode/opencode.json and state file."""
257
+ config_dir = os.path.expanduser("~/.config/opencode")
258
+ config_path = os.path.join(config_dir, "opencode.json")
259
+
260
+ # Load existing config or start fresh
261
+ config: dict = {}
262
+ if os.path.isfile(config_path):
263
+ try:
264
+ with open(config_path) as f:
265
+ config = json.load(f)
266
+ except (json.JSONDecodeError, OSError):
267
+ pass
268
+
269
+ config.setdefault("$schema", "https://opencode.ai/config.json")
270
+ providers = config.setdefault("provider", {})
271
+ ollama = providers.setdefault("ollama", {})
272
+ ollama["npm"] = "@ai-sdk/openai-compatible"
273
+ ollama["name"] = "Ollama (local)"
274
+ ollama.setdefault("options", {})["baseURL"] = base_url
275
+ models = ollama.setdefault("models", {})
276
+
277
+ if model:
278
+ models[model] = {"name": model, "_launch": True}
279
+
280
+ os.makedirs(config_dir, mode=0o755, exist_ok=True)
281
+ with open(config_path, "w") as f:
282
+ json.dump(config, f, indent=2)
283
+ f.write("\n")
284
+
285
+ # Update state/model.json with recent model
286
+ if model:
287
+ state_dir = os.path.expanduser("~/.local/state/opencode")
288
+ state_path = os.path.join(state_dir, "model.json")
289
+
290
+ state: dict = {}
291
+ if os.path.isfile(state_path):
292
+ try:
293
+ with open(state_path) as f:
294
+ state = json.load(f)
295
+ except (json.JSONDecodeError, OSError):
296
+ pass
297
+
298
+ recent: list = state.setdefault("recent", [])
299
+ state.setdefault("favorite", [])
300
+ state.setdefault("variant", {})
301
+
302
+ entry = {"providerID": "ollama", "modelID": model}
303
+ recent = [r for r in recent if r.get("modelID") != model]
304
+ recent.insert(0, entry)
305
+ state["recent"] = recent[:10]
306
+
307
+ os.makedirs(state_dir, mode=0o755, exist_ok=True)
308
+ with open(state_path, "w") as f:
309
+ json.dump(state, f, indent=2)
310
+ f.write("\n")
311
+
312
+
313
+ @launch_app.command(
314
+ "opencode",
315
+ context_settings={"allow_extra_args": True, "allow_interspersed_args": False},
316
+ )
317
+ def launch_opencode(
318
+ ctx: typer.Context,
319
+ model: str = typer.Option(None, "--model", "-m", help="Model name for opencode"),
320
+ base_url: str = typer.Option(
321
+ ...,
322
+ "--base-url",
323
+ envvar="GLM_BASE_URL",
324
+ help="Base URL for the API endpoint",
325
+ ),
326
+ ) -> None:
327
+ """Launch opencode after writing provider config."""
328
+ binary = _find_binary("opencode")
329
+
330
+ _write_opencode_config(model, base_url)
331
+
332
+ cmd_args = [binary]
333
+ cmd_args.extend(ctx.args)
334
+
335
+ os.execvpe(binary, cmd_args, os.environ)
336
+
337
+
338
+ # ---------------------------------------------------------------------------
339
+ # shell
340
+ # ---------------------------------------------------------------------------
341
+
342
+
343
+ def _shell_quote(value: str) -> str:
344
+ """Single-quote a value safely for POSIX shell eval."""
345
+ return "'" + value.replace("'", "'\"'\"'") + "'"
346
+
347
+
348
+ @app.command()
349
+ def shell(
350
+ model: str = typer.Option(
351
+ "glm-5.2", "--model", "-m", help="Top-level model (ANTHROPIC_MODEL)"
352
+ ),
353
+ base_url: str = typer.Option(
354
+ "https://api.z.ai/api/anthropic",
355
+ "--base-url",
356
+ envvar="GLM_BASE_URL",
357
+ help="Base URL for the API endpoint",
358
+ ),
359
+ api_key: str = typer.Option("", "--api-key", envvar="GLM_API_KEY", help="API key"),
360
+ auth_token: str = typer.Option(
361
+ ..., "--auth-token", envvar="GLM_AUTH_TOKEN", help="Auth token"
362
+ ),
363
+ api_timeout_ms: str = typer.Option(
364
+ "3000000", "--api-timeout-ms", envvar="API_TIMEOUT_MS"
365
+ ),
366
+ default_haiku_model: str = typer.Option(
367
+ "glm-4.5-air", "--default-haiku-model", envvar="ANTHROPIC_DEFAULT_HAIKU_MODEL"
368
+ ),
369
+ default_sonnet_model: str = typer.Option(
370
+ "glm-5.2", "--default-sonnet-model", envvar="ANTHROPIC_DEFAULT_SONNET_MODEL"
371
+ ),
372
+ default_opus_model: str = typer.Option(
373
+ "glm-5.2", "--default-opus-model", envvar="ANTHROPIC_DEFAULT_OPUS_MODEL"
374
+ ),
375
+ subagent_model: str = typer.Option(
376
+ "glm-4.5-air", "--subagent-model", envvar="CLAUDE_CODE_SUBAGENT_MODEL"
377
+ ),
378
+ effort_level: str = typer.Option(
379
+ "max", "--effort-level", envvar="CLAUDE_CODE_EFFORT_LEVEL"
380
+ ),
381
+ attribution_header: str = typer.Option(
382
+ "0", "--attribution-header", envvar="CLAUDE_CODE_ATTRIBUTION_HEADER"
383
+ ),
384
+ auto_compact_window: str = typer.Option(
385
+ "200000", "--auto-compact-window", envvar="CLAUDE_CODE_AUTO_COMPACT_WINDOW"
386
+ ),
387
+ ) -> None:
388
+ """Print `export` lines to bootstrap the current shell for Z.ai.
389
+
390
+ Eval the output to configure your shell so a plain `claude` uses Z.ai:
391
+
392
+ eval "$(uv run src/main.py shell)"
393
+ """
394
+ env = _build_claude_env(
395
+ model=model,
396
+ base_url=base_url,
397
+ api_key=api_key,
398
+ auth_token=auth_token,
399
+ api_timeout_ms=api_timeout_ms,
400
+ default_haiku_model=default_haiku_model,
401
+ default_sonnet_model=default_sonnet_model,
402
+ default_opus_model=default_opus_model,
403
+ subagent_model=subagent_model,
404
+ effort_level=effort_level,
405
+ attribution_header=attribution_header,
406
+ auto_compact_window=auto_compact_window,
407
+ )
408
+ for key, value in env.items():
409
+ if value:
410
+ print(f"export {key}={_shell_quote(value)}")
411
+
412
+
413
+ # ---------------------------------------------------------------------------
414
+ # models
415
+ # ---------------------------------------------------------------------------
416
+
417
+
418
+ def _fetch_remote_models(models_url: str, auth_token: str, timeout: float) -> list[str]:
419
+ """Fetch the live model ID list from the Z.ai PaaS /models endpoint."""
420
+ import json
421
+ import urllib.error
422
+ import urllib.request
423
+
424
+ req = urllib.request.Request(
425
+ models_url,
426
+ headers={"Authorization": f"Bearer {auth_token}"},
427
+ method="GET",
428
+ )
429
+ try:
430
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
431
+ payload = json.load(resp)
432
+ except urllib.error.HTTPError as e:
433
+ body = e.read().decode("utf-8", errors="replace")
434
+ msg = f"Failed to fetch models ({e.code})"
435
+ if body:
436
+ msg += f": {body[:200]}"
437
+ raise SystemExit(msg)
438
+ except urllib.error.URLError as e:
439
+ raise SystemExit(f"Failed to fetch models: {e.reason}")
440
+
441
+ data = payload.get("data", payload) if isinstance(payload, dict) else payload
442
+ ids = [m.get("id") for m in data if isinstance(m, dict) and m.get("id")]
443
+ return sorted(ids)
444
+
445
+
446
+ @app.command()
447
+ def models(
448
+ remote: bool = typer.Option(
449
+ False, "--remote", "-r", help="Fetch the live list from the Z.ai API"
450
+ ),
451
+ models_url: str = typer.Option(
452
+ "https://api.z.ai/api/paas/v4/models",
453
+ "--models-url",
454
+ envvar="GLM_MODELS_URL",
455
+ help="PaaS models endpoint (used with --remote)",
456
+ ),
457
+ auth_token: str = typer.Option(
458
+ "",
459
+ "--auth-token",
460
+ envvar="GLM_AUTH_TOKEN",
461
+ help="Auth token (required with --remote)",
462
+ ),
463
+ timeout: float = typer.Option(30.0, "--timeout", help="Request timeout in seconds"),
464
+ ) -> None:
465
+ """List Z.ai GLM models (built-in list, or --remote for the live API list)."""
466
+ if remote:
467
+ if not auth_token:
468
+ raise SystemExit(
469
+ "--remote requires an auth token (--auth-token or GLM_AUTH_TOKEN)."
470
+ )
471
+ known = dict(ZAI_MODELS)
472
+ ids = _fetch_remote_models(models_url, auth_token, timeout)
473
+ if not ids:
474
+ print(f"No models returned from {models_url}")
475
+ return
476
+ print(f"Z.ai models (live from {models_url}):")
477
+ width = max(len(model_id) for model_id in ids)
478
+ for model_id in ids:
479
+ desc = known.get(model_id, "")
480
+ print(f" {model_id.ljust(width)} {desc}".rstrip())
481
+ return
482
+
483
+ print("Z.ai GLM models (use the ID in --model):")
484
+ width = max(len(model_id) for model_id, _ in ZAI_MODELS)
485
+ for model_id, desc in ZAI_MODELS:
486
+ print(f" {model_id.ljust(width)} {desc}")
487
+
488
+
489
+ # ---------------------------------------------------------------------------
490
+ # bench
491
+ # ---------------------------------------------------------------------------
492
+
493
+
494
+ @app.command()
495
+ def bench(
496
+ model: str = typer.Option("glm-5.2", "--model", "-m", help="Model to benchmark"),
497
+ base_url: str = typer.Option(
498
+ "https://api.z.ai/api/anthropic",
499
+ "--base-url",
500
+ envvar="GLM_BASE_URL",
501
+ help="Base URL for the API endpoint",
502
+ ),
503
+ auth_token: str = typer.Option(
504
+ ...,
505
+ "--auth-token",
506
+ envvar="GLM_AUTH_TOKEN",
507
+ help="Auth token for the endpoint",
508
+ ),
509
+ timeout: float = typer.Option(30.0, "--timeout", help="Request timeout in seconds"),
510
+ ) -> None:
511
+ """Time a single /v1/messages round-trip against the configured endpoint."""
512
+ import json
513
+ import time
514
+ import urllib.error
515
+ import urllib.request
516
+
517
+ url = f"{base_url.rstrip('/')}/v1/messages"
518
+ payload = json.dumps(
519
+ {
520
+ "model": model,
521
+ "max_tokens": 32,
522
+ "messages": [{"role": "user", "content": "Reply: ok"}],
523
+ }
524
+ ).encode()
525
+
526
+ req = urllib.request.Request(
527
+ url,
528
+ data=payload,
529
+ headers={
530
+ "x-api-key": auth_token,
531
+ "content-type": "application/json",
532
+ "anthropic-version": "2023-06-01",
533
+ },
534
+ method="POST",
535
+ )
536
+
537
+ print(f" {model} via {base_url}")
538
+ start = time.monotonic()
539
+ try:
540
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
541
+ elapsed_ms = int((time.monotonic() - start) * 1000)
542
+ print(f" OK ({resp.status}) in {elapsed_ms}ms")
543
+ except urllib.error.HTTPError as e:
544
+ elapsed_ms = int((time.monotonic() - start) * 1000)
545
+ body = e.read().decode("utf-8", errors="replace")
546
+ print(f" FAIL ({e.code}) in {elapsed_ms}ms")
547
+ if body:
548
+ print(f" {body[:200]}")
549
+ raise typer.Exit(code=1)
550
+ except urllib.error.URLError as e:
551
+ elapsed_ms = int((time.monotonic() - start) * 1000)
552
+ print(f" FAIL ({e.reason}) in {elapsed_ms}ms")
553
+ raise typer.Exit(code=1)
554
+
555
+
556
+ # ---------------------------------------------------------------------------
557
+ # doctor
558
+ # ---------------------------------------------------------------------------
559
+
560
+ _CLAUDE_ENV_VARS = [
561
+ "GLM_BASE_URL",
562
+ "GLM_API_KEY",
563
+ "GLM_AUTH_TOKEN",
564
+ "API_TIMEOUT_MS",
565
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL",
566
+ "ANTHROPIC_DEFAULT_SONNET_MODEL",
567
+ "ANTHROPIC_DEFAULT_OPUS_MODEL",
568
+ "CLAUDE_CODE_SUBAGENT_MODEL",
569
+ "CLAUDE_CODE_EFFORT_LEVEL",
570
+ ]
571
+
572
+ _BINARIES = [
573
+ ("claude", "~/.claude/local/claude"),
574
+ ("codex", None),
575
+ ("opencode", None),
576
+ ]
577
+
578
+ _SECRET_VARS = {"GLM_API_KEY", "GLM_AUTH_TOKEN"}
579
+
580
+
581
+ def _mask(value: str) -> str:
582
+ """Show first 4 and last 4 chars, mask the rest."""
583
+ if len(value) <= 10:
584
+ return value[:2] + "***"
585
+ return value[:4] + "***" + value[-4:]
586
+
587
+
588
+ @app.command()
589
+ def doctor() -> None:
590
+ """Check environment variables and binary availability."""
591
+ ok = True
592
+
593
+ print("Environment variables:")
594
+ for var in _CLAUDE_ENV_VARS:
595
+ value = os.environ.get(var)
596
+ if value:
597
+ display = _mask(value) if var in _SECRET_VARS else value
598
+ print(f" {var}: {display}")
599
+ else:
600
+ print(f" {var}: (not set)")
601
+
602
+ print()
603
+ print("Binaries:")
604
+ for name, fallback in _BINARIES:
605
+ found = shutil.which(name)
606
+ if found:
607
+ print(f" {name}: {found}")
608
+ elif fallback:
609
+ expanded = os.path.expanduser(fallback)
610
+ if os.path.isfile(expanded) and os.access(expanded, os.X_OK):
611
+ print(f" {name}: {expanded} (fallback)")
612
+ else:
613
+ print(f" {name}: NOT FOUND")
614
+ ok = False
615
+ else:
616
+ print(f" {name}: NOT FOUND")
617
+ ok = False
618
+
619
+ print()
620
+ print("Config files:")
621
+ opencode_config = os.path.expanduser("~/.config/opencode/opencode.json")
622
+ if os.path.isfile(opencode_config):
623
+ print(f" {opencode_config}: exists")
624
+ else:
625
+ print(f" {opencode_config}: not found")
626
+
627
+ opencode_state = os.path.expanduser("~/.local/state/opencode/model.json")
628
+ if os.path.isfile(opencode_state):
629
+ print(f" {opencode_state}: exists")
630
+ else:
631
+ print(f" {opencode_state}: not found")
632
+
633
+ print()
634
+ if ok:
635
+ print("All checks passed.")
636
+ else:
637
+ print("Some checks failed. See above for details.")
638
+ raise typer.Exit(code=1)
639
+
640
+
641
+ # ---------------------------------------------------------------------------
642
+ # Top-level provider aliases
643
+ # ---------------------------------------------------------------------------
644
+
645
+ # Expose providers at the top level so `glm-launch claude` works the same as
646
+ # `glm-launch launch claude`. The `launch` group is kept for backwards compat.
647
+ _PROVIDER_CTX = {"allow_extra_args": True, "allow_interspersed_args": False}
648
+ app.command("claude", context_settings=_PROVIDER_CTX)(launch_claude)
649
+ app.command("codex", context_settings=_PROVIDER_CTX)(launch_codex)
650
+ app.command("opencode", context_settings=_PROVIDER_CTX)(launch_opencode)
651
+
652
+
653
+ # ---------------------------------------------------------------------------
654
+ # entry point
655
+ # ---------------------------------------------------------------------------
656
+
657
+
658
+ def cli() -> None:
659
+ """Run the app, defaulting to the `claude` provider when no command is given."""
660
+ import sys
661
+
662
+ if len(sys.argv) == 1:
663
+ sys.argv.append("claude")
664
+ app()
665
+
666
+
667
+ if __name__ == "__main__":
668
+ cli()