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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/PKG-INFO +46 -20
  2. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/README.md +45 -19
  3. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/pyproject.toml +1 -1
  4. xsync_cli-0.2.0/src/xsync_cli/adapters/isolated.py +118 -0
  5. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/cli.py +173 -34
  6. xsync_cli-0.2.0/src/xsync_cli/core/homes.py +108 -0
  7. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_cli_claude.py +24 -15
  8. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_cli_codex.py +29 -22
  9. xsync_cli-0.2.0/tests/test_cli_launch.py +144 -0
  10. xsync_cli-0.2.0/tests/test_homes.py +131 -0
  11. xsync_cli-0.2.0/tests/test_isolated.py +149 -0
  12. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/uv.lock +1 -1
  13. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/.gitignore +0 -0
  14. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/LICENSE +0 -0
  15. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/docs/superpowers/plans/2026-09-13-xsync-cli.md +0 -0
  16. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/docs/superpowers/specs/2026-09-13-xsync-design.md +0 -0
  17. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/docs/verification-2026-09-13.md +0 -0
  18. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/__init__.py +0 -0
  19. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/__init__.py +0 -0
  20. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/claude.py +0 -0
  21. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/claude_config.py +0 -0
  22. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/codex.py +0 -0
  23. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/codex_config.py +0 -0
  24. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/codex_wiring.py +0 -0
  25. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/prompts/__init__.py +0 -0
  26. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/prompts/generic-codex.md +0 -0
  27. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/prompts/generic-codex.messages.json +0 -0
  28. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/prompts/gpt-5.4.md +0 -0
  29. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/prompts/gpt-5.4.messages.json +0 -0
  30. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/prompts/gpt-5.5.md +0 -0
  31. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/prompts/gpt-5.5.messages.json +0 -0
  32. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/rules/__init__.py +0 -0
  33. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/rules/claude.toml +0 -0
  34. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/rules/codex.toml +0 -0
  35. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/core/__init__.py +0 -0
  36. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/core/atomic.py +0 -0
  37. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/core/diff.py +0 -0
  38. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/core/filters.py +0 -0
  39. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/core/model.py +0 -0
  40. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/core/profiles.py +0 -0
  41. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/core/term.py +0 -0
  42. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/sources/__init__.py +0 -0
  43. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/sources/openai_compat.py +0 -0
  44. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/__init__.py +0 -0
  45. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/fixtures/__init__.py +0 -0
  46. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/fixtures/ninerouter_models.json +0 -0
  47. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/fixtures/plain_openai_models.json +0 -0
  48. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_atomic.py +0 -0
  49. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_claude_config.py +0 -0
  50. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_claude_render.py +0 -0
  51. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_cli_profiles.py +0 -0
  52. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_codex_config.py +0 -0
  53. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_codex_render.py +0 -0
  54. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_codex_wiring.py +0 -0
  55. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_diff.py +0 -0
  56. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_filters.py +0 -0
  57. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_model.py +0 -0
  58. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_openai_compat.py +0 -0
  59. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_package_data.py +0 -0
  60. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_profiles.py +0 -0
  61. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_term.py +0 -0
  62. {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tools/extract_prompts.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: xsync-cli
3
- Version: 0.1.4
3
+ Version: 0.2.0
4
4
  Summary: Sync the model list of an OpenAI-compatible endpoint into the Codex model catalog.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -50,19 +50,25 @@ The last question asks for the wire API:
50
50
  A wrong value breaks every request. Ask the operator of the endpoint when
51
51
  you do not know.
52
52
 
53
- ### 2. Connect Codex to the endpoint
53
+ ### 2. Open a harness
54
54
 
55
- xsync codex --init
55
+ xsync codex
56
+ xsync claude
56
57
 
57
- The command adds `[model_providers.<profile>]` to `~/.codex/config.toml` and
58
- points `model_catalog_json` at the catalog of the profile. Run this one time
59
- for each endpoint.
58
+ The command builds a home for the profile, then starts the harness in that
59
+ home. The real Codex and the real Claude Code of the user stay as they are.
60
60
 
61
- ### 3. Sync
61
+ Arguments after `--` go to the harness:
62
62
 
63
- xsync codex
63
+ xsync claude -- --model cmc/deepseek/deepseek-v4-pro
64
+
65
+ ### 3. Or write the real harness
64
66
 
65
- Run this command again when the endpoint changes.
67
+ xsync codex apply
68
+ xsync claude apply
69
+
70
+ The `apply` verb changes the real home of the user. Read **Safety** before
71
+ you use it.
66
72
 
67
73
  ## Commands
68
74
 
@@ -72,18 +78,38 @@ Run this command again when the endpoint changes.
72
78
  | `xsync list` | Show the profiles. The active profile has a star. |
73
79
  | `xsync use <name>` | Set the active profile. |
74
80
  | `xsync remove <name>` | Delete a profile. |
75
- | `xsync codex` | Sync the active profile into the Codex catalog. |
76
- | `xsync codex --dry-run` | Show the difference. Write nothing. |
77
- | `xsync codex --profile <name>` | Use another profile for one run. |
78
- | `xsync codex --init` | Connect Codex to the endpoint of the profile. |
79
- | `xsync codex --reset` | Remove everything that xSync wrote. |
80
- | `xsync codex --reset --force` | Reset when no state file exists. It asks first. |
81
- | `xsync codex --reset --force --yes` | Reset with no question. Use this in a script. |
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
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.
87
113
 
88
114
  Exit codes: `0` for success, `1` for an error, `2` when the endpoint does not
89
115
  answer.
@@ -38,19 +38,25 @@ The last question asks for the wire API:
38
38
  A wrong value breaks every request. Ask the operator of the endpoint when
39
39
  you do not know.
40
40
 
41
- ### 2. Connect Codex to the endpoint
41
+ ### 2. Open a harness
42
42
 
43
- xsync codex --init
43
+ xsync codex
44
+ xsync claude
44
45
 
45
- The command adds `[model_providers.<profile>]` to `~/.codex/config.toml` and
46
- points `model_catalog_json` at the catalog of the profile. Run this one time
47
- for each endpoint.
46
+ The command builds a home for the profile, then starts the harness in that
47
+ home. The real Codex and the real Claude Code of the user stay as they are.
48
48
 
49
- ### 3. Sync
49
+ Arguments after `--` go to the harness:
50
50
 
51
- xsync codex
51
+ xsync claude -- --model cmc/deepseek/deepseek-v4-pro
52
+
53
+ ### 3. Or write the real harness
52
54
 
53
- Run this command again when the endpoint changes.
55
+ xsync codex apply
56
+ xsync claude apply
57
+
58
+ The `apply` verb changes the real home of the user. Read **Safety** before
59
+ you use it.
54
60
 
55
61
  ## Commands
56
62
 
@@ -60,18 +66,38 @@ Run this command again when the endpoint changes.
60
66
  | `xsync list` | Show the profiles. The active profile has a star. |
61
67
  | `xsync use <name>` | Set the active profile. |
62
68
  | `xsync remove <name>` | Delete a profile. |
63
- | `xsync codex` | Sync the active profile into the Codex catalog. |
64
- | `xsync codex --dry-run` | Show the difference. Write nothing. |
65
- | `xsync codex --profile <name>` | Use another profile for one run. |
66
- | `xsync codex --init` | Connect Codex to the endpoint of the profile. |
67
- | `xsync codex --reset` | Remove everything that xSync wrote. |
68
- | `xsync codex --reset --force` | Reset when no state file exists. It asks first. |
69
- | `xsync codex --reset --force --yes` | Reset with no question. Use this in a script. |
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
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.
75
101
 
76
102
  Exit codes: `0` for success, `1` for an error, `2` when the endpoint does not
77
103
  answer.
@@ -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.0"
8
8
  description = "Sync the model list of an OpenAI-compatible endpoint into the Codex model catalog."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,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
@@ -10,7 +10,9 @@ from getpass import getpass
10
10
  from pathlib import Path
11
11
 
12
12
  from xsync_cli.adapters.codex import load_rules, render_catalog
13
+ from xsync_cli.adapters.isolated import prepare_claude_home, prepare_codex_home
13
14
  from xsync_cli.core import term
15
+ from xsync_cli.core.homes import HomeError, home_for, launch
14
16
  from xsync_cli.adapters.claude import load_rules as load_claude_rules
15
17
  from xsync_cli.adapters.claude import render_rows
16
18
  from xsync_cli.adapters import claude_config
@@ -382,8 +384,8 @@ def _do_reset(
382
384
  return EXIT_OK
383
385
 
384
386
 
385
- def cmd_codex(args: argparse.Namespace) -> int:
386
- """Sync a profile into the Codex catalog."""
387
+ def _codex_apply(args: argparse.Namespace) -> int:
388
+ """Write the real Codex home."""
387
389
  store = open_store()
388
390
  codex_home = default_codex_home()
389
391
  config_path = codex_home / "config.toml"
@@ -491,15 +493,20 @@ examples:
491
493
  xsync setup make a profile, and read the model list
492
494
  xsync list show the profiles. The active one has a star
493
495
  xsync use openrouter set the active profile
494
- xsync codex --init connect Codex to the endpoint of the profile
495
- xsync codex --dry-run show the difference. Write nothing
496
- xsync codex sync the models into the Codex catalog
497
- xsync codex --reset remove everything that xsync wrote
496
+
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
+
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
498
505
 
499
506
  files:
500
507
  ~/.config/xsync/profiles.toml the profiles. `xsync setup` writes this file
501
- ~/.codex/<profile>-models.json the Codex catalog that `xsync codex` writes
502
- ~/.codex/config.toml only `--init` and `--reset` write this file
508
+ ~/.config/xsync/homes/ one home for each profile and each harness
509
+ ~/.codex, ~/.claude only `apply` writes these
503
510
 
504
511
  exit codes:
505
512
  0 success 1 error 2 the endpoint does not answer
@@ -569,8 +576,8 @@ def _claude_reset(claude_home: Path, profile_name: str, force: bool, yes: bool)
569
576
  return EXIT_OK
570
577
 
571
578
 
572
- def cmd_claude(args: argparse.Namespace) -> int:
573
- """Sync a profile into the Claude Code model picker."""
579
+ def _claude_apply(args: argparse.Namespace) -> int:
580
+ """Write the real Claude Code home."""
574
581
  store = open_store()
575
582
  claude_home = default_claude_home()
576
583
  settings_file = settings_path_for(claude_home)
@@ -706,6 +713,130 @@ def _print_claude_report(rows, old_rows, profile: Profile) -> None:
706
713
  print(term.dim(f" {native} models keep their native Claude handling"))
707
714
 
708
715
 
716
+ def _resolve_profile(args: argparse.Namespace):
717
+ """The profile of this run, or None after an error."""
718
+ store = open_store()
719
+ try:
720
+ return store.get(args.profile) if args.profile else store.active_profile()
721
+ except ProfileError as error:
722
+ _fail(str(error))
723
+ return None
724
+
725
+
726
+ def _models_for(profile) -> tuple[list | None, int]:
727
+ """The models of the endpoint, after the filters."""
728
+ try:
729
+ api_key = profile.resolve_key(os.environ)
730
+ models = fetch_models(profile.base_url, api_key)
731
+ except EndpointUnreachable as error:
732
+ _fail(str(error))
733
+ return None, EXIT_UNREACHABLE
734
+ except (SourceError, ProfileError) as error:
735
+ _fail(str(error))
736
+ return None, EXIT_ERROR
737
+ return apply_filters(models, profile.include, profile.exclude), EXIT_OK
738
+
739
+
740
+ def _extra_args(args: argparse.Namespace) -> list[str]:
741
+ """The arguments that go to the harness."""
742
+ extra = list(getattr(args, "extra", None) or [])
743
+ return extra[1:] if extra and extra[0] == "--" else extra
744
+
745
+
746
+ def _open_harness(args: argparse.Namespace, harness: str) -> int:
747
+ """Build the isolated home, then start the harness in it."""
748
+ profile = _resolve_profile(args)
749
+ if profile is None:
750
+ return EXIT_ERROR
751
+
752
+ models, code = _models_for(profile)
753
+ if models is None:
754
+ return code
755
+
756
+ try:
757
+ home = home_for(profile.name, harness)
758
+ except HomeError as error:
759
+ _fail(str(error))
760
+ return EXIT_ERROR
761
+
762
+ try:
763
+ api_key = profile.resolve_key(os.environ)
764
+ except ProfileError as error:
765
+ _fail(str(error))
766
+ return EXIT_ERROR
767
+
768
+ if harness == "codex":
769
+ prepare_codex_home(home, default_codex_home(), profile, api_key, models)
770
+ command, variable = "codex", "CODEX_HOME"
771
+ else:
772
+ prepare_claude_home(home, default_claude_home(), profile, api_key, models)
773
+ command, variable = "claude", "CLAUDE_CONFIG_DIR"
774
+
775
+ print(term.heading(f"{command} · {profile.name}"))
776
+ print(term.dim(f" {profile.base_url}"))
777
+ print(term.dim(f" home: {home}"))
778
+ _ok(f"{term.bold(str(len(models)))} models ready. Your real {command} is untouched.")
779
+ print()
780
+
781
+ try:
782
+ launch(command, variable, str(home), _extra_args(args))
783
+ except HomeError as error:
784
+ _fail(str(error))
785
+ return EXIT_ERROR
786
+ return EXIT_OK
787
+
788
+
789
+ def cmd_codex(args: argparse.Namespace) -> int:
790
+ """Open an isolated Codex, or write the real one."""
791
+ if args.action == "apply":
792
+ # `apply` writes the real home. It therefore wires the endpoint.
793
+ # A dry run writes nothing, so it never wires anything.
794
+ if not args.reset and not args.dry_run:
795
+ args.init = True
796
+ return _codex_apply(args)
797
+ return _open_harness(args, "codex")
798
+
799
+
800
+ def cmd_claude(args: argparse.Namespace) -> int:
801
+ """Open an isolated Claude Code, or write the real one."""
802
+ if args.action == "apply":
803
+ # `apply` writes the real home. It therefore wires the endpoint.
804
+ # A dry run writes nothing, so it never wires anything.
805
+ if not args.reset and not args.dry_run:
806
+ args.init = True
807
+ return _claude_apply(args)
808
+ return _open_harness(args, "claude")
809
+
810
+
811
+ def _add_harness_arguments(parser: argparse.ArgumentParser) -> None:
812
+ """The arguments that both harness commands share."""
813
+ parser.add_argument(
814
+ "action",
815
+ nargs="?",
816
+ choices=["apply"],
817
+ help="apply writes the real home of the harness",
818
+ )
819
+ parser.add_argument("--profile", help="use this profile for one run")
820
+ parser.add_argument(
821
+ "--dry-run", action="store_true", help="apply: show the difference only"
822
+ )
823
+ parser.add_argument(
824
+ "--init",
825
+ action="store_true",
826
+ help="apply: point the harness at the endpoint (implied by apply)",
827
+ )
828
+ parser.add_argument(
829
+ "--reset", action="store_true", help="apply: remove everything xsync wrote"
830
+ )
831
+ parser.add_argument(
832
+ "--force", action="store_true", help="apply: reset without a state file"
833
+ )
834
+ parser.add_argument(
835
+ "--yes", action="store_true", help="apply: answer yes to the reset question"
836
+ )
837
+ parser.set_defaults(extra=[])
838
+
839
+
709
840
  def build_parser() -> argparse.ArgumentParser:
710
841
  parser = argparse.ArgumentParser(
711
842
  prog="xsync",
@@ -726,34 +857,28 @@ def build_parser() -> argparse.ArgumentParser:
726
857
  remove.add_argument("name")
727
858
  remove.set_defaults(func=cmd_remove)
728
859
 
729
- codex = sub.add_parser("codex", help="sync a profile into the Codex catalog")
730
- codex.add_argument("--profile", help="use this profile for one run")
731
- codex.add_argument("--dry-run", action="store_true", help="show the difference only")
732
- codex.add_argument("--init", action="store_true", help="wire Codex to the endpoint")
733
- codex.add_argument(
734
- "--reset", action="store_true", help="remove everything xSync wrote"
735
- )
736
- codex.add_argument("--force", action="store_true", help="reset without a state file")
737
- codex.add_argument(
738
- "--yes", action="store_true", help="answer yes to the reset question"
860
+ codex = sub.add_parser(
861
+ "codex",
862
+ help="open an isolated Codex on the active profile",
863
+ description=(
864
+ "Open a Codex that talks to the endpoint of the profile. The real "
865
+ "Codex of the user stays as it is. Add `apply` to write the real "
866
+ "Codex instead."
867
+ ),
739
868
  )
869
+ _add_harness_arguments(codex)
740
870
  codex.set_defaults(func=cmd_codex)
741
871
 
742
872
  claude = sub.add_parser(
743
- "claude", help="sync a profile into the Claude Code model picker"
744
- )
745
- claude.add_argument("--profile", help="use this profile for one run")
746
- claude.add_argument("--dry-run", action="store_true", help="show the difference only")
747
- claude.add_argument(
748
- "--init", action="store_true", help="point Claude Code at the endpoint"
749
- )
750
- claude.add_argument(
751
- "--reset", action="store_true", help="remove everything xsync wrote"
752
- )
753
- claude.add_argument("--force", action="store_true", help="reset without a state file")
754
- claude.add_argument(
755
- "--yes", action="store_true", help="answer yes to the reset question"
873
+ "claude",
874
+ help="open an isolated Claude Code on the active profile",
875
+ description=(
876
+ "Open a Claude Code that talks to the endpoint of the profile. The "
877
+ "real Claude Code of the user stays as it is. Add `apply` to write "
878
+ "the real Claude Code instead."
879
+ ),
756
880
  )
881
+ _add_harness_arguments(claude)
757
882
  claude.set_defaults(func=cmd_claude)
758
883
 
759
884
  help_command = sub.add_parser("help", help="show this help, or the help of a command")
@@ -763,9 +888,23 @@ def build_parser() -> argparse.ArgumentParser:
763
888
  return parser
764
889
 
765
890
 
891
+ def _split_extra(argv: list[str]) -> tuple[list[str], list[str]]:
892
+ """Cut the argument list at the first `--`.
893
+
894
+ argparse reads the first part. The harness gets the second part.
895
+ """
896
+ if "--" not in argv:
897
+ return argv, []
898
+ cut = argv.index("--")
899
+ return argv[:cut], argv[cut + 1 :]
900
+
901
+
766
902
  def main(argv: list[str] | None = None) -> int:
903
+ raw = list(sys.argv[1:] if argv is None else argv)
904
+ mine, theirs = _split_extra(raw)
767
905
  parser = build_parser()
768
- args = parser.parse_args(argv)
906
+ args = parser.parse_args(mine)
907
+ args.extra = theirs
769
908
  if not getattr(args, "command", None):
770
909
  parser.print_help()
771
910
  return EXIT_OK
@@ -0,0 +1,108 @@
1
+ """The isolated home of one harness.
2
+
3
+ A harness keeps its settings in a home directory. xSync makes one home
4
+ for each profile and each harness. The harness then runs against the
5
+ endpoint of the profile, and the real home of the user stays as it is.
6
+
7
+ The home holds its own settings and its own sessions. It links the
8
+ folders that hold the work of the user, such as the skills and the
9
+ plugins, so those stay in one place.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import shutil
16
+ from collections.abc import Sequence
17
+ from pathlib import Path
18
+ from typing import Callable
19
+
20
+ # The folders and the files that both homes share. A session folder, a
21
+ # cache, and a log never appear here. Those hold state of one run.
22
+ SHARED_CODEX = ("skills", "plugins", "marketplaces", "memories", "AGENTS.md")
23
+ SHARED_CLAUDE = ("agents", "skills", "plugins", "hooks", "commands", "CLAUDE.md")
24
+
25
+
26
+ class HomeError(Exception):
27
+ """The home cannot be made, or the harness cannot start."""
28
+
29
+
30
+ def homes_root() -> Path:
31
+ """The folder that holds every isolated home."""
32
+ from xsync_cli.core.profiles import default_store_path
33
+
34
+ override = os.environ.get("XSYNC_PROFILES")
35
+ base = Path(override).parent if override else default_store_path().parent
36
+ return base / "homes"
37
+
38
+
39
+ def home_for(profile_name: str, harness: str) -> Path:
40
+ """The home of one profile and one harness."""
41
+ if not profile_name or "/" in profile_name or profile_name in (".", ".."):
42
+ raise HomeError(
43
+ f"the profile name {profile_name!r} cannot become a folder name."
44
+ )
45
+ return homes_root() / profile_name / harness
46
+
47
+
48
+ def link_shared(
49
+ source_home: Path, target_home: Path, names: Sequence[str]
50
+ ) -> list[str]:
51
+ """Share the work of the user with the isolated home.
52
+
53
+ A folder becomes a symbolic link. A file becomes a copy, because a
54
+ harness writes a file back. The function never replaces a real
55
+ folder in the target.
56
+ """
57
+ shared: list[str] = []
58
+ for name in names:
59
+ source = source_home / name
60
+ if not source.exists():
61
+ continue
62
+ target = target_home / name
63
+
64
+ if source.is_dir():
65
+ if target.is_symlink():
66
+ if target.resolve() == source.resolve():
67
+ shared.append(name)
68
+ continue
69
+ target.unlink()
70
+ elif target.exists():
71
+ continue
72
+ target.symlink_to(source, target_is_directory=True)
73
+ shared.append(name)
74
+ else:
75
+ if target.exists() and not target.is_symlink():
76
+ continue
77
+ if target.is_symlink():
78
+ target.unlink()
79
+ shutil.copy2(source, target)
80
+ shared.append(name)
81
+ return shared
82
+
83
+
84
+ def _default_exec(command: str, argv: list[str], env: dict[str, str]) -> None:
85
+ os.execvpe(command, argv, env) # noqa: S606
86
+
87
+
88
+ def launch(
89
+ command: str,
90
+ home_variable: str,
91
+ home: str,
92
+ extra_args: Sequence[str],
93
+ exec_fn: Callable[[str, list[str], dict[str, str]], None] | None = None,
94
+ ) -> None:
95
+ """Start the harness against the isolated home.
96
+
97
+ The function replaces the xsync process. It does not return.
98
+ """
99
+ env = dict(os.environ)
100
+ env[home_variable] = str(home)
101
+ argv = [command, *extra_args]
102
+ runner = exec_fn or _default_exec
103
+ try:
104
+ runner(command, argv, env)
105
+ except FileNotFoundError:
106
+ raise HomeError(
107
+ f"the command {command!r} is not found. Install it, or add it to PATH."
108
+ ) from None