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.
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/PKG-INFO +84 -16
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/README.md +83 -15
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/docs/superpowers/specs/2026-09-13-xsync-design.md +72 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/pyproject.toml +1 -1
- xsync_cli-0.2.0/src/xsync_cli/adapters/claude.py +92 -0
- xsync_cli-0.2.0/src/xsync_cli/adapters/claude_config.py +206 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/adapters/codex.py +12 -3
- xsync_cli-0.2.0/src/xsync_cli/adapters/isolated.py +118 -0
- xsync_cli-0.2.0/src/xsync_cli/adapters/rules/claude.toml +51 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/adapters/rules/codex.toml +10 -4
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/cli.py +393 -20
- xsync_cli-0.2.0/src/xsync_cli/core/homes.py +108 -0
- xsync_cli-0.2.0/tests/test_claude_config.py +191 -0
- xsync_cli-0.2.0/tests/test_claude_render.py +86 -0
- xsync_cli-0.2.0/tests/test_cli_claude.py +140 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/test_cli_codex.py +29 -22
- xsync_cli-0.2.0/tests/test_cli_launch.py +144 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/test_cli_profiles.py +20 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/test_codex_render.py +29 -0
- xsync_cli-0.2.0/tests/test_homes.py +131 -0
- xsync_cli-0.2.0/tests/test_isolated.py +149 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/uv.lock +1 -1
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/.gitignore +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/LICENSE +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/docs/superpowers/plans/2026-09-13-xsync-cli.md +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/docs/verification-2026-09-13.md +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/__init__.py +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/adapters/__init__.py +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/adapters/codex_config.py +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/adapters/codex_wiring.py +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/adapters/prompts/__init__.py +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/adapters/prompts/generic-codex.md +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/adapters/prompts/generic-codex.messages.json +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/adapters/prompts/gpt-5.4.md +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/adapters/prompts/gpt-5.4.messages.json +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/adapters/prompts/gpt-5.5.md +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/adapters/prompts/gpt-5.5.messages.json +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/adapters/rules/__init__.py +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/core/__init__.py +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/core/atomic.py +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/core/diff.py +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/core/filters.py +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/core/model.py +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/core/profiles.py +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/core/term.py +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/sources/__init__.py +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/src/xsync_cli/sources/openai_compat.py +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/__init__.py +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/fixtures/__init__.py +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/fixtures/ninerouter_models.json +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/fixtures/plain_openai_models.json +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/test_atomic.py +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/test_codex_config.py +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/test_codex_wiring.py +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/test_diff.py +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/test_filters.py +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/test_model.py +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/test_openai_compat.py +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/test_package_data.py +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/test_profiles.py +0 -0
- {xsync_cli-0.1.3 → xsync_cli-0.2.0}/tests/test_term.py +0 -0
- {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.
|
|
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.
|
|
53
|
+
### 2. Open a harness
|
|
54
54
|
|
|
55
|
-
xsync codex
|
|
55
|
+
xsync codex
|
|
56
|
+
xsync claude
|
|
56
57
|
|
|
57
|
-
The command
|
|
58
|
-
|
|
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
|
-
|
|
61
|
+
Arguments after `--` go to the harness:
|
|
62
62
|
|
|
63
|
-
xsync
|
|
63
|
+
xsync claude -- --model cmc/deepseek/deepseek-v4-pro
|
|
64
|
+
|
|
65
|
+
### 3. Or write the real harness
|
|
64
66
|
|
|
65
|
-
|
|
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
|
|
76
|
-
| `xsync codex
|
|
77
|
-
| `xsync
|
|
78
|
-
| `xsync codex --
|
|
79
|
-
| `xsync codex
|
|
80
|
-
| `xsync
|
|
81
|
-
| `xsync
|
|
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.
|
|
41
|
+
### 2. Open a harness
|
|
42
42
|
|
|
43
|
-
xsync codex
|
|
43
|
+
xsync codex
|
|
44
|
+
xsync claude
|
|
44
45
|
|
|
45
|
-
The command
|
|
46
|
-
|
|
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
|
-
|
|
49
|
+
Arguments after `--` go to the harness:
|
|
50
50
|
|
|
51
|
-
xsync
|
|
51
|
+
xsync claude -- --model cmc/deepseek/deepseek-v4-pro
|
|
52
|
+
|
|
53
|
+
### 3. Or write the real harness
|
|
52
54
|
|
|
53
|
-
|
|
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
|
|
64
|
-
| `xsync codex
|
|
65
|
-
| `xsync
|
|
66
|
-
| `xsync codex --
|
|
67
|
-
| `xsync codex
|
|
68
|
-
| `xsync
|
|
69
|
-
| `xsync
|
|
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.
|
|
@@ -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
|
|
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
|
|