xsync-cli 0.1.3__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/PKG-INFO +84 -16
  2. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/README.md +83 -15
  3. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/docs/superpowers/specs/2026-09-13-xsync-design.md +72 -0
  4. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/pyproject.toml +1 -1
  5. xsync_cli-0.2.0/src/xsync_cli/adapters/claude.py +92 -0
  6. xsync_cli-0.2.0/src/xsync_cli/adapters/claude_config.py +206 -0
  7. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/adapters/codex.py +12 -3
  8. xsync_cli-0.2.0/src/xsync_cli/adapters/isolated.py +118 -0
  9. xsync_cli-0.2.0/src/xsync_cli/adapters/rules/claude.toml +51 -0
  10. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/adapters/rules/codex.toml +10 -4
  11. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/cli.py +393 -20
  12. xsync_cli-0.2.0/src/xsync_cli/core/homes.py +108 -0
  13. xsync_cli-0.2.0/tests/test_claude_config.py +191 -0
  14. xsync_cli-0.2.0/tests/test_claude_render.py +86 -0
  15. xsync_cli-0.2.0/tests/test_cli_claude.py +140 -0
  16. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/test_cli_codex.py +29 -22
  17. xsync_cli-0.2.0/tests/test_cli_launch.py +144 -0
  18. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/test_cli_profiles.py +20 -0
  19. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/test_codex_render.py +29 -0
  20. xsync_cli-0.2.0/tests/test_homes.py +131 -0
  21. xsync_cli-0.2.0/tests/test_isolated.py +149 -0
  22. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/uv.lock +1 -1
  23. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/.gitignore +0 -0
  24. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/LICENSE +0 -0
  25. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/docs/superpowers/plans/2026-09-13-xsync-cli.md +0 -0
  26. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/docs/verification-2026-09-13.md +0 -0
  27. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/__init__.py +0 -0
  28. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/adapters/__init__.py +0 -0
  29. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/adapters/codex_config.py +0 -0
  30. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/adapters/codex_wiring.py +0 -0
  31. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/adapters/prompts/__init__.py +0 -0
  32. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/adapters/prompts/generic-codex.md +0 -0
  33. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/adapters/prompts/generic-codex.messages.json +0 -0
  34. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/adapters/prompts/gpt-5.4.md +0 -0
  35. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/adapters/prompts/gpt-5.4.messages.json +0 -0
  36. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/adapters/prompts/gpt-5.5.md +0 -0
  37. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/adapters/prompts/gpt-5.5.messages.json +0 -0
  38. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/adapters/rules/__init__.py +0 -0
  39. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/core/__init__.py +0 -0
  40. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/core/atomic.py +0 -0
  41. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/core/diff.py +0 -0
  42. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/core/filters.py +0 -0
  43. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/core/model.py +0 -0
  44. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/core/profiles.py +0 -0
  45. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/core/term.py +0 -0
  46. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/sources/__init__.py +0 -0
  47. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/sources/openai_compat.py +0 -0
  48. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/__init__.py +0 -0
  49. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/fixtures/__init__.py +0 -0
  50. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/fixtures/ninerouter_models.json +0 -0
  51. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/fixtures/plain_openai_models.json +0 -0
  52. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/test_atomic.py +0 -0
  53. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/test_codex_config.py +0 -0
  54. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/test_codex_wiring.py +0 -0
  55. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/test_diff.py +0 -0
  56. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/test_filters.py +0 -0
  57. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/test_model.py +0 -0
  58. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/test_openai_compat.py +0 -0
  59. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/test_package_data.py +0 -0
  60. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/test_profiles.py +0 -0
  61. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/test_term.py +0 -0
  62. {xsync_cli-0.1.3 → xsync_cli-0.2.0}/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.3
3
+ Version: 0.2.0
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
@@ -50,19 +50,25 @@ The last question asks for the wire API:
50
50
  A wrong value breaks every request. Ask the operator of the endpoint when
51
51
  you do not know.
52
52
 
53
- ### 2. Connect Codex to the endpoint
53
+ ### 2. Open a harness
54
54
 
55
- xsync codex --init
55
+ xsync codex
56
+ xsync claude
56
57
 
57
- The command adds `[model_providers.<profile>]` to `~/.codex/config.toml` and
58
- points `model_catalog_json` at the catalog of the profile. Run this one time
59
- for each endpoint.
58
+ The command builds a home for the profile, then starts the harness in that
59
+ home. The real Codex and the real Claude Code of the user stay as they are.
60
60
 
61
- ### 3. Sync
61
+ Arguments after `--` go to the harness:
62
62
 
63
- xsync codex
63
+ xsync claude -- --model cmc/deepseek/deepseek-v4-pro
64
+
65
+ ### 3. Or write the real harness
64
66
 
65
- Run this command again when the endpoint changes.
67
+ xsync codex apply
68
+ xsync claude apply
69
+
70
+ The `apply` verb changes the real home of the user. Read **Safety** before
71
+ you use it.
66
72
 
67
73
  ## Commands
68
74
 
@@ -72,17 +78,79 @@ Run this command again when the endpoint changes.
72
78
  | `xsync list` | Show the profiles. The active profile has a star. |
73
79
  | `xsync use <name>` | Set the active profile. |
74
80
  | `xsync remove <name>` | Delete a profile. |
75
- | `xsync codex` | Sync the active profile into the Codex catalog. |
76
- | `xsync codex --dry-run` | Show the difference. Write nothing. |
77
- | `xsync codex --profile <name>` | Use another profile for one run. |
78
- | `xsync codex --init` | Connect Codex to the endpoint of the profile. |
79
- | `xsync codex --reset` | Remove everything that xSync wrote. |
80
- | `xsync codex --reset --force` | Reset when no state file exists. It asks first. |
81
- | `xsync codex --reset --force --yes` | Reset with no question. Use this in a script. |
81
+ | `xsync help` | Show the help. `xsync help codex` explains one command. |
82
+ | `xsync codex` | Open a Codex on the active profile, in its own home. |
83
+ | `xsync claude` | Open a Claude Code the same way. |
84
+ | `xsync codex -- <args>` | The arguments after `--` go to the harness. |
85
+ | `xsync codex apply` | Write the real Codex of the user. |
86
+ | `xsync claude apply` | Write the real Claude Code of the user. |
87
+ | `xsync <harness> apply --dry-run` | Show the difference. Write nothing. |
88
+ | `xsync <harness> apply --reset` | Remove everything that xSync wrote. |
89
+ | `xsync <harness> apply --reset --force --yes` | Reset with no question. |
90
+ | `xsync <harness> --profile <name>` | Use another profile for one run. |
91
+
92
+ ## The isolated home
93
+
94
+ `xsync codex` and `xsync claude` do not change the settings of the user. Each
95
+ one builds a home under `~/.config/xsync/homes/<profile>/`, and then starts
96
+ the harness with `CODEX_HOME` or with `CLAUDE_CONFIG_DIR` set to that home.
97
+
98
+ The home starts as a copy of the real settings file, so the hooks of the
99
+ user and the trusted projects of the user stay. xSync then points the copy at
100
+ the endpoint of the profile.
101
+
102
+ The home shares the work of the user through a symbolic link:
103
+
104
+ | Harness | Shared |
105
+ |---|---|
106
+ | Codex | `skills`, `plugins`, `marketplaces`, `memories`, `AGENTS.md` |
107
+ | Claude Code | `agents`, `skills`, `plugins`, `hooks`, `commands`, `CLAUDE.md` |
108
+
109
+ A session, a cache, and a log stay inside the isolated home. They never mix
110
+ with the real home.
111
+
112
+ Delete a home at any time. The next command builds it again.
82
113
 
83
114
  Exit codes: `0` for success, `1` for an error, `2` when the endpoint does not
84
115
  answer.
85
116
 
117
+ ## Claude Code
118
+
119
+ xsync claude --init
120
+ xsync claude
121
+
122
+ Claude Code holds no catalog file. It reads the rows of the `/model`
123
+ picker from `modelPicker.options` in `~/.claude/settings.json`, and it reads
124
+ the endpoint from the `env` block of the same file. `xsync claude` writes
125
+ those two keys and nothing else.
126
+
127
+ The endpoint must answer the Anthropic API at `POST /v1/messages`. An
128
+ endpoint that answers only the OpenAI API works with Codex, not with Claude
129
+ Code.
130
+
131
+ ### The map to a known model
132
+
133
+ Claude Code refuses a model that it does not know:
134
+
135
+ "cmc/deepseek/deepseek-v4-pro" isn't described by this version's model
136
+ catalog; update Claude Code, or map it with behavesAs on a modelPicker row
137
+
138
+ Therefore each row carries `behavesAs`: the id of a model that Claude Code
139
+ knows. The prompt profile, the capability defaults, and the effort defaults
140
+ of that model then apply. The model id that Claude Code sends does not
141
+ change.
142
+
143
+ A model name that already holds a known model id, such as
144
+ `ed3n/claude-sonnet-5`, gets no `behavesAs`. It keeps its native handling.
145
+
146
+ Edit `adapters/rules/claude.toml` to change a map.
147
+
148
+ ### The built-in models stay
149
+
150
+ xSync sets `replaceBuiltInOptions` to `false`. The built-in models stay at
151
+ the top of the picker, and the router models come after them. A router that
152
+ stops therefore leaves a working picker.
153
+
86
154
  ## Switch between endpoints
87
155
 
88
156
  Codex holds one model provider. Therefore one profile is active at a time.
@@ -38,19 +38,25 @@ The last question asks for the wire API:
38
38
  A wrong value breaks every request. Ask the operator of the endpoint when
39
39
  you do not know.
40
40
 
41
- ### 2. Connect Codex to the endpoint
41
+ ### 2. Open a harness
42
42
 
43
- xsync codex --init
43
+ xsync codex
44
+ xsync claude
44
45
 
45
- The command adds `[model_providers.<profile>]` to `~/.codex/config.toml` and
46
- points `model_catalog_json` at the catalog of the profile. Run this one time
47
- for each endpoint.
46
+ The command builds a home for the profile, then starts the harness in that
47
+ home. The real Codex and the real Claude Code of the user stay as they are.
48
48
 
49
- ### 3. Sync
49
+ Arguments after `--` go to the harness:
50
50
 
51
- xsync codex
51
+ xsync claude -- --model cmc/deepseek/deepseek-v4-pro
52
+
53
+ ### 3. Or write the real harness
52
54
 
53
- Run this command again when the endpoint changes.
55
+ xsync codex apply
56
+ xsync claude apply
57
+
58
+ The `apply` verb changes the real home of the user. Read **Safety** before
59
+ you use it.
54
60
 
55
61
  ## Commands
56
62
 
@@ -60,17 +66,79 @@ Run this command again when the endpoint changes.
60
66
  | `xsync list` | Show the profiles. The active profile has a star. |
61
67
  | `xsync use <name>` | Set the active profile. |
62
68
  | `xsync remove <name>` | Delete a profile. |
63
- | `xsync codex` | Sync the active profile into the Codex catalog. |
64
- | `xsync codex --dry-run` | Show the difference. Write nothing. |
65
- | `xsync codex --profile <name>` | Use another profile for one run. |
66
- | `xsync codex --init` | Connect Codex to the endpoint of the profile. |
67
- | `xsync codex --reset` | Remove everything that xSync wrote. |
68
- | `xsync codex --reset --force` | Reset when no state file exists. It asks first. |
69
- | `xsync codex --reset --force --yes` | Reset with no question. Use this in a script. |
69
+ | `xsync help` | Show the help. `xsync help codex` explains one command. |
70
+ | `xsync codex` | Open a Codex on the active profile, in its own home. |
71
+ | `xsync claude` | Open a Claude Code the same way. |
72
+ | `xsync codex -- <args>` | The arguments after `--` go to the harness. |
73
+ | `xsync codex apply` | Write the real Codex of the user. |
74
+ | `xsync claude apply` | Write the real Claude Code of the user. |
75
+ | `xsync <harness> apply --dry-run` | Show the difference. Write nothing. |
76
+ | `xsync <harness> apply --reset` | Remove everything that xSync wrote. |
77
+ | `xsync <harness> apply --reset --force --yes` | Reset with no question. |
78
+ | `xsync <harness> --profile <name>` | Use another profile for one run. |
79
+
80
+ ## The isolated home
81
+
82
+ `xsync codex` and `xsync claude` do not change the settings of the user. Each
83
+ one builds a home under `~/.config/xsync/homes/<profile>/`, and then starts
84
+ the harness with `CODEX_HOME` or with `CLAUDE_CONFIG_DIR` set to that home.
85
+
86
+ The home starts as a copy of the real settings file, so the hooks of the
87
+ user and the trusted projects of the user stay. xSync then points the copy at
88
+ the endpoint of the profile.
89
+
90
+ The home shares the work of the user through a symbolic link:
91
+
92
+ | Harness | Shared |
93
+ |---|---|
94
+ | Codex | `skills`, `plugins`, `marketplaces`, `memories`, `AGENTS.md` |
95
+ | Claude Code | `agents`, `skills`, `plugins`, `hooks`, `commands`, `CLAUDE.md` |
96
+
97
+ A session, a cache, and a log stay inside the isolated home. They never mix
98
+ with the real home.
99
+
100
+ Delete a home at any time. The next command builds it again.
70
101
 
71
102
  Exit codes: `0` for success, `1` for an error, `2` when the endpoint does not
72
103
  answer.
73
104
 
105
+ ## Claude Code
106
+
107
+ xsync claude --init
108
+ xsync claude
109
+
110
+ Claude Code holds no catalog file. It reads the rows of the `/model`
111
+ picker from `modelPicker.options` in `~/.claude/settings.json`, and it reads
112
+ the endpoint from the `env` block of the same file. `xsync claude` writes
113
+ those two keys and nothing else.
114
+
115
+ The endpoint must answer the Anthropic API at `POST /v1/messages`. An
116
+ endpoint that answers only the OpenAI API works with Codex, not with Claude
117
+ Code.
118
+
119
+ ### The map to a known model
120
+
121
+ Claude Code refuses a model that it does not know:
122
+
123
+ "cmc/deepseek/deepseek-v4-pro" isn't described by this version's model
124
+ catalog; update Claude Code, or map it with behavesAs on a modelPicker row
125
+
126
+ Therefore each row carries `behavesAs`: the id of a model that Claude Code
127
+ knows. The prompt profile, the capability defaults, and the effort defaults
128
+ of that model then apply. The model id that Claude Code sends does not
129
+ change.
130
+
131
+ A model name that already holds a known model id, such as
132
+ `ed3n/claude-sonnet-5`, gets no `behavesAs`. It keeps its native handling.
133
+
134
+ Edit `adapters/rules/claude.toml` to change a map.
135
+
136
+ ### The built-in models stay
137
+
138
+ xSync sets `replaceBuiltInOptions` to `false`. The built-in models stay at
139
+ the top of the picker, and the router models come after them. A router that
140
+ stops therefore leaves a working picker.
141
+
74
142
  ## Switch between endpoints
75
143
 
76
144
  Codex holds one model provider. Therefore one profile is active at a time.
@@ -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.3"
7
+ version = "0.2.0"
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