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.
Files changed (62) hide show
  1. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/.gitignore +3 -0
  2. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/PKG-INFO +108 -55
  3. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/README.md +107 -54
  4. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/pyproject.toml +1 -1
  5. xsync_cli-0.2.1/src/xsync_cli/adapters/isolated.py +118 -0
  6. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/cli.py +260 -48
  7. xsync_cli-0.2.1/src/xsync_cli/core/homes.py +108 -0
  8. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_cli_claude.py +24 -15
  9. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_cli_codex.py +29 -22
  10. xsync_cli-0.2.1/tests/test_cli_launch.py +144 -0
  11. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_cli_profiles.py +31 -0
  12. xsync_cli-0.2.1/tests/test_homes.py +131 -0
  13. xsync_cli-0.2.1/tests/test_isolated.py +149 -0
  14. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/uv.lock +1 -1
  15. xsync_cli-0.1.4/docs/superpowers/plans/2026-09-13-xsync-cli.md +0 -3144
  16. xsync_cli-0.1.4/docs/superpowers/specs/2026-09-13-xsync-design.md +0 -499
  17. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/LICENSE +0 -0
  18. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/docs/verification-2026-09-13.md +0 -0
  19. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/__init__.py +0 -0
  20. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/__init__.py +0 -0
  21. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/claude.py +0 -0
  22. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/claude_config.py +0 -0
  23. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/codex.py +0 -0
  24. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/codex_config.py +0 -0
  25. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/codex_wiring.py +0 -0
  26. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/prompts/__init__.py +0 -0
  27. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/prompts/generic-codex.md +0 -0
  28. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/prompts/generic-codex.messages.json +0 -0
  29. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/prompts/gpt-5.4.md +0 -0
  30. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/prompts/gpt-5.4.messages.json +0 -0
  31. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/prompts/gpt-5.5.md +0 -0
  32. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/prompts/gpt-5.5.messages.json +0 -0
  33. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/rules/__init__.py +0 -0
  34. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/rules/claude.toml +0 -0
  35. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/adapters/rules/codex.toml +0 -0
  36. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/core/__init__.py +0 -0
  37. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/core/atomic.py +0 -0
  38. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/core/diff.py +0 -0
  39. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/core/filters.py +0 -0
  40. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/core/model.py +0 -0
  41. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/core/profiles.py +0 -0
  42. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/core/term.py +0 -0
  43. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/sources/__init__.py +0 -0
  44. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/src/xsync_cli/sources/openai_compat.py +0 -0
  45. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/__init__.py +0 -0
  46. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/fixtures/__init__.py +0 -0
  47. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/fixtures/ninerouter_models.json +0 -0
  48. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/fixtures/plain_openai_models.json +0 -0
  49. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_atomic.py +0 -0
  50. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_claude_config.py +0 -0
  51. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_claude_render.py +0 -0
  52. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_codex_config.py +0 -0
  53. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_codex_render.py +0 -0
  54. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_codex_wiring.py +0 -0
  55. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_diff.py +0 -0
  56. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_filters.py +0 -0
  57. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_model.py +0 -0
  58. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_openai_compat.py +0 -0
  59. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_package_data.py +0 -0
  60. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_profiles.py +0 -0
  61. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tests/test_term.py +0 -0
  62. {xsync_cli-0.1.4 → xsync_cli-0.2.1}/tools/extract_prompts.py +0 -0
@@ -1,3 +1,6 @@
1
+ # Superpowers working documents
2
+ docs/superpowers/
3
+
1
4
  # Secrets
2
5
  .env
3
6
  .env.*
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: xsync-cli
3
- Version: 0.1.4
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
- Sync the model list of an OpenAI-compatible endpoint into the Codex model
16
- catalog.
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
- Codex reads its model list from a large JSON catalog file. A person must
19
- write that file by hand. The file becomes wrong when the endpoint adds a
20
- model or drops a model. `xsync` writes the file for you.
18
+ ```
19
+ $ xsync claude
21
20
 
22
- xsync codex
23
- wrote 35 models to /Users/you/.codex/9router-models.json
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. Connect Codex to the endpoint
67
+ ### 2. Open a harness
54
68
 
55
- xsync codex --init
69
+ xsync codex
70
+ xsync claude
56
71
 
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.
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
- ### 3. Sync
75
+ Arguments after `--` go to the harness:
62
76
 
63
- xsync codex
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
- Run this command again when the endpoint changes.
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
- Codex holds one model provider. Therefore one profile is active at a time.
170
+ Each profile gets a home of its own. Therefore two endpoints never mix:
131
171
 
132
- xsync codex --profile openrouter --init
172
+ xsync codex --profile openrouter
173
+ xsync codex --profile 9router
133
174
 
134
- This command points Codex at the other endpoint and fills the catalog in one
135
- step.
175
+ A harness holds one model provider. Therefore `apply` writes one profile at
176
+ a time:
136
177
 
137
- `xsync codex --profile X` without `--init` fills the catalog from X while
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
- catalog would come from "openrouter" (https://openrouter.ai/api/v1)
142
- but Codex routes to "9router" (http://127.0.0.1:20128/v1)
143
- run: xsync codex --profile openrouter --init
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 Codex.
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
- - The everyday `xsync codex` command never opens `config.toml` for writing.
188
- Only `--init` and `--reset` do.
189
- - Codex writes `config.toml` while it runs. Therefore `--init` and `--reset`
190
- hash the file before the edit and again before the replace. On a
191
- difference they stop and write nothing.
192
- - `--reset` removes only what the state file `~/.codex/.xsync-state.json`
193
- records. It never restores an old copy of `config.toml`, because Codex
194
- adds project entries to that file over time.
195
- - `--reset` keeps the catalog backup `<name>.bak`. The backup is the only
196
- way back after a reset.
197
- - With no state file, `--reset` prints the keys that it would remove and
198
- then stops. Add `--force` to continue. The command then asks a question
199
- before it removes anything. Add `--yes` to answer the question in a
200
- script.
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
- | `~/.codex/<profile>-models.json` | yes | yes |
218
- | `~/.codex/config.toml` | yes | only with `--init` or `--reset` |
219
- | `~/.codex/.xsync-state.json` | yes | only with `--init` or `--reset` |
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
- Sync the model list of an OpenAI-compatible endpoint into the Codex model
4
- catalog.
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
- Codex reads its model list from a large JSON catalog file. A person must
7
- write that file by hand. The file becomes wrong when the endpoint adds a
8
- model or drops a model. `xsync` writes the file for you.
6
+ ```
7
+ $ xsync claude
9
8
 
10
- xsync codex
11
- wrote 35 models to /Users/you/.codex/9router-models.json
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. Connect Codex to the endpoint
55
+ ### 2. Open a harness
42
56
 
43
- xsync codex --init
57
+ xsync codex
58
+ xsync claude
44
59
 
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.
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
- ### 3. Sync
63
+ Arguments after `--` go to the harness:
50
64
 
51
- xsync codex
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
- Run this command again when the endpoint changes.
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
- Codex holds one model provider. Therefore one profile is active at a time.
158
+ Each profile gets a home of its own. Therefore two endpoints never mix:
119
159
 
120
- xsync codex --profile openrouter --init
160
+ xsync codex --profile openrouter
161
+ xsync codex --profile 9router
121
162
 
122
- This command points Codex at the other endpoint and fills the catalog in one
123
- step.
163
+ A harness holds one model provider. Therefore `apply` writes one profile at
164
+ a time:
124
165
 
125
- `xsync codex --profile X` without `--init` fills the catalog from X while
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
- catalog would come from "openrouter" (https://openrouter.ai/api/v1)
130
- but Codex routes to "9router" (http://127.0.0.1:20128/v1)
131
- run: xsync codex --profile openrouter --init
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 Codex.
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
- - The everyday `xsync codex` command never opens `config.toml` for writing.
176
- Only `--init` and `--reset` do.
177
- - Codex writes `config.toml` while it runs. Therefore `--init` and `--reset`
178
- hash the file before the edit and again before the replace. On a
179
- difference they stop and write nothing.
180
- - `--reset` removes only what the state file `~/.codex/.xsync-state.json`
181
- records. It never restores an old copy of `config.toml`, because Codex
182
- adds project entries to that file over time.
183
- - `--reset` keeps the catalog backup `<name>.bak`. The backup is the only
184
- way back after a reset.
185
- - With no state file, `--reset` prints the keys that it would remove and
186
- then stops. Add `--force` to continue. The command then asks a question
187
- before it removes anything. Add `--yes` to answer the question in a
188
- script.
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
- | `~/.codex/<profile>-models.json` | yes | yes |
206
- | `~/.codex/config.toml` | yes | only with `--init` or `--reset` |
207
- | `~/.codex/.xsync-state.json` | yes | only with `--init` or `--reset` |
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
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "xsync-cli"
7
- version = "0.1.4"
7
+ version = "0.2.1"
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,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