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.
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/PKG-INFO +46 -20
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/README.md +45 -19
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/pyproject.toml +1 -1
- xsync_cli-0.2.0/src/xsync_cli/adapters/isolated.py +118 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/cli.py +173 -34
- xsync_cli-0.2.0/src/xsync_cli/core/homes.py +108 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_cli_claude.py +24 -15
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_cli_codex.py +29 -22
- xsync_cli-0.2.0/tests/test_cli_launch.py +144 -0
- xsync_cli-0.2.0/tests/test_homes.py +131 -0
- xsync_cli-0.2.0/tests/test_isolated.py +149 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/uv.lock +1 -1
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/.gitignore +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/LICENSE +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/docs/superpowers/plans/2026-09-13-xsync-cli.md +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/docs/superpowers/specs/2026-09-13-xsync-design.md +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/docs/verification-2026-09-13.md +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/__init__.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/__init__.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/claude.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/claude_config.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/codex.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/codex_config.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/codex_wiring.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/prompts/__init__.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/prompts/generic-codex.md +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/prompts/generic-codex.messages.json +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/prompts/gpt-5.4.md +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/prompts/gpt-5.4.messages.json +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/prompts/gpt-5.5.md +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/prompts/gpt-5.5.messages.json +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/rules/__init__.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/rules/claude.toml +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/adapters/rules/codex.toml +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/core/__init__.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/core/atomic.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/core/diff.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/core/filters.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/core/model.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/core/profiles.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/core/term.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/sources/__init__.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/src/xsync_cli/sources/openai_compat.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/__init__.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/fixtures/__init__.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/fixtures/ninerouter_models.json +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/fixtures/plain_openai_models.json +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_atomic.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_claude_config.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_claude_render.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_cli_profiles.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_codex_config.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_codex_render.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_codex_wiring.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_diff.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_filters.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_model.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_openai_compat.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_package_data.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_profiles.py +0 -0
- {xsync_cli-0.1.4 → xsync_cli-0.2.0}/tests/test_term.py +0 -0
- {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.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Sync the model list of an OpenAI-compatible endpoint into the Codex model catalog.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -50,19 +50,25 @@ The last question asks for the wire API:
|
|
|
50
50
|
A wrong value breaks every request. Ask the operator of the endpoint when
|
|
51
51
|
you do not know.
|
|
52
52
|
|
|
53
|
-
### 2.
|
|
53
|
+
### 2. Open a harness
|
|
54
54
|
|
|
55
|
-
xsync codex
|
|
55
|
+
xsync codex
|
|
56
|
+
xsync claude
|
|
56
57
|
|
|
57
|
-
The command
|
|
58
|
-
|
|
59
|
-
for each endpoint.
|
|
58
|
+
The command builds a home for the profile, then starts the harness in that
|
|
59
|
+
home. The real Codex and the real Claude Code of the user stay as they are.
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
Arguments after `--` go to the harness:
|
|
62
62
|
|
|
63
|
-
xsync
|
|
63
|
+
xsync claude -- --model cmc/deepseek/deepseek-v4-pro
|
|
64
|
+
|
|
65
|
+
### 3. Or write the real harness
|
|
64
66
|
|
|
65
|
-
|
|
67
|
+
xsync codex apply
|
|
68
|
+
xsync claude apply
|
|
69
|
+
|
|
70
|
+
The `apply` verb changes the real home of the user. Read **Safety** before
|
|
71
|
+
you use it.
|
|
66
72
|
|
|
67
73
|
## Commands
|
|
68
74
|
|
|
@@ -72,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.
|
|
41
|
+
### 2. Open a harness
|
|
42
42
|
|
|
43
|
-
xsync codex
|
|
43
|
+
xsync codex
|
|
44
|
+
xsync claude
|
|
44
45
|
|
|
45
|
-
The command
|
|
46
|
-
|
|
47
|
-
for each endpoint.
|
|
46
|
+
The command builds a home for the profile, then starts the harness in that
|
|
47
|
+
home. The real Codex and the real Claude Code of the user stay as they are.
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
Arguments after `--` go to the harness:
|
|
50
50
|
|
|
51
|
-
xsync
|
|
51
|
+
xsync claude -- --model cmc/deepseek/deepseek-v4-pro
|
|
52
|
+
|
|
53
|
+
### 3. Or write the real harness
|
|
52
54
|
|
|
53
|
-
|
|
55
|
+
xsync codex apply
|
|
56
|
+
xsync claude apply
|
|
57
|
+
|
|
58
|
+
The `apply` verb changes the real home of the user. Read **Safety** before
|
|
59
|
+
you use it.
|
|
54
60
|
|
|
55
61
|
## Commands
|
|
56
62
|
|
|
@@ -60,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.
|
|
@@ -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
|
|
386
|
-
"""
|
|
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
|
-
|
|
495
|
-
xsync codex
|
|
496
|
-
xsync
|
|
497
|
-
xsync
|
|
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
|
-
~/.
|
|
502
|
-
~/.codex
|
|
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
|
|
573
|
-
"""
|
|
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(
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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",
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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(
|
|
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
|