xsync-cli 0.1.4__tar.gz → 0.2.1__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.4 → xsync_cli-0.2.1}/.gitignore +3 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/PKG-INFO +108 -55
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/README.md +107 -54
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/pyproject.toml +1 -1
- xsync_cli-0.2.1/src/xsync_cli/adapters/isolated.py +118 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/cli.py +260 -48
- xsync_cli-0.2.1/src/xsync_cli/core/homes.py +108 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_cli_claude.py +24 -15
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_cli_codex.py +29 -22
- xsync_cli-0.2.1/tests/test_cli_launch.py +144 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_cli_profiles.py +31 -0
- xsync_cli-0.2.1/tests/test_homes.py +131 -0
- xsync_cli-0.2.1/tests/test_isolated.py +149 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/uv.lock +1 -1
- xsync_cli-0.1.4/docs/superpowers/plans/2026-09-13-xsync-cli.md +0 -3144
- xsync_cli-0.1.4/docs/superpowers/specs/2026-09-13-xsync-design.md +0 -499
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/LICENSE +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/docs/verification-2026-09-13.md +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/__init__.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/__init__.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/claude.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/claude_config.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/codex.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/codex_config.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/codex_wiring.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/prompts/__init__.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/prompts/generic-codex.md +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/prompts/generic-codex.messages.json +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/prompts/gpt-5.4.md +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/prompts/gpt-5.4.messages.json +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/prompts/gpt-5.5.md +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/prompts/gpt-5.5.messages.json +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/rules/__init__.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/rules/claude.toml +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/rules/codex.toml +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/core/__init__.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/core/atomic.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/core/diff.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/core/filters.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/core/model.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/core/profiles.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/core/term.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/sources/__init__.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/sources/openai_compat.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/__init__.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/fixtures/__init__.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/fixtures/ninerouter_models.json +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/fixtures/plain_openai_models.json +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_atomic.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_claude_config.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_claude_render.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_codex_config.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_codex_render.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_codex_wiring.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_diff.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_filters.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_model.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_openai_compat.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_package_data.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_profiles.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_term.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.1}/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
|
+
Version: 0.2.1
|
|
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
|
|
@@ -12,15 +12,29 @@ Description-Content-Type: text/markdown
|
|
|
12
12
|
|
|
13
13
|
# xsync-cli
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
Point Codex and Claude Code at any OpenAI-compatible endpoint, with every
|
|
16
|
+
model of that endpoint in the model picker.
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
model or drops a model. `xsync` writes the file for you.
|
|
18
|
+
```
|
|
19
|
+
$ xsync claude
|
|
21
20
|
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
claude · 9router
|
|
22
|
+
http://127.0.0.1:20128/v1
|
|
23
|
+
home: ~/.config/xsync/homes/9router/claude
|
|
24
|
+
✓ 35 models ready. Your real claude is untouched.
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
A harness keeps its model list in a settings file. A person must write that
|
|
28
|
+
file by hand, and the file becomes wrong when the endpoint adds a model or
|
|
29
|
+
drops one. xSync writes the file for you.
|
|
30
|
+
|
|
31
|
+
xSync opens the harness in a home of its own. The settings of the user stay
|
|
32
|
+
as they are. Add `apply` when you do want to change them.
|
|
33
|
+
|
|
34
|
+
| Harness | Needs |
|
|
35
|
+
|---|---|
|
|
36
|
+
| Codex | An endpoint that answers `GET /v1/models`. |
|
|
37
|
+
| Claude Code | The same, and `POST /v1/messages` for the requests. |
|
|
24
38
|
|
|
25
39
|
## Install
|
|
26
40
|
|
|
@@ -50,19 +64,25 @@ The last question asks for the wire API:
|
|
|
50
64
|
A wrong value breaks every request. Ask the operator of the endpoint when
|
|
51
65
|
you do not know.
|
|
52
66
|
|
|
53
|
-
### 2.
|
|
67
|
+
### 2. Open a harness
|
|
54
68
|
|
|
55
|
-
xsync codex
|
|
69
|
+
xsync codex
|
|
70
|
+
xsync claude
|
|
56
71
|
|
|
57
|
-
The command
|
|
58
|
-
|
|
59
|
-
for each endpoint.
|
|
72
|
+
The command builds a home for the profile, then starts the harness in that
|
|
73
|
+
home. The real Codex and the real Claude Code of the user stay as they are.
|
|
60
74
|
|
|
61
|
-
|
|
75
|
+
Arguments after `--` go to the harness:
|
|
62
76
|
|
|
63
|
-
xsync
|
|
77
|
+
xsync claude -- --model cmc/deepseek/deepseek-v4-pro
|
|
78
|
+
|
|
79
|
+
### 3. Or write the real harness
|
|
80
|
+
|
|
81
|
+
xsync codex apply
|
|
82
|
+
xsync claude apply
|
|
64
83
|
|
|
65
|
-
|
|
84
|
+
The `apply` verb changes the real home of the user. Read **Safety** before
|
|
85
|
+
you use it.
|
|
66
86
|
|
|
67
87
|
## Commands
|
|
68
88
|
|
|
@@ -72,18 +92,38 @@ Run this command again when the endpoint changes.
|
|
|
72
92
|
| `xsync list` | Show the profiles. The active profile has a star. |
|
|
73
93
|
| `xsync use <name>` | Set the active profile. |
|
|
74
94
|
| `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. |
|
|
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
95
|
| `xsync help` | Show the help. `xsync help codex` explains one command. |
|
|
96
|
+
| `xsync codex` | Open a Codex on the active profile, in its own home. |
|
|
97
|
+
| `xsync claude` | Open a Claude Code the same way. |
|
|
98
|
+
| `xsync codex -- <args>` | The arguments after `--` go to the harness. |
|
|
99
|
+
| `xsync codex apply` | Write the real Codex of the user. |
|
|
100
|
+
| `xsync claude apply` | Write the real Claude Code of the user. |
|
|
101
|
+
| `xsync <harness> apply --dry-run` | Show the difference. Write nothing. |
|
|
102
|
+
| `xsync <harness> apply --reset` | Remove everything that xSync wrote. |
|
|
103
|
+
| `xsync <harness> apply --reset --force --yes` | Reset with no question. |
|
|
104
|
+
| `xsync <harness> --profile <name>` | Use another profile for one run. |
|
|
105
|
+
|
|
106
|
+
## The isolated home
|
|
107
|
+
|
|
108
|
+
`xsync codex` and `xsync claude` do not change the settings of the user. Each
|
|
109
|
+
one builds a home under `~/.config/xsync/homes/<profile>/`, and then starts
|
|
110
|
+
the harness with `CODEX_HOME` or with `CLAUDE_CONFIG_DIR` set to that home.
|
|
111
|
+
|
|
112
|
+
The home starts as a copy of the real settings file, so the hooks of the
|
|
113
|
+
user and the trusted projects of the user stay. xSync then points the copy at
|
|
114
|
+
the endpoint of the profile.
|
|
115
|
+
|
|
116
|
+
The home shares the work of the user through a symbolic link:
|
|
117
|
+
|
|
118
|
+
| Harness | Shared |
|
|
119
|
+
|---|---|
|
|
120
|
+
| Codex | `skills`, `plugins`, `marketplaces`, `memories`, `AGENTS.md` |
|
|
121
|
+
| Claude Code | `agents`, `skills`, `plugins`, `hooks`, `commands`, `CLAUDE.md` |
|
|
122
|
+
|
|
123
|
+
A session, a cache, and a log stay inside the isolated home. They never mix
|
|
124
|
+
with the real home.
|
|
125
|
+
|
|
126
|
+
Delete a home at any time. The next command builds it again.
|
|
87
127
|
|
|
88
128
|
Exit codes: `0` for success, `1` for an error, `2` when the endpoint does not
|
|
89
129
|
answer.
|
|
@@ -127,20 +167,22 @@ stops therefore leaves a working picker.
|
|
|
127
167
|
|
|
128
168
|
## Switch between endpoints
|
|
129
169
|
|
|
130
|
-
|
|
170
|
+
Each profile gets a home of its own. Therefore two endpoints never mix:
|
|
131
171
|
|
|
132
|
-
xsync codex --profile openrouter
|
|
172
|
+
xsync codex --profile openrouter
|
|
173
|
+
xsync codex --profile 9router
|
|
133
174
|
|
|
134
|
-
|
|
135
|
-
|
|
175
|
+
A harness holds one model provider. Therefore `apply` writes one profile at
|
|
176
|
+
a time:
|
|
136
177
|
|
|
137
|
-
|
|
138
|
-
Codex still routes somewhere else. Every model then fails at request time.
|
|
139
|
-
xSync finds this mismatch before it writes, and it stops:
|
|
178
|
+
xsync codex apply --profile openrouter
|
|
140
179
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
180
|
+
`apply` always points the harness at the endpoint of the profile, so the
|
|
181
|
+
model list and the endpoint always agree. A dry run does not point anything
|
|
182
|
+
at anything, so it warns when the two disagree:
|
|
183
|
+
|
|
184
|
+
the rows would come from "openrouter" (https://openrouter.ai/api/v1)
|
|
185
|
+
but Claude Code talks to http://127.0.0.1:20128/v1
|
|
144
186
|
|
|
145
187
|
## The profile file
|
|
146
188
|
|
|
@@ -175,29 +217,35 @@ A glob pattern selects a model by name. The sign `*` means any characters.
|
|
|
175
217
|
| `exclude = ["*-image-*", "*-tts-*"]` | Drop the image models and the speech models. |
|
|
176
218
|
|
|
177
219
|
The `exclude` list always wins over the `include` list. Use the filters when
|
|
178
|
-
an endpoint serves many models that you never choose in
|
|
220
|
+
an endpoint serves many models that you never choose in a harness.
|
|
179
221
|
|
|
180
222
|
The match ignores the letter case.
|
|
181
223
|
|
|
182
224
|
## Safety
|
|
183
225
|
|
|
226
|
+
**The everyday commands write nothing outside `~/.config/xsync/`.** Only
|
|
227
|
+
`apply` touches `~/.codex` or `~/.claude`.
|
|
228
|
+
|
|
184
229
|
- The catalog write is atomic. xSync writes a temporary file, confirms the
|
|
185
230
|
JSON, and then replaces the target. It keeps one backup with the suffix
|
|
186
231
|
`.bak`.
|
|
187
|
-
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
232
|
+
- A harness writes its own settings file while it runs. Therefore `apply`
|
|
233
|
+
hashes the file before the edit and again before the replace. On a
|
|
234
|
+
difference it stops and writes nothing.
|
|
235
|
+
- Claude Code rewrites `settings.json` from its own memory. An `apply` that
|
|
236
|
+
runs while Claude Code is open can therefore disappear. Close Claude Code
|
|
237
|
+
first, or use `xsync claude` and change nothing.
|
|
238
|
+
- `apply --dry-run` writes nothing at all. It does not point the harness at
|
|
239
|
+
the endpoint either.
|
|
240
|
+
- `apply --reset` removes only what the state file records. It never
|
|
241
|
+
restores an old copy of the settings, because a harness adds its own
|
|
242
|
+
entries to that file over time.
|
|
243
|
+
- `apply --reset` keeps the backup `<name>.bak`. The backup is the only way
|
|
244
|
+
back after a reset.
|
|
245
|
+
- With no state file, `apply --reset` prints the keys that it would remove
|
|
246
|
+
and then stops. Add `--force` to continue. The command then asks a
|
|
247
|
+
question before it removes anything. Add `--yes` to answer the question in
|
|
248
|
+
a script.
|
|
201
249
|
- The setup hides the API key while you type it. It then shows the first
|
|
202
250
|
five characters only, and it never shows the length of the key.
|
|
203
251
|
- xSync never prints a whole API key.
|
|
@@ -214,9 +262,14 @@ keep the color in a pipe.
|
|
|
214
262
|
|---|---|---|
|
|
215
263
|
| `{base_url}/models` | yes | no |
|
|
216
264
|
| `~/.config/xsync/profiles.toml` | yes | yes |
|
|
217
|
-
| `~/.
|
|
218
|
-
| `~/.codex/config.toml` | yes | only with
|
|
219
|
-
| `~/.codex
|
|
265
|
+
| `~/.config/xsync/homes/...` | yes | yes |
|
|
266
|
+
| `~/.codex/config.toml` | yes | only with `apply` |
|
|
267
|
+
| `~/.codex/<profile>-models.json` | yes | only with `apply` |
|
|
268
|
+
| `~/.claude/settings.json` | yes | only with `apply` |
|
|
269
|
+
| `.xsync-state.json` in each home | yes | only with `apply` |
|
|
270
|
+
|
|
271
|
+
xSync copies the real settings file when it builds an isolated home. It
|
|
272
|
+
reads that file. It never writes it.
|
|
220
273
|
|
|
221
274
|
## The catalog fields
|
|
222
275
|
|
|
@@ -1,14 +1,28 @@
|
|
|
1
1
|
# xsync-cli
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Point Codex and Claude Code at any OpenAI-compatible endpoint, with every
|
|
4
|
+
model of that endpoint in the model picker.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
model or drops a model. `xsync` writes the file for you.
|
|
6
|
+
```
|
|
7
|
+
$ xsync claude
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
claude · 9router
|
|
10
|
+
http://127.0.0.1:20128/v1
|
|
11
|
+
home: ~/.config/xsync/homes/9router/claude
|
|
12
|
+
✓ 35 models ready. Your real claude is untouched.
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
A harness keeps its model list in a settings file. A person must write that
|
|
16
|
+
file by hand, and the file becomes wrong when the endpoint adds a model or
|
|
17
|
+
drops one. xSync writes the file for you.
|
|
18
|
+
|
|
19
|
+
xSync opens the harness in a home of its own. The settings of the user stay
|
|
20
|
+
as they are. Add `apply` when you do want to change them.
|
|
21
|
+
|
|
22
|
+
| Harness | Needs |
|
|
23
|
+
|---|---|
|
|
24
|
+
| Codex | An endpoint that answers `GET /v1/models`. |
|
|
25
|
+
| Claude Code | The same, and `POST /v1/messages` for the requests. |
|
|
12
26
|
|
|
13
27
|
## Install
|
|
14
28
|
|
|
@@ -38,19 +52,25 @@ The last question asks for the wire API:
|
|
|
38
52
|
A wrong value breaks every request. Ask the operator of the endpoint when
|
|
39
53
|
you do not know.
|
|
40
54
|
|
|
41
|
-
### 2.
|
|
55
|
+
### 2. Open a harness
|
|
42
56
|
|
|
43
|
-
xsync codex
|
|
57
|
+
xsync codex
|
|
58
|
+
xsync claude
|
|
44
59
|
|
|
45
|
-
The command
|
|
46
|
-
|
|
47
|
-
for each endpoint.
|
|
60
|
+
The command builds a home for the profile, then starts the harness in that
|
|
61
|
+
home. The real Codex and the real Claude Code of the user stay as they are.
|
|
48
62
|
|
|
49
|
-
|
|
63
|
+
Arguments after `--` go to the harness:
|
|
50
64
|
|
|
51
|
-
xsync
|
|
65
|
+
xsync claude -- --model cmc/deepseek/deepseek-v4-pro
|
|
66
|
+
|
|
67
|
+
### 3. Or write the real harness
|
|
68
|
+
|
|
69
|
+
xsync codex apply
|
|
70
|
+
xsync claude apply
|
|
52
71
|
|
|
53
|
-
|
|
72
|
+
The `apply` verb changes the real home of the user. Read **Safety** before
|
|
73
|
+
you use it.
|
|
54
74
|
|
|
55
75
|
## Commands
|
|
56
76
|
|
|
@@ -60,18 +80,38 @@ Run this command again when the endpoint changes.
|
|
|
60
80
|
| `xsync list` | Show the profiles. The active profile has a star. |
|
|
61
81
|
| `xsync use <name>` | Set the active profile. |
|
|
62
82
|
| `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. |
|
|
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
83
|
| `xsync help` | Show the help. `xsync help codex` explains one command. |
|
|
84
|
+
| `xsync codex` | Open a Codex on the active profile, in its own home. |
|
|
85
|
+
| `xsync claude` | Open a Claude Code the same way. |
|
|
86
|
+
| `xsync codex -- <args>` | The arguments after `--` go to the harness. |
|
|
87
|
+
| `xsync codex apply` | Write the real Codex of the user. |
|
|
88
|
+
| `xsync claude apply` | Write the real Claude Code of the user. |
|
|
89
|
+
| `xsync <harness> apply --dry-run` | Show the difference. Write nothing. |
|
|
90
|
+
| `xsync <harness> apply --reset` | Remove everything that xSync wrote. |
|
|
91
|
+
| `xsync <harness> apply --reset --force --yes` | Reset with no question. |
|
|
92
|
+
| `xsync <harness> --profile <name>` | Use another profile for one run. |
|
|
93
|
+
|
|
94
|
+
## The isolated home
|
|
95
|
+
|
|
96
|
+
`xsync codex` and `xsync claude` do not change the settings of the user. Each
|
|
97
|
+
one builds a home under `~/.config/xsync/homes/<profile>/`, and then starts
|
|
98
|
+
the harness with `CODEX_HOME` or with `CLAUDE_CONFIG_DIR` set to that home.
|
|
99
|
+
|
|
100
|
+
The home starts as a copy of the real settings file, so the hooks of the
|
|
101
|
+
user and the trusted projects of the user stay. xSync then points the copy at
|
|
102
|
+
the endpoint of the profile.
|
|
103
|
+
|
|
104
|
+
The home shares the work of the user through a symbolic link:
|
|
105
|
+
|
|
106
|
+
| Harness | Shared |
|
|
107
|
+
|---|---|
|
|
108
|
+
| Codex | `skills`, `plugins`, `marketplaces`, `memories`, `AGENTS.md` |
|
|
109
|
+
| Claude Code | `agents`, `skills`, `plugins`, `hooks`, `commands`, `CLAUDE.md` |
|
|
110
|
+
|
|
111
|
+
A session, a cache, and a log stay inside the isolated home. They never mix
|
|
112
|
+
with the real home.
|
|
113
|
+
|
|
114
|
+
Delete a home at any time. The next command builds it again.
|
|
75
115
|
|
|
76
116
|
Exit codes: `0` for success, `1` for an error, `2` when the endpoint does not
|
|
77
117
|
answer.
|
|
@@ -115,20 +155,22 @@ stops therefore leaves a working picker.
|
|
|
115
155
|
|
|
116
156
|
## Switch between endpoints
|
|
117
157
|
|
|
118
|
-
|
|
158
|
+
Each profile gets a home of its own. Therefore two endpoints never mix:
|
|
119
159
|
|
|
120
|
-
xsync codex --profile openrouter
|
|
160
|
+
xsync codex --profile openrouter
|
|
161
|
+
xsync codex --profile 9router
|
|
121
162
|
|
|
122
|
-
|
|
123
|
-
|
|
163
|
+
A harness holds one model provider. Therefore `apply` writes one profile at
|
|
164
|
+
a time:
|
|
124
165
|
|
|
125
|
-
|
|
126
|
-
Codex still routes somewhere else. Every model then fails at request time.
|
|
127
|
-
xSync finds this mismatch before it writes, and it stops:
|
|
166
|
+
xsync codex apply --profile openrouter
|
|
128
167
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
168
|
+
`apply` always points the harness at the endpoint of the profile, so the
|
|
169
|
+
model list and the endpoint always agree. A dry run does not point anything
|
|
170
|
+
at anything, so it warns when the two disagree:
|
|
171
|
+
|
|
172
|
+
the rows would come from "openrouter" (https://openrouter.ai/api/v1)
|
|
173
|
+
but Claude Code talks to http://127.0.0.1:20128/v1
|
|
132
174
|
|
|
133
175
|
## The profile file
|
|
134
176
|
|
|
@@ -163,29 +205,35 @@ A glob pattern selects a model by name. The sign `*` means any characters.
|
|
|
163
205
|
| `exclude = ["*-image-*", "*-tts-*"]` | Drop the image models and the speech models. |
|
|
164
206
|
|
|
165
207
|
The `exclude` list always wins over the `include` list. Use the filters when
|
|
166
|
-
an endpoint serves many models that you never choose in
|
|
208
|
+
an endpoint serves many models that you never choose in a harness.
|
|
167
209
|
|
|
168
210
|
The match ignores the letter case.
|
|
169
211
|
|
|
170
212
|
## Safety
|
|
171
213
|
|
|
214
|
+
**The everyday commands write nothing outside `~/.config/xsync/`.** Only
|
|
215
|
+
`apply` touches `~/.codex` or `~/.claude`.
|
|
216
|
+
|
|
172
217
|
- The catalog write is atomic. xSync writes a temporary file, confirms the
|
|
173
218
|
JSON, and then replaces the target. It keeps one backup with the suffix
|
|
174
219
|
`.bak`.
|
|
175
|
-
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
220
|
+
- A harness writes its own settings file while it runs. Therefore `apply`
|
|
221
|
+
hashes the file before the edit and again before the replace. On a
|
|
222
|
+
difference it stops and writes nothing.
|
|
223
|
+
- Claude Code rewrites `settings.json` from its own memory. An `apply` that
|
|
224
|
+
runs while Claude Code is open can therefore disappear. Close Claude Code
|
|
225
|
+
first, or use `xsync claude` and change nothing.
|
|
226
|
+
- `apply --dry-run` writes nothing at all. It does not point the harness at
|
|
227
|
+
the endpoint either.
|
|
228
|
+
- `apply --reset` removes only what the state file records. It never
|
|
229
|
+
restores an old copy of the settings, because a harness adds its own
|
|
230
|
+
entries to that file over time.
|
|
231
|
+
- `apply --reset` keeps the backup `<name>.bak`. The backup is the only way
|
|
232
|
+
back after a reset.
|
|
233
|
+
- With no state file, `apply --reset` prints the keys that it would remove
|
|
234
|
+
and then stops. Add `--force` to continue. The command then asks a
|
|
235
|
+
question before it removes anything. Add `--yes` to answer the question in
|
|
236
|
+
a script.
|
|
189
237
|
- The setup hides the API key while you type it. It then shows the first
|
|
190
238
|
five characters only, and it never shows the length of the key.
|
|
191
239
|
- xSync never prints a whole API key.
|
|
@@ -202,9 +250,14 @@ keep the color in a pipe.
|
|
|
202
250
|
|---|---|---|
|
|
203
251
|
| `{base_url}/models` | yes | no |
|
|
204
252
|
| `~/.config/xsync/profiles.toml` | yes | yes |
|
|
205
|
-
| `~/.
|
|
206
|
-
| `~/.codex/config.toml` | yes | only with
|
|
207
|
-
| `~/.codex
|
|
253
|
+
| `~/.config/xsync/homes/...` | yes | yes |
|
|
254
|
+
| `~/.codex/config.toml` | yes | only with `apply` |
|
|
255
|
+
| `~/.codex/<profile>-models.json` | yes | only with `apply` |
|
|
256
|
+
| `~/.claude/settings.json` | yes | only with `apply` |
|
|
257
|
+
| `.xsync-state.json` in each home | yes | only with `apply` |
|
|
258
|
+
|
|
259
|
+
xSync copies the real settings file when it builds an isolated home. It
|
|
260
|
+
reads that file. It never writes it.
|
|
208
261
|
|
|
209
262
|
## The catalog fields
|
|
210
263
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""The builder of an isolated harness home.
|
|
2
|
+
|
|
3
|
+
The functions here write into a home that xSync owns. They read the
|
|
4
|
+
real home of the user, but they never write into it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import tomlkit
|
|
15
|
+
|
|
16
|
+
from xsync_cli.adapters.claude import load_rules as load_claude_rules
|
|
17
|
+
from xsync_cli.adapters.claude import render_rows
|
|
18
|
+
from xsync_cli.adapters.codex import load_rules as load_codex_rules
|
|
19
|
+
from xsync_cli.adapters.codex import render_catalog
|
|
20
|
+
from xsync_cli.core.atomic import write_json_atomic
|
|
21
|
+
from xsync_cli.core.homes import SHARED_CLAUDE, SHARED_CODEX, link_shared
|
|
22
|
+
from xsync_cli.core.model import Model
|
|
23
|
+
from xsync_cli.core.profiles import Profile
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def prepare_codex_home(
|
|
27
|
+
home: Path,
|
|
28
|
+
real_home: Path,
|
|
29
|
+
profile: Profile,
|
|
30
|
+
api_key: str | None,
|
|
31
|
+
models: Sequence[Model],
|
|
32
|
+
) -> Path:
|
|
33
|
+
"""Build a Codex home that talks to the endpoint of the profile.
|
|
34
|
+
|
|
35
|
+
The function copies the real `config.toml` as a base, so the trusted
|
|
36
|
+
projects of the user stay. It then points the copy at the endpoint.
|
|
37
|
+
"""
|
|
38
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
|
|
40
|
+
catalog_path = home / f"{profile.name}-models.json"
|
|
41
|
+
write_json_atomic(
|
|
42
|
+
catalog_path, render_catalog(models, load_codex_rules()), keep_backup=False
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
real_config = real_home / "config.toml"
|
|
46
|
+
document = (
|
|
47
|
+
tomlkit.parse(real_config.read_text(encoding="utf-8"))
|
|
48
|
+
if real_config.exists()
|
|
49
|
+
else tomlkit.document()
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
document["model_catalog_json"] = str(catalog_path)
|
|
53
|
+
document["model_provider"] = profile.name
|
|
54
|
+
|
|
55
|
+
providers = document.get("model_providers")
|
|
56
|
+
if providers is None:
|
|
57
|
+
providers = tomlkit.table(is_super_table=True)
|
|
58
|
+
document["model_providers"] = providers
|
|
59
|
+
|
|
60
|
+
entry = tomlkit.table()
|
|
61
|
+
entry["name"] = profile.name
|
|
62
|
+
entry["base_url"] = profile.base_url
|
|
63
|
+
entry["wire_api"] = profile.wire_api
|
|
64
|
+
if api_key:
|
|
65
|
+
headers = tomlkit.table()
|
|
66
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
67
|
+
entry["http_headers"] = headers
|
|
68
|
+
providers[profile.name] = entry
|
|
69
|
+
|
|
70
|
+
temporary = home / "config.toml.tmp"
|
|
71
|
+
temporary.write_text(tomlkit.dumps(document), encoding="utf-8")
|
|
72
|
+
os.replace(temporary, home / "config.toml")
|
|
73
|
+
|
|
74
|
+
link_shared(real_home, home, SHARED_CODEX)
|
|
75
|
+
return home
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def prepare_claude_home(
|
|
79
|
+
home: Path,
|
|
80
|
+
real_home: Path,
|
|
81
|
+
profile: Profile,
|
|
82
|
+
api_key: str | None,
|
|
83
|
+
models: Sequence[Model],
|
|
84
|
+
) -> Path:
|
|
85
|
+
"""Build a Claude Code home that talks to the endpoint.
|
|
86
|
+
|
|
87
|
+
The function copies the real `settings.json` as a base, so the hooks
|
|
88
|
+
of the user stay. It then adds the endpoint and the picker rows.
|
|
89
|
+
"""
|
|
90
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
|
|
92
|
+
real_settings = real_home / "settings.json"
|
|
93
|
+
settings: dict = {}
|
|
94
|
+
if real_settings.exists():
|
|
95
|
+
try:
|
|
96
|
+
loaded = json.loads(real_settings.read_text(encoding="utf-8"))
|
|
97
|
+
if isinstance(loaded, dict):
|
|
98
|
+
settings = loaded
|
|
99
|
+
except json.JSONDecodeError:
|
|
100
|
+
settings = {}
|
|
101
|
+
|
|
102
|
+
environment = dict(settings.get("env") or {})
|
|
103
|
+
environment["ANTHROPIC_BASE_URL"] = profile.base_url
|
|
104
|
+
if api_key:
|
|
105
|
+
environment["ANTHROPIC_AUTH_TOKEN"] = api_key
|
|
106
|
+
settings["env"] = environment
|
|
107
|
+
|
|
108
|
+
rows = render_rows(models, load_claude_rules())
|
|
109
|
+
settings["modelPicker"] = {"options": rows, "replaceBuiltInOptions": False}
|
|
110
|
+
|
|
111
|
+
temporary = home / "settings.json.tmp"
|
|
112
|
+
temporary.write_text(
|
|
113
|
+
json.dumps(settings, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
|
114
|
+
)
|
|
115
|
+
os.replace(temporary, home / "settings.json")
|
|
116
|
+
|
|
117
|
+
link_shared(real_home, home, SHARED_CLAUDE)
|
|
118
|
+
return home
|