xsync-cli 0.1.2__tar.gz → 0.1.4__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 (57) hide show
  1. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/PKG-INFO +44 -2
  2. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/README.md +43 -1
  3. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/docs/superpowers/specs/2026-09-13-xsync-design.md +72 -0
  4. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/pyproject.toml +1 -1
  5. xsync_cli-0.1.4/src/xsync_cli/adapters/claude.py +92 -0
  6. xsync_cli-0.1.4/src/xsync_cli/adapters/claude_config.py +206 -0
  7. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/adapters/codex.py +18 -7
  8. xsync_cli-0.1.4/src/xsync_cli/adapters/rules/claude.toml +51 -0
  9. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/adapters/rules/codex.toml +11 -4
  10. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/cli.py +237 -2
  11. xsync_cli-0.1.4/tests/test_claude_config.py +191 -0
  12. xsync_cli-0.1.4/tests/test_claude_render.py +86 -0
  13. xsync_cli-0.1.4/tests/test_cli_claude.py +131 -0
  14. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/test_cli_profiles.py +24 -4
  15. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/test_codex_render.py +62 -1
  16. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/test_term.py +1 -1
  17. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/uv.lock +1 -1
  18. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/.gitignore +0 -0
  19. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/LICENSE +0 -0
  20. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/docs/superpowers/plans/2026-09-13-xsync-cli.md +0 -0
  21. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/docs/verification-2026-09-13.md +0 -0
  22. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/__init__.py +0 -0
  23. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/adapters/__init__.py +0 -0
  24. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/adapters/codex_config.py +0 -0
  25. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/adapters/codex_wiring.py +0 -0
  26. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/adapters/prompts/__init__.py +0 -0
  27. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/adapters/prompts/generic-codex.md +0 -0
  28. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/adapters/prompts/generic-codex.messages.json +0 -0
  29. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/adapters/prompts/gpt-5.4.md +0 -0
  30. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/adapters/prompts/gpt-5.4.messages.json +0 -0
  31. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/adapters/prompts/gpt-5.5.md +0 -0
  32. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/adapters/prompts/gpt-5.5.messages.json +0 -0
  33. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/adapters/rules/__init__.py +0 -0
  34. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/core/__init__.py +0 -0
  35. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/core/atomic.py +0 -0
  36. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/core/diff.py +0 -0
  37. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/core/filters.py +0 -0
  38. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/core/model.py +0 -0
  39. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/core/profiles.py +0 -0
  40. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/core/term.py +0 -0
  41. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/sources/__init__.py +0 -0
  42. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/src/xsync_cli/sources/openai_compat.py +0 -0
  43. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/__init__.py +0 -0
  44. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/fixtures/__init__.py +0 -0
  45. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/fixtures/ninerouter_models.json +0 -0
  46. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/fixtures/plain_openai_models.json +0 -0
  47. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/test_atomic.py +0 -0
  48. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/test_cli_codex.py +0 -0
  49. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/test_codex_config.py +0 -0
  50. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/test_codex_wiring.py +0 -0
  51. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/test_diff.py +0 -0
  52. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/test_filters.py +0 -0
  53. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/test_model.py +0 -0
  54. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/test_openai_compat.py +0 -0
  55. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/test_package_data.py +0 -0
  56. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tests/test_profiles.py +0 -0
  57. {xsync_cli-0.1.2 → xsync_cli-0.1.4}/tools/extract_prompts.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: xsync-cli
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Sync the model list of an OpenAI-compatible endpoint into the Codex model catalog.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -79,10 +79,52 @@ Run this command again when the endpoint changes.
79
79
  | `xsync codex --reset` | Remove everything that xSync wrote. |
80
80
  | `xsync codex --reset --force` | Reset when no state file exists. It asks first. |
81
81
  | `xsync codex --reset --force --yes` | Reset with no question. Use this in a script. |
82
+ | `xsync claude` | Sync the active profile into the Claude Code model picker. |
83
+ | `xsync claude --init` | Point Claude Code at the endpoint of the profile. |
84
+ | `xsync claude --dry-run` | Show the difference. Write nothing. |
85
+ | `xsync claude --reset` | Remove everything that xSync wrote. |
86
+ | `xsync help` | Show the help. `xsync help codex` explains one command. |
82
87
 
83
88
  Exit codes: `0` for success, `1` for an error, `2` when the endpoint does not
84
89
  answer.
85
90
 
91
+ ## Claude Code
92
+
93
+ xsync claude --init
94
+ xsync claude
95
+
96
+ Claude Code holds no catalog file. It reads the rows of the `/model`
97
+ picker from `modelPicker.options` in `~/.claude/settings.json`, and it reads
98
+ the endpoint from the `env` block of the same file. `xsync claude` writes
99
+ those two keys and nothing else.
100
+
101
+ The endpoint must answer the Anthropic API at `POST /v1/messages`. An
102
+ endpoint that answers only the OpenAI API works with Codex, not with Claude
103
+ Code.
104
+
105
+ ### The map to a known model
106
+
107
+ Claude Code refuses a model that it does not know:
108
+
109
+ "cmc/deepseek/deepseek-v4-pro" isn't described by this version's model
110
+ catalog; update Claude Code, or map it with behavesAs on a modelPicker row
111
+
112
+ Therefore each row carries `behavesAs`: the id of a model that Claude Code
113
+ knows. The prompt profile, the capability defaults, and the effort defaults
114
+ of that model then apply. The model id that Claude Code sends does not
115
+ change.
116
+
117
+ A model name that already holds a known model id, such as
118
+ `ed3n/claude-sonnet-5`, gets no `behavesAs`. It keeps its native handling.
119
+
120
+ Edit `adapters/rules/claude.toml` to change a map.
121
+
122
+ ### The built-in models stay
123
+
124
+ xSync sets `replaceBuiltInOptions` to `false`. The built-in models stay at
125
+ the top of the picker, and the router models come after them. A router that
126
+ stops therefore leaves a working picker.
127
+
86
128
  ## Switch between endpoints
87
129
 
88
130
  Codex holds one model provider. Therefore one profile is active at a time.
@@ -128,7 +170,7 @@ A glob pattern selects a model by name. The sign `*` means any characters.
128
170
  | Rule | Effect |
129
171
  |---|---|
130
172
  | `include = []` | Keep every model. This is the default. |
131
- | `include = ["cx/*", "ed3n/*"]` | Keep only the models of those two providers. |
173
+ | `include = ["openai/*", "anthropic/*"]` | Keep only the models of those two providers. |
132
174
  | `exclude = ["*embedding*"]` | Drop every model with `embedding` in the name. |
133
175
  | `exclude = ["*-image-*", "*-tts-*"]` | Drop the image models and the speech models. |
134
176
 
@@ -67,10 +67,52 @@ Run this command again when the endpoint changes.
67
67
  | `xsync codex --reset` | Remove everything that xSync wrote. |
68
68
  | `xsync codex --reset --force` | Reset when no state file exists. It asks first. |
69
69
  | `xsync codex --reset --force --yes` | Reset with no question. Use this in a script. |
70
+ | `xsync claude` | Sync the active profile into the Claude Code model picker. |
71
+ | `xsync claude --init` | Point Claude Code at the endpoint of the profile. |
72
+ | `xsync claude --dry-run` | Show the difference. Write nothing. |
73
+ | `xsync claude --reset` | Remove everything that xSync wrote. |
74
+ | `xsync help` | Show the help. `xsync help codex` explains one command. |
70
75
 
71
76
  Exit codes: `0` for success, `1` for an error, `2` when the endpoint does not
72
77
  answer.
73
78
 
79
+ ## Claude Code
80
+
81
+ xsync claude --init
82
+ xsync claude
83
+
84
+ Claude Code holds no catalog file. It reads the rows of the `/model`
85
+ picker from `modelPicker.options` in `~/.claude/settings.json`, and it reads
86
+ the endpoint from the `env` block of the same file. `xsync claude` writes
87
+ those two keys and nothing else.
88
+
89
+ The endpoint must answer the Anthropic API at `POST /v1/messages`. An
90
+ endpoint that answers only the OpenAI API works with Codex, not with Claude
91
+ Code.
92
+
93
+ ### The map to a known model
94
+
95
+ Claude Code refuses a model that it does not know:
96
+
97
+ "cmc/deepseek/deepseek-v4-pro" isn't described by this version's model
98
+ catalog; update Claude Code, or map it with behavesAs on a modelPicker row
99
+
100
+ Therefore each row carries `behavesAs`: the id of a model that Claude Code
101
+ knows. The prompt profile, the capability defaults, and the effort defaults
102
+ of that model then apply. The model id that Claude Code sends does not
103
+ change.
104
+
105
+ A model name that already holds a known model id, such as
106
+ `ed3n/claude-sonnet-5`, gets no `behavesAs`. It keeps its native handling.
107
+
108
+ Edit `adapters/rules/claude.toml` to change a map.
109
+
110
+ ### The built-in models stay
111
+
112
+ xSync sets `replaceBuiltInOptions` to `false`. The built-in models stay at
113
+ the top of the picker, and the router models come after them. A router that
114
+ stops therefore leaves a working picker.
115
+
74
116
  ## Switch between endpoints
75
117
 
76
118
  Codex holds one model provider. Therefore one profile is active at a time.
@@ -116,7 +158,7 @@ A glob pattern selects a model by name. The sign `*` means any characters.
116
158
  | Rule | Effect |
117
159
  |---|---|
118
160
  | `include = []` | Keep every model. This is the default. |
119
- | `include = ["cx/*", "ed3n/*"]` | Keep only the models of those two providers. |
161
+ | `include = ["openai/*", "anthropic/*"]` | Keep only the models of those two providers. |
120
162
  | `exclude = ["*embedding*"]` | Drop every model with `embedding` in the name. |
121
163
  | `exclude = ["*-image-*", "*-tts-*"]` | Drop the image models and the speech models. |
122
164
 
@@ -425,3 +425,75 @@ works against a second endpoint.
425
425
  The upload needs a PyPI API token. The token belongs to the user. The token
426
426
  never enters the repository. The user supplies it through the environment
427
427
  variable `UV_PUBLISH_TOKEN` or through `~/.pypirc`.
428
+
429
+ ## 18. The Claude Code adapter
430
+
431
+ Date added: 2026-09-13
432
+
433
+ ### 18.1 What Claude Code offers
434
+
435
+ Claude Code holds no catalog file like Codex. It reads two things from
436
+ `~/.claude/settings.json`:
437
+
438
+ | Key | Purpose |
439
+ |---|---|
440
+ | `modelPicker.options` | The rows of the `/model` picker, in order. |
441
+ | `modelPicker.replaceBuiltInOptions` | True hides the built-in models. |
442
+ | `env` | The variables that point Claude Code at an endpoint. |
443
+
444
+ One picker row holds these fields:
445
+
446
+ | Field | Meaning |
447
+ |---|---|
448
+ | `model` | The model id, taken word for word. |
449
+ | `label` | The row title. |
450
+ | `description` | The row subtitle. |
451
+ | `behavesAs` | The id of a model that this version knows. Its prompt profile, its capability defaults, and its effort defaults then apply. |
452
+
453
+ Claude Code refuses a model that it does not know:
454
+
455
+ ```
456
+ "cmc/deepseek/deepseek-v4-pro" isn't described by this version's model
457
+ catalog; update Claude Code, or map it with behavesAs on a modelPicker row
458
+ ```
459
+
460
+ A model name that holds a known model id, such as
461
+ `ed3n/claude-sonnet-5`, needs no `behavesAs`.
462
+
463
+ ### 18.2 The proof
464
+
465
+ Each fact below comes from a test on this machine, not from a document.
466
+
467
+ 1. 9router answers the Anthropic API at `POST /v1/messages`.
468
+ 2. `claude -p "say ok" --model ed3n/claude-sonnet-5` answered `ok`.
469
+ 3. The same command with `cmc/deepseek/deepseek-v4-pro` failed, until a
470
+ picker row gave it `behavesAs = "claude-opus-4-8"`. It then answered
471
+ `ok`.
472
+ 4. The variables in the `env` block of `settings.json` are enough. The
473
+ shell needs no variable.
474
+
475
+ ### 18.3 The design
476
+
477
+ - `xsync claude` writes `modelPicker.options` into `settings.json`.
478
+ - `replaceBuiltInOptions` stays `false`. The built-in models stay in the
479
+ picker. The router models come after them. A router that stops
480
+ therefore leaves a working picker.
481
+ - A rules file `adapters/rules/claude.toml` gives `behavesAs`. It uses
482
+ the same layers as the Codex rules: defaults, prefix, name, slug.
483
+ - A model whose name holds a known Claude model id gets no `behavesAs`.
484
+ It keeps its native handling.
485
+ - `xsync claude --init` writes the `env` block with
486
+ `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN`.
487
+ - `xsync claude --reset` removes only what the state file records.
488
+
489
+ ### 18.4 Safety
490
+
491
+ The file `settings.json` holds the hooks and the settings of the user. It
492
+ is large. Therefore:
493
+
494
+ - xSync changes the key `modelPicker` only, and the key `env` only with
495
+ `--init`.
496
+ - The write is atomic. xSync keeps one backup.
497
+ - xSync hashes the file before the edit and again before the replace. On
498
+ a difference it stops.
499
+ - The state file `~/.claude/.xsync-state.json` records every change.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "xsync-cli"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  description = "Sync the model list of an OpenAI-compatible endpoint into the Codex model catalog."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,92 @@
1
+ """The Claude Code adapter.
2
+
3
+ Claude Code holds no catalog file. It reads the rows of the `/model`
4
+ picker from `modelPicker.options` in `settings.json`. This module makes
5
+ those rows. It knows nothing about HTTP.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import tomllib
11
+ from collections.abc import Sequence
12
+ from dataclasses import dataclass
13
+ from importlib.resources import files
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from xsync_cli.core.model import Model
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class Rules:
22
+ """The four rule layers, and the list of native model ids."""
23
+
24
+ defaults: dict[str, Any]
25
+ prefixes: dict[str, dict[str, Any]]
26
+ names: dict[str, dict[str, Any]]
27
+ slugs: dict[str, dict[str, Any]]
28
+
29
+ def for_model(self, model: Model) -> dict[str, Any]:
30
+ """The merged rule values for one model."""
31
+ merged: dict[str, Any] = dict(self.defaults)
32
+ merged.update(self.prefixes.get(model.prefix.lower(), {}))
33
+ merged.update(self.names.get(model.leaf_name.lower(), {}))
34
+ merged.update(self.slugs.get(model.slug, {}))
35
+ return merged
36
+
37
+ @property
38
+ def native_ids(self) -> list[str]:
39
+ """The model ids that Claude Code knows."""
40
+ return [str(item).lower() for item in self.defaults.get("native_ids", [])]
41
+
42
+
43
+ def load_rules(path: Path | None = None) -> Rules:
44
+ """Read the rules file. The default file is package data."""
45
+ if path is None:
46
+ text = files("xsync_cli.adapters.rules").joinpath("claude.toml").read_text(
47
+ encoding="utf-8"
48
+ )
49
+ else:
50
+ text = path.read_text(encoding="utf-8")
51
+ data = tomllib.loads(text)
52
+ return Rules(
53
+ defaults=dict(data.get("defaults", {})),
54
+ prefixes={k.lower(): dict(v) for k, v in (data.get("prefix") or {}).items()},
55
+ names={k.lower(): dict(v) for k, v in (data.get("name") or {}).items()},
56
+ slugs={k: dict(v) for k, v in (data.get("slug") or {}).items()},
57
+ )
58
+
59
+
60
+ def is_native(slug: str, rules: Rules) -> bool:
61
+ """True when Claude Code already knows how to drive this model.
62
+
63
+ Claude Code matches a known model id inside the name of the model.
64
+ Therefore `ed3n/claude-sonnet-5` needs no map.
65
+ """
66
+ lowered = slug.lower()
67
+ return any(known in lowered for known in rules.native_ids)
68
+
69
+
70
+ def _label(model: Model) -> str:
71
+ words = model.leaf_name.replace("_", "-").split("-")
72
+ return " ".join(word[:1].upper() + word[1:] for word in words if word)
73
+
74
+
75
+ def render_rows(models: Sequence[Model], rules: Rules) -> list[dict[str, Any]]:
76
+ """One picker row for each model.
77
+
78
+ A row holds `behavesAs` only when Claude Code does not know the
79
+ model. Claude Code refuses such a model without that field.
80
+ """
81
+ rows: list[dict[str, Any]] = []
82
+ for model in models:
83
+ values = rules.for_model(model)
84
+ row: dict[str, Any] = {
85
+ "model": model.slug,
86
+ "label": values.get("label") or _label(model),
87
+ "description": str(values["description_template"]).format(slug=model.slug),
88
+ }
89
+ if not is_native(model.slug, rules):
90
+ row["behavesAs"] = values["behaves_as"]
91
+ rows.append(row)
92
+ return rows
@@ -0,0 +1,206 @@
1
+ """The reader and the writer of the Claude Code settings.
2
+
3
+ Claude Code keeps the rows of the `/model` picker in
4
+ `modelPicker.options`. It keeps the endpoint in the `env` block. This
5
+ module changes those two keys and nothing else.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import datetime as dt
11
+ import hashlib
12
+ import json
13
+ import os
14
+ from dataclasses import asdict, dataclass, field
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from xsync_cli.core.profiles import Profile
19
+
20
+ STATE_FILENAME = ".xsync-state.json"
21
+ STATE_VERSION = 1
22
+ BASE_URL_KEY = "ANTHROPIC_BASE_URL"
23
+ TOKEN_KEY = "ANTHROPIC_AUTH_TOKEN"
24
+ PICKER_KEY = "modelPicker"
25
+
26
+
27
+ class ClaudeConfigError(Exception):
28
+ """The settings file cannot be read or written."""
29
+
30
+
31
+ def default_claude_home() -> Path:
32
+ """The Claude Code home directory."""
33
+ return Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude")
34
+
35
+
36
+ def settings_path_for(claude_home: Path) -> Path:
37
+ """The settings file of that home."""
38
+ return claude_home / "settings.json"
39
+
40
+
41
+ def file_sha256(path: Path) -> str:
42
+ """The hash of a file. An absent file gives an empty string."""
43
+ if not path.exists():
44
+ return ""
45
+ return hashlib.sha256(path.read_bytes()).hexdigest()
46
+
47
+
48
+ def read_settings(path: Path) -> dict[str, Any]:
49
+ """Read the settings. An absent file gives an empty mapping."""
50
+ if not path.exists():
51
+ return {}
52
+ try:
53
+ data = json.loads(path.read_text(encoding="utf-8"))
54
+ except json.JSONDecodeError as error:
55
+ raise ClaudeConfigError(f"{path} is not valid JSON: {error}") from None
56
+ if not isinstance(data, dict):
57
+ raise ClaudeConfigError(f"{path} does not hold a JSON object.")
58
+ return data
59
+
60
+
61
+ def base_url_of(settings: dict[str, Any]) -> str | None:
62
+ """The endpoint that Claude Code uses."""
63
+ value = (settings.get("env") or {}).get(BASE_URL_KEY)
64
+ return str(value).rstrip("/") if value else None
65
+
66
+
67
+ def check_endpoint_match(settings: dict[str, Any], profile: Profile) -> str | None:
68
+ """Confirm that Claude Code talks to the endpoint of the profile."""
69
+ current = base_url_of(settings)
70
+ if current is None:
71
+ return (
72
+ "Claude Code has no endpoint.\n"
73
+ f"run: xsync claude --profile {profile.name} --init"
74
+ )
75
+ if current != profile.base_url:
76
+ return (
77
+ f'the rows would come from "{profile.name}" ({profile.base_url})\n'
78
+ f"but Claude Code talks to {current}\n"
79
+ f"run: xsync claude --profile {profile.name} --init"
80
+ )
81
+ return None
82
+
83
+
84
+ @dataclass(frozen=True, slots=True)
85
+ class State:
86
+ """The record of what xSync wrote into the settings."""
87
+
88
+ profile: str
89
+ keys_written: list[str] = field(default_factory=list)
90
+ settings_sha256: str = ""
91
+ written_at: str = ""
92
+
93
+
94
+ def _now() -> str:
95
+ return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
96
+
97
+
98
+ def _write_atomic(path: Path, data: dict[str, Any], before: str) -> None:
99
+ """Write the settings only when the file did not change."""
100
+ if file_sha256(path) != before:
101
+ raise ClaudeConfigError(
102
+ f"{path} changed during the edit. Close Claude Code and try again."
103
+ )
104
+ text = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
105
+ json.loads(text)
106
+ temporary = path.with_name(path.name + ".tmp")
107
+ temporary.write_text(text, encoding="utf-8")
108
+ if path.exists():
109
+ path.with_name(path.name + ".bak").write_bytes(path.read_bytes())
110
+ os.replace(temporary, path)
111
+
112
+
113
+ def init_settings(path: Path, profile: Profile, api_key: str | None) -> State:
114
+ """Point Claude Code at the endpoint of the profile."""
115
+ before = file_sha256(path)
116
+ settings = read_settings(path)
117
+
118
+ environment = dict(settings.get("env") or {})
119
+ environment[BASE_URL_KEY] = profile.base_url
120
+ keys = [f"env.{BASE_URL_KEY}"]
121
+ if api_key:
122
+ environment[TOKEN_KEY] = api_key
123
+ keys.append(f"env.{TOKEN_KEY}")
124
+ settings["env"] = environment
125
+
126
+ _write_atomic(path, settings, before)
127
+ return State(
128
+ profile=profile.name,
129
+ keys_written=keys,
130
+ settings_sha256=file_sha256(path),
131
+ written_at=_now(),
132
+ )
133
+
134
+
135
+ def write_picker(path: Path, rows: list[dict[str, Any]]) -> None:
136
+ """Write the rows of the `/model` picker.
137
+
138
+ The value `replaceBuiltInOptions` stays false. The built-in models
139
+ stay in the picker, and the rows come after them. A router that
140
+ stops therefore leaves a working picker.
141
+ """
142
+ before = file_sha256(path)
143
+ settings = read_settings(path)
144
+ settings[PICKER_KEY] = {"options": rows, "replaceBuiltInOptions": False}
145
+ _write_atomic(path, settings, before)
146
+
147
+
148
+ def current_rows(settings: dict[str, Any]) -> list[dict[str, Any]]:
149
+ """The rows that the settings hold now."""
150
+ picker = settings.get(PICKER_KEY) or {}
151
+ rows = picker.get("options")
152
+ return list(rows) if isinstance(rows, list) else []
153
+
154
+
155
+ def reset_settings(path: Path, state: State) -> list[str]:
156
+ """Remove every key that the state records."""
157
+ before = file_sha256(path)
158
+ settings = read_settings(path)
159
+ removed: list[str] = []
160
+
161
+ for key in state.keys_written:
162
+ parent, _, child = key.partition(".")
163
+ if child:
164
+ table = settings.get(parent)
165
+ if isinstance(table, dict) and child in table:
166
+ del table[child]
167
+ removed.append(key)
168
+ if not table:
169
+ del settings[parent]
170
+ elif parent in settings:
171
+ del settings[parent]
172
+ removed.append(parent)
173
+
174
+ _write_atomic(path, settings, before)
175
+ return removed
176
+
177
+
178
+ def read_state(path: Path) -> State | None:
179
+ """Read the state file. An absent file gives None."""
180
+ if not path.exists():
181
+ return None
182
+ try:
183
+ data = json.loads(path.read_text(encoding="utf-8"))
184
+ except json.JSONDecodeError as error:
185
+ raise ClaudeConfigError(f"{path} is not valid JSON: {error}") from None
186
+ entry = data.get("claude") or {}
187
+ return State(
188
+ profile=entry.get("profile", ""),
189
+ keys_written=list(entry.get("keys_written", [])),
190
+ settings_sha256=entry.get("settings_sha256", ""),
191
+ written_at=entry.get("written_at", ""),
192
+ )
193
+
194
+
195
+ def write_state(path: Path, state: State) -> None:
196
+ """Write the state file."""
197
+ path.parent.mkdir(parents=True, exist_ok=True)
198
+ path.write_text(
199
+ json.dumps({"version": STATE_VERSION, "claude": asdict(state)}, indent=2),
200
+ encoding="utf-8",
201
+ )
202
+
203
+
204
+ def clear_state(path: Path) -> None:
205
+ """Delete the state file."""
206
+ path.unlink(missing_ok=True)
@@ -58,16 +58,24 @@ def _read_prompt(name: str) -> str:
58
58
 
59
59
  @dataclass(frozen=True, slots=True)
60
60
  class Rules:
61
- """The three rule layers."""
61
+ """The four rule layers.
62
+
63
+ A router gives the same model a different slug. The model gpt-5.5
64
+ is `cx/gpt-5.5` on one router and `openai/gpt-5.5` on another one.
65
+ Therefore a name rule matches the last slug segment, and it works
66
+ on every router. A slug rule still wins over a name rule.
67
+ """
62
68
 
63
69
  defaults: dict[str, Any]
64
70
  prefixes: dict[str, dict[str, Any]]
71
+ names: dict[str, dict[str, Any]]
65
72
  slugs: dict[str, dict[str, Any]]
66
73
 
67
74
  def for_model(self, model: Model) -> dict[str, Any]:
68
75
  """The merged rule values for one model."""
69
76
  merged: dict[str, Any] = dict(self.defaults)
70
- merged.update(self.prefixes.get(model.prefix, {}))
77
+ merged.update(self.prefixes.get(model.prefix.lower(), {}))
78
+ merged.update(self.names.get(model.leaf_name.lower(), {}))
71
79
  merged.update(self.slugs.get(model.slug, {}))
72
80
  return merged
73
81
 
@@ -83,7 +91,8 @@ def load_rules(path: Path | None = None) -> Rules:
83
91
  data = tomllib.loads(text)
84
92
  return Rules(
85
93
  defaults=dict(data.get("defaults", {})),
86
- prefixes={k: dict(v) for k, v in (data.get("prefix") or {}).items()},
94
+ prefixes={k.lower(): dict(v) for k, v in (data.get("prefix") or {}).items()},
95
+ names={k.lower(): dict(v) for k, v in (data.get("name") or {}).items()},
87
96
  slugs={k: dict(v) for k, v in (data.get("slug") or {}).items()},
88
97
  )
89
98
 
@@ -103,7 +112,8 @@ def render_entry(model: Model, rules: Rules) -> dict[str, Any]:
103
112
  "slug": model.slug,
104
113
  "display_name": values.get("display_name") or _display_name(model),
105
114
  "description": str(values["description_template"]).format(slug=model.slug),
106
- "default_reasoning_level": "medium" if model.reasoning else None,
115
+ # Codex accepts a string here. A null stops Codex at startup.
116
+ "default_reasoning_level": values["default_reasoning_level"],
107
117
  "supported_reasoning_levels": (
108
118
  [dict(level) for level in REASONING_LEVELS] if model.reasoning else []
109
119
  ),
@@ -127,9 +137,10 @@ def render_entry(model: Model, rules: Rules) -> dict[str, Any]:
127
137
  "support_verbosity": values["support_verbosity"],
128
138
  "default_verbosity": values["default_verbosity"],
129
139
  "apply_patch_tool_type": values["apply_patch_tool_type"],
130
- "web_search_tool_type": (
131
- values["web_search_tool_type"] if model.search else None
132
- ),
140
+ # Codex accepts a string or a map here. A null stops Codex at
141
+ # startup. The flag supports_search_tool carries the fact from
142
+ # the endpoint.
143
+ "web_search_tool_type": values["web_search_tool_type"],
133
144
  "truncation_policy": values["truncation_policy"],
134
145
  "supports_parallel_tool_calls": model.tools,
135
146
  "supports_image_detail_original": values["supports_image_detail_original"],
@@ -0,0 +1,51 @@
1
+ # Claude Code refuses a model that it does not know. A picker row gives
2
+ # it `behavesAs`: the id of a model that Claude Code knows. The prompt
3
+ # profile, the capability defaults, and the effort defaults of that
4
+ # model then apply.
5
+ #
6
+ # A model name that already holds a known model id needs no behavesAs.
7
+ # The list `native_ids` holds those known ids.
8
+ #
9
+ # The layers apply in this order. A later layer wins:
10
+ # 1. defaults
11
+ # 2. prefix.<first slug segment>
12
+ # 3. name.<last slug segment> matches on every router
13
+ # 4. slug."<exact slug>" matches one router only
14
+
15
+ [defaults]
16
+ behaves_as = "claude-opus-4-8"
17
+ description_template = "{slug}"
18
+
19
+ # A model name that holds one of these ids keeps its native handling.
20
+ native_ids = [
21
+ "claude-opus-5",
22
+ "claude-opus-4-8",
23
+ "claude-opus-4-7",
24
+ "claude-opus-4-6",
25
+ "claude-opus-4-5",
26
+ "claude-opus-4-1",
27
+ "claude-opus-4",
28
+ "claude-sonnet-5",
29
+ "claude-sonnet-4-6",
30
+ "claude-sonnet-4-5",
31
+ "claude-sonnet-4",
32
+ "claude-sonnet-3-7",
33
+ "claude-haiku-4-5",
34
+ "claude-haiku-4",
35
+ "claude-haiku-3-5",
36
+ "claude-fable-5-1",
37
+ "claude-fable-5",
38
+ ]
39
+
40
+ # A small or fast model works better with the handling of a small model.
41
+ [name."deepseek-v4.1-flash"]
42
+ behaves_as = "claude-haiku-4-5"
43
+
44
+ [name."deepseek-v4-flash"]
45
+ behaves_as = "claude-haiku-4-5"
46
+
47
+ [name."step-3.5-flash"]
48
+ behaves_as = "claude-haiku-4-5"
49
+
50
+ [name."gpt-5.4-mini"]
51
+ behaves_as = "claude-sonnet-4-6"
@@ -8,7 +8,12 @@
8
8
  # The layers apply in this order. A later layer wins:
9
9
  # 1. defaults
10
10
  # 2. prefix.<first slug segment>
11
- # 3. slug."<exact slug>"
11
+ # 3. name.<last slug segment> matches on every router
12
+ # 4. slug."<exact slug>" matches one router only
13
+ #
14
+ # Prefer a name rule. A router gives the same model a different slug.
15
+ # The model gpt-5.5 is "cx/gpt-5.5" on one router and "openai/gpt-5.5"
16
+ # on another one. A name rule matches both.
12
17
 
13
18
  [defaults]
14
19
  base_instructions_file = "generic-codex.md"
@@ -20,6 +25,7 @@ shell_type = "shell_command"
20
25
  supported_in_api = true
21
26
  apply_patch_tool_type = "freeform"
22
27
  web_search_tool_type = "text_and_image"
28
+ default_reasoning_level = "medium"
23
29
  supports_reasoning_summaries = true
24
30
  default_reasoning_summary = "none"
25
31
  support_verbosity = true
@@ -40,9 +46,10 @@ limit = 10000
40
46
  priority = 10
41
47
 
42
48
  # The two OpenAI models carry their own prompts and their own speed
43
- # tiers. The values come from the working catalog.
49
+ # tiers. The values come from the working catalog. A name rule matches
50
+ # the model on every router.
44
51
 
45
- [slug."cx/gpt-5.5"]
52
+ [name."gpt-5.5"]
46
53
  base_instructions_file = "gpt-5.5.md"
47
54
  model_messages_file = "gpt-5.5.messages.json"
48
55
  priority = 18
@@ -53,7 +60,7 @@ service_tiers = [
53
60
  { id = "priority", name = "Fast", description = "1.5x speed, increased usage" },
54
61
  ]
55
62
 
56
- [slug."cx/gpt-5.4"]
63
+ [name."gpt-5.4"]
57
64
  base_instructions_file = "gpt-5.4.md"
58
65
  model_messages_file = "gpt-5.4.messages.json"
59
66
  priority = 14