xsync-cli 0.2.0__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.2.0 → xsync_cli-0.2.1}/.gitignore +3 -0
  2. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/PKG-INFO +63 -36
  3. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/README.md +62 -35
  4. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/pyproject.toml +1 -1
  5. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/src/xsync_cli/cli.py +96 -23
  6. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/tests/test_cli_profiles.py +31 -0
  7. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/uv.lock +1 -1
  8. xsync_cli-0.2.0/docs/superpowers/plans/2026-09-13-xsync-cli.md +0 -3144
  9. xsync_cli-0.2.0/docs/superpowers/specs/2026-09-13-xsync-design.md +0 -499
  10. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/LICENSE +0 -0
  11. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/docs/verification-2026-09-13.md +0 -0
  12. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/src/xsync_cli/__init__.py +0 -0
  13. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/src/xsync_cli/adapters/__init__.py +0 -0
  14. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/src/xsync_cli/adapters/claude.py +0 -0
  15. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/src/xsync_cli/adapters/claude_config.py +0 -0
  16. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/src/xsync_cli/adapters/codex.py +0 -0
  17. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/src/xsync_cli/adapters/codex_config.py +0 -0
  18. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/src/xsync_cli/adapters/codex_wiring.py +0 -0
  19. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/src/xsync_cli/adapters/isolated.py +0 -0
  20. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/src/xsync_cli/adapters/prompts/__init__.py +0 -0
  21. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/src/xsync_cli/adapters/prompts/generic-codex.md +0 -0
  22. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/src/xsync_cli/adapters/prompts/generic-codex.messages.json +0 -0
  23. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/src/xsync_cli/adapters/prompts/gpt-5.4.md +0 -0
  24. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/src/xsync_cli/adapters/prompts/gpt-5.4.messages.json +0 -0
  25. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/src/xsync_cli/adapters/prompts/gpt-5.5.md +0 -0
  26. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/src/xsync_cli/adapters/prompts/gpt-5.5.messages.json +0 -0
  27. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/src/xsync_cli/adapters/rules/__init__.py +0 -0
  28. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/src/xsync_cli/adapters/rules/claude.toml +0 -0
  29. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/src/xsync_cli/adapters/rules/codex.toml +0 -0
  30. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/src/xsync_cli/core/__init__.py +0 -0
  31. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/src/xsync_cli/core/atomic.py +0 -0
  32. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/src/xsync_cli/core/diff.py +0 -0
  33. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/src/xsync_cli/core/filters.py +0 -0
  34. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/src/xsync_cli/core/homes.py +0 -0
  35. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/src/xsync_cli/core/model.py +0 -0
  36. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/src/xsync_cli/core/profiles.py +0 -0
  37. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/src/xsync_cli/core/term.py +0 -0
  38. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/src/xsync_cli/sources/__init__.py +0 -0
  39. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/src/xsync_cli/sources/openai_compat.py +0 -0
  40. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/tests/__init__.py +0 -0
  41. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/tests/fixtures/__init__.py +0 -0
  42. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/tests/fixtures/ninerouter_models.json +0 -0
  43. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/tests/fixtures/plain_openai_models.json +0 -0
  44. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/tests/test_atomic.py +0 -0
  45. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/tests/test_claude_config.py +0 -0
  46. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/tests/test_claude_render.py +0 -0
  47. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/tests/test_cli_claude.py +0 -0
  48. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/tests/test_cli_codex.py +0 -0
  49. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/tests/test_cli_launch.py +0 -0
  50. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/tests/test_codex_config.py +0 -0
  51. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/tests/test_codex_render.py +0 -0
  52. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/tests/test_codex_wiring.py +0 -0
  53. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/tests/test_diff.py +0 -0
  54. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/tests/test_filters.py +0 -0
  55. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/tests/test_homes.py +0 -0
  56. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/tests/test_isolated.py +0 -0
  57. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/tests/test_model.py +0 -0
  58. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/tests/test_openai_compat.py +0 -0
  59. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/tests/test_package_data.py +0 -0
  60. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/tests/test_profiles.py +0 -0
  61. {xsync_cli-0.2.0 → xsync_cli-0.2.1}/tests/test_term.py +0 -0
  62. {xsync_cli-0.2.0 → 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.2.0
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
 
@@ -153,20 +167,22 @@ stops therefore leaves a working picker.
153
167
 
154
168
  ## Switch between endpoints
155
169
 
156
- 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:
171
+
172
+ xsync codex --profile openrouter
173
+ xsync codex --profile 9router
157
174
 
158
- xsync codex --profile openrouter --init
175
+ A harness holds one model provider. Therefore `apply` writes one profile at
176
+ a time:
159
177
 
160
- This command points Codex at the other endpoint and fills the catalog in one
161
- step.
178
+ xsync codex apply --profile openrouter
162
179
 
163
- `xsync codex --profile X` without `--init` fills the catalog from X while
164
- Codex still routes somewhere else. Every model then fails at request time.
165
- xSync finds this mismatch before it writes, and it stops:
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:
166
183
 
167
- catalog would come from "openrouter" (https://openrouter.ai/api/v1)
168
- but Codex routes to "9router" (http://127.0.0.1:20128/v1)
169
- run: xsync codex --profile openrouter --init
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
170
186
 
171
187
  ## The profile file
172
188
 
@@ -201,29 +217,35 @@ A glob pattern selects a model by name. The sign `*` means any characters.
201
217
  | `exclude = ["*-image-*", "*-tts-*"]` | Drop the image models and the speech models. |
202
218
 
203
219
  The `exclude` list always wins over the `include` list. Use the filters when
204
- an endpoint serves many models that you never choose in Codex.
220
+ an endpoint serves many models that you never choose in a harness.
205
221
 
206
222
  The match ignores the letter case.
207
223
 
208
224
  ## Safety
209
225
 
226
+ **The everyday commands write nothing outside `~/.config/xsync/`.** Only
227
+ `apply` touches `~/.codex` or `~/.claude`.
228
+
210
229
  - The catalog write is atomic. xSync writes a temporary file, confirms the
211
230
  JSON, and then replaces the target. It keeps one backup with the suffix
212
231
  `.bak`.
213
- - The everyday `xsync codex` command never opens `config.toml` for writing.
214
- Only `--init` and `--reset` do.
215
- - Codex writes `config.toml` while it runs. Therefore `--init` and `--reset`
216
- hash the file before the edit and again before the replace. On a
217
- difference they stop and write nothing.
218
- - `--reset` removes only what the state file `~/.codex/.xsync-state.json`
219
- records. It never restores an old copy of `config.toml`, because Codex
220
- adds project entries to that file over time.
221
- - `--reset` keeps the catalog backup `<name>.bak`. The backup is the only
222
- way back after a reset.
223
- - With no state file, `--reset` prints the keys that it would remove and
224
- then stops. Add `--force` to continue. The command then asks a question
225
- before it removes anything. Add `--yes` to answer the question in a
226
- 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.
227
249
  - The setup hides the API key while you type it. It then shows the first
228
250
  five characters only, and it never shows the length of the key.
229
251
  - xSync never prints a whole API key.
@@ -240,9 +262,14 @@ keep the color in a pipe.
240
262
  |---|---|---|
241
263
  | `{base_url}/models` | yes | no |
242
264
  | `~/.config/xsync/profiles.toml` | yes | yes |
243
- | `~/.codex/<profile>-models.json` | yes | yes |
244
- | `~/.codex/config.toml` | yes | only with `--init` or `--reset` |
245
- | `~/.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.
246
273
 
247
274
  ## The catalog fields
248
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
 
@@ -141,20 +155,22 @@ stops therefore leaves a working picker.
141
155
 
142
156
  ## Switch between endpoints
143
157
 
144
- 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:
159
+
160
+ xsync codex --profile openrouter
161
+ xsync codex --profile 9router
145
162
 
146
- xsync codex --profile openrouter --init
163
+ A harness holds one model provider. Therefore `apply` writes one profile at
164
+ a time:
147
165
 
148
- This command points Codex at the other endpoint and fills the catalog in one
149
- step.
166
+ xsync codex apply --profile openrouter
150
167
 
151
- `xsync codex --profile X` without `--init` fills the catalog from X while
152
- Codex still routes somewhere else. Every model then fails at request time.
153
- xSync finds this mismatch before it writes, and it stops:
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:
154
171
 
155
- catalog would come from "openrouter" (https://openrouter.ai/api/v1)
156
- but Codex routes to "9router" (http://127.0.0.1:20128/v1)
157
- run: xsync codex --profile openrouter --init
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
158
174
 
159
175
  ## The profile file
160
176
 
@@ -189,29 +205,35 @@ A glob pattern selects a model by name. The sign `*` means any characters.
189
205
  | `exclude = ["*-image-*", "*-tts-*"]` | Drop the image models and the speech models. |
190
206
 
191
207
  The `exclude` list always wins over the `include` list. Use the filters when
192
- an endpoint serves many models that you never choose in Codex.
208
+ an endpoint serves many models that you never choose in a harness.
193
209
 
194
210
  The match ignores the letter case.
195
211
 
196
212
  ## Safety
197
213
 
214
+ **The everyday commands write nothing outside `~/.config/xsync/`.** Only
215
+ `apply` touches `~/.codex` or `~/.claude`.
216
+
198
217
  - The catalog write is atomic. xSync writes a temporary file, confirms the
199
218
  JSON, and then replaces the target. It keeps one backup with the suffix
200
219
  `.bak`.
201
- - The everyday `xsync codex` command never opens `config.toml` for writing.
202
- Only `--init` and `--reset` do.
203
- - Codex writes `config.toml` while it runs. Therefore `--init` and `--reset`
204
- hash the file before the edit and again before the replace. On a
205
- difference they stop and write nothing.
206
- - `--reset` removes only what the state file `~/.codex/.xsync-state.json`
207
- records. It never restores an old copy of `config.toml`, because Codex
208
- adds project entries to that file over time.
209
- - `--reset` keeps the catalog backup `<name>.bak`. The backup is the only
210
- way back after a reset.
211
- - With no state file, `--reset` prints the keys that it would remove and
212
- then stops. Add `--force` to continue. The command then asks a question
213
- before it removes anything. Add `--yes` to answer the question in a
214
- 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.
215
237
  - The setup hides the API key while you type it. It then shows the first
216
238
  five characters only, and it never shows the length of the key.
217
239
  - xSync never prints a whole API key.
@@ -228,9 +250,14 @@ keep the color in a pipe.
228
250
  |---|---|---|
229
251
  | `{base_url}/models` | yes | no |
230
252
  | `~/.config/xsync/profiles.toml` | yes | yes |
231
- | `~/.codex/<profile>-models.json` | yes | yes |
232
- | `~/.codex/config.toml` | yes | only with `--init` or `--reset` |
233
- | `~/.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.
234
261
 
235
262
  ## The catalog fields
236
263
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "xsync-cli"
7
- version = "0.2.0"
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"
@@ -488,36 +488,111 @@ def _print_report(report, profile: Profile, total: int) -> None:
488
488
  print(" " + " ".join(parts))
489
489
 
490
490
 
491
- EPILOG = """\
492
- examples:
493
- xsync setup make a profile, and read the model list
494
- xsync list show the profiles. The active one has a star
495
- xsync use openrouter set the active profile
491
+ HELP_GROUPS = (
492
+ (
493
+ "PROFILES",
494
+ (
495
+ ("setup", "make a profile, and read the model list"),
496
+ ("list", "show the profiles. The active one has a dot"),
497
+ ("use <name>", "set the active profile"),
498
+ ("remove <name>", "delete a profile"),
499
+ ),
500
+ ),
501
+ (
502
+ "HARNESSES",
503
+ (
504
+ ("codex", "open a Codex on the profile, in its own home"),
505
+ ("claude", "open a Claude Code the same way"),
506
+ ("<harness> apply", "write the real harness of the user"),
507
+ ),
508
+ ),
509
+ (
510
+ "HELP",
511
+ (
512
+ ("help", "show this page"),
513
+ ("help <command>", "show the options of one command"),
514
+ ),
515
+ ),
516
+ )
517
+
518
+ HELP_OPTIONS = (
519
+ ("--profile <name>", "use another profile for one run"),
520
+ ("-- <args>", "send the arguments after -- to the harness"),
521
+ ("apply --dry-run", "show the difference. Write nothing"),
522
+ ("apply --reset", "remove everything that xsync wrote"),
523
+ )
524
+
525
+ HELP_FILES = (
526
+ ("~/.config/xsync/profiles.toml", "the profiles"),
527
+ ("~/.config/xsync/homes/", "one home for each profile and harness"),
528
+ ("~/.codex, ~/.claude", "only `apply` writes these"),
529
+ )
530
+
531
+ HELP_EXIT = (
532
+ ("0", "success"),
533
+ ("1", "an error"),
534
+ ("2", "the endpoint does not answer"),
535
+ )
496
536
 
497
- xsync codex open a Codex on the profile, in its own home
498
- xsync claude open a Claude Code the same way
499
- xsync claude -- --model a/b arguments after -- go to the harness
500
537
 
501
- xsync codex apply write the real Codex of the user
502
- xsync claude apply write the real Claude Code of the user
503
- xsync codex apply --dry-run show the difference. Write nothing
504
- xsync codex apply --reset remove everything that xsync wrote
538
+ def _version() -> str:
539
+ """The installed version of the tool."""
540
+ try:
541
+ from importlib.metadata import version
542
+
543
+ return version("xsync-cli")
544
+ except Exception: # noqa: BLE001
545
+ return "unknown"
546
+
547
+
548
+ def render_help() -> str:
549
+ """The help page of the tool."""
550
+ width = 30
551
+ lines: list[str] = []
552
+
553
+ lines.append("")
554
+ lines.append(f" {term.bold(term.cyan('xsync'))} {term.dim('v' + _version())}")
555
+ lines.append(
556
+ term.dim(
557
+ " Sync the models of an OpenAI-compatible endpoint into a harness."
558
+ )
559
+ )
560
+ lines.append("")
561
+
562
+ for title, rows in HELP_GROUPS:
563
+ lines.append(f" {term.bold(title)}")
564
+ for name, text in rows:
565
+ lines.append(f" {term.pad(term.green(name), width)}{term.dim(text)}")
566
+ lines.append("")
567
+
568
+ lines.append(f" {term.bold('OPTIONS')}")
569
+ for name, text in HELP_OPTIONS:
570
+ lines.append(f" {term.pad(term.yellow(name), width)}{term.dim(text)}")
571
+ lines.append("")
572
+
573
+ lines.append(f" {term.bold('FILES')}")
574
+ for name, text in HELP_FILES:
575
+ lines.append(f" {term.pad(name, width)}{term.dim(text)}")
576
+ lines.append("")
577
+
578
+ lines.append(f" {term.bold('EXIT CODES')}")
579
+ codes = " ".join(
580
+ f"{term.bold(code)} {term.dim(text)}" for code, text in HELP_EXIT
581
+ )
582
+ lines.append(f" {codes}")
583
+ lines.append("")
505
584
 
506
- files:
507
- ~/.config/xsync/profiles.toml the profiles. `xsync setup` writes this file
508
- ~/.config/xsync/homes/ one home for each profile and each harness
509
- ~/.codex, ~/.claude only `apply` writes these
585
+ lines.append(f" {term.dim('start here:')} {term.bold('xsync setup')}")
586
+ lines.append("")
587
+ return "\n".join(lines)
510
588
 
511
- exit codes:
512
- 0 success 1 error 2 the endpoint does not answer
513
- """
514
589
 
515
590
 
516
591
  def cmd_help(args: argparse.Namespace) -> int:
517
592
  """Show the help of the tool, or the help of one command."""
518
593
  parser = build_parser()
519
594
  if not args.topic:
520
- parser.print_help()
595
+ print(render_help())
521
596
  return EXIT_OK
522
597
 
523
598
  actions = [
@@ -841,8 +916,6 @@ def build_parser() -> argparse.ArgumentParser:
841
916
  parser = argparse.ArgumentParser(
842
917
  prog="xsync",
843
918
  description="Sync the model list of an OpenAI-compatible endpoint into a harness.",
844
- epilog=EPILOG,
845
- formatter_class=argparse.RawDescriptionHelpFormatter,
846
919
  )
847
920
  sub = parser.add_subparsers(dest="command", metavar="command")
848
921
 
@@ -906,7 +979,7 @@ def main(argv: list[str] | None = None) -> int:
906
979
  args = parser.parse_args(mine)
907
980
  args.extra = theirs
908
981
  if not getattr(args, "command", None):
909
- parser.print_help()
982
+ print(render_help())
910
983
  return EXIT_OK
911
984
  try:
912
985
  return args.func(args)
@@ -194,3 +194,34 @@ def test_the_help_command_can_explain_one_command(capsys):
194
194
  def test_the_help_of_an_unknown_command_fails(capsys):
195
195
  assert cli.main(["help", "nope"]) == 1
196
196
  assert "nope" in capsys.readouterr().err
197
+
198
+
199
+ def test_the_help_groups_the_commands(capsys):
200
+ cli.main([])
201
+ out = capsys.readouterr().out
202
+ for group in ("PROFILES", "HARNESSES", "FILES"):
203
+ assert group in out
204
+
205
+
206
+ def test_the_help_names_the_version(capsys):
207
+ from importlib.metadata import version
208
+
209
+ cli.main([])
210
+ assert version("xsync-cli") in capsys.readouterr().out
211
+
212
+
213
+ def test_the_help_shows_every_command(capsys):
214
+ cli.main([])
215
+ out = capsys.readouterr().out
216
+ for name in ("setup", "list", "use", "remove", "codex", "claude", "help"):
217
+ assert name in out
218
+
219
+
220
+ def test_the_help_explains_apply(capsys):
221
+ cli.main([])
222
+ assert "apply" in capsys.readouterr().out
223
+
224
+
225
+ def test_the_help_holds_no_escape_code_in_a_pipe(capsys):
226
+ cli.main([])
227
+ assert "\x1b[" not in capsys.readouterr().out
@@ -74,7 +74,7 @@ wheels = [
74
74
 
75
75
  [[package]]
76
76
  name = "xsync-cli"
77
- version = "0.1.4"
77
+ version = "0.2.0"
78
78
  source = { editable = "." }
79
79
  dependencies = [
80
80
  { name = "tomlkit" },