codelux 0.1.0a2__py3-none-any.whl
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.
- codelux/__init__.py +12 -0
- codelux/adapters/__init__.py +7 -0
- codelux/adapters/base.py +47 -0
- codelux/adapters/claude.py +245 -0
- codelux/adapters/codex.py +649 -0
- codelux/cli.py +1486 -0
- codelux/coordinator.py +99 -0
- codelux/errors.py +21 -0
- codelux/locking.py +44 -0
- codelux/models.py +202 -0
- codelux/registry.py +177 -0
- codelux/registry_io.py +34 -0
- codelux/safe_files.py +67 -0
- codelux/sessions.py +155 -0
- codelux/snapshots.py +288 -0
- codelux/sync.py +1166 -0
- codelux/sync_transport.py +264 -0
- codelux-0.1.0a2.dist-info/METADATA +175 -0
- codelux-0.1.0a2.dist-info/RECORD +22 -0
- codelux-0.1.0a2.dist-info/WHEEL +4 -0
- codelux-0.1.0a2.dist-info/entry_points.txt +3 -0
- codelux-0.1.0a2.dist-info/licenses/LICENSE +21 -0
codelux/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Codelux - Unified Provider management CLI for AI coding assistants.
|
|
2
|
+
|
|
3
|
+
Codelux provides a unified interface for managing LLM Provider credentials
|
|
4
|
+
across multiple AI coding assistants (Claude Code, Codex, and future platforms).
|
|
5
|
+
|
|
6
|
+
The public package contains product behavior only; internal development records are maintained
|
|
7
|
+
outside the distribution repository.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0a2"
|
|
11
|
+
__author__ = "Codelux AI Initiative"
|
|
12
|
+
__all__ = ["__version__"]
|
codelux/adapters/base.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Shared adapter interface."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Mapping
|
|
6
|
+
|
|
7
|
+
from codelux.models import ConfigFile, ObservedConfig, PreparedChange, ProcessState
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ClientAdapter(ABC):
|
|
11
|
+
name: str
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def is_installed(self) -> bool:
|
|
15
|
+
"""Return whether the client can be addressed on this machine."""
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def is_running(self) -> ProcessState:
|
|
19
|
+
"""Return a conservative process state."""
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def inspect(self) -> ObservedConfig:
|
|
23
|
+
"""Read actual client configuration without using registry cache."""
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def prepare_provider(self, binding: Mapping[str, object]) -> PreparedChange:
|
|
27
|
+
"""Build a change without modifying live files."""
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def prepare_snapshot_restore(self, manifest: Mapping[str, object]) -> PreparedChange:
|
|
31
|
+
"""Build a full-file restore from a validated snapshot manifest."""
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def validate_files(self, files: tuple[ConfigFile, ...]) -> None:
|
|
35
|
+
"""Parse and validate candidate files."""
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def commit(self, change: PreparedChange) -> None:
|
|
39
|
+
"""Commit a prepared change atomically for this client."""
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def rollback(self, change: PreparedChange) -> None:
|
|
43
|
+
"""Restore the before files; repeated calls must be harmless."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def config_file(path: Path, content: bytes, mode: int = 0o600) -> ConfigFile:
|
|
47
|
+
return ConfigFile(path=path, content=content, mode=mode & 0o777)
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Claude Code settings adapter."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from copy import deepcopy
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Mapping, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
from codelux.adapters.base import ClientAdapter, config_file
|
|
13
|
+
from codelux.errors import ValidationError
|
|
14
|
+
from codelux.models import ConfigFile, ConfigState, ObservedConfig, PreparedChange, ProcessState
|
|
15
|
+
from codelux.registry import Registry
|
|
16
|
+
from codelux.safe_files import atomic_write_private
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
OFFICIAL_BASE_URLS = {"https://api.anthropic.com"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ClaudeAdapter(ClientAdapter):
|
|
23
|
+
name = "claude"
|
|
24
|
+
|
|
25
|
+
def __init__(self, home: Optional[Path] = None, registry: Optional[Registry] = None) -> None:
|
|
26
|
+
self.home = (home or Path.home()).absolute()
|
|
27
|
+
self.settings_path = self.home / ".claude" / "settings.json"
|
|
28
|
+
self.config_root = self.settings_path.parent
|
|
29
|
+
self.registry = registry
|
|
30
|
+
|
|
31
|
+
def is_installed(self) -> bool:
|
|
32
|
+
return self.settings_path.exists() or shutil.which("claude") is not None
|
|
33
|
+
|
|
34
|
+
def is_running(self) -> ProcessState:
|
|
35
|
+
try:
|
|
36
|
+
result = subprocess.run(
|
|
37
|
+
["ps", "-axo", "pid=,command="],
|
|
38
|
+
check=True,
|
|
39
|
+
stdout=subprocess.PIPE,
|
|
40
|
+
stderr=subprocess.PIPE,
|
|
41
|
+
)
|
|
42
|
+
except (OSError, subprocess.SubprocessError):
|
|
43
|
+
return ProcessState.UNKNOWN
|
|
44
|
+
matches = []
|
|
45
|
+
for raw_line in result.stdout.splitlines():
|
|
46
|
+
command = raw_line.strip()
|
|
47
|
+
if not command or command.startswith(b"ps "):
|
|
48
|
+
continue
|
|
49
|
+
tokens = command.split()
|
|
50
|
+
command_tokens = tokens[1:] if tokens and tokens[0].isdigit() else tokens
|
|
51
|
+
if not command_tokens:
|
|
52
|
+
continue
|
|
53
|
+
first = command_tokens[0].rsplit(b"/", 1)[-1]
|
|
54
|
+
# Arguments may contain "claude" (for example, codelux --client
|
|
55
|
+
# claude), so only the executable token identifies the client.
|
|
56
|
+
node_launcher = first in {b"node", b"nodejs"} and any(
|
|
57
|
+
token.rsplit(b"/", 1)[-1] in {b"claude", b"claude-code"}
|
|
58
|
+
for token in command_tokens[1:]
|
|
59
|
+
)
|
|
60
|
+
if first in {b"claude", b"claude-code"} or node_launcher:
|
|
61
|
+
matches.append(command)
|
|
62
|
+
return ProcessState.RUNNING if matches else ProcessState.NOT_RUNNING
|
|
63
|
+
|
|
64
|
+
def inspect(self) -> ObservedConfig:
|
|
65
|
+
settings, raw, reasons = self._read_settings()
|
|
66
|
+
if settings is None:
|
|
67
|
+
return ObservedConfig(ConfigState.UNKNOWN, None, None, None, tuple(reasons))
|
|
68
|
+
env = settings.get("env", {})
|
|
69
|
+
if env is not None and not isinstance(env, dict):
|
|
70
|
+
return ObservedConfig(
|
|
71
|
+
ConfigState.UNKNOWN, None, None, None, tuple(reasons + ["env must be an object"])
|
|
72
|
+
)
|
|
73
|
+
env = env or {}
|
|
74
|
+
base_url = env.get("ANTHROPIC_BASE_URL")
|
|
75
|
+
token = env.get("ANTHROPIC_AUTH_TOKEN")
|
|
76
|
+
if base_url is not None and not isinstance(base_url, str):
|
|
77
|
+
return ObservedConfig(
|
|
78
|
+
ConfigState.UNKNOWN, None, None, None, ("base URL must be a string",)
|
|
79
|
+
)
|
|
80
|
+
if token is not None and not isinstance(token, str):
|
|
81
|
+
return ObservedConfig(
|
|
82
|
+
ConfigState.UNKNOWN, None, None, None, ("auth token must be a string",)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
override = self._environment_override(env, reasons)
|
|
86
|
+
if override:
|
|
87
|
+
state = ConfigState.EXTERNAL_OVERRIDE
|
|
88
|
+
reasons.extend(override)
|
|
89
|
+
elif base_url and base_url.rstrip("/") not in OFFICIAL_BASE_URLS:
|
|
90
|
+
if not token:
|
|
91
|
+
return ObservedConfig(
|
|
92
|
+
ConfigState.UNKNOWN,
|
|
93
|
+
None,
|
|
94
|
+
base_url,
|
|
95
|
+
None,
|
|
96
|
+
tuple(reasons + ["custom base URL requires an auth token"]),
|
|
97
|
+
)
|
|
98
|
+
state = ConfigState.CUSTOM
|
|
99
|
+
elif token:
|
|
100
|
+
state = ConfigState.OFFICIAL_API_KEY
|
|
101
|
+
else:
|
|
102
|
+
state = ConfigState.OFFICIAL_LOGIN
|
|
103
|
+
fingerprint = hashlib.sha256(
|
|
104
|
+
json.dumps({"base_url": base_url, "token": token}, sort_keys=True).encode()
|
|
105
|
+
).hexdigest()
|
|
106
|
+
provider_id = None
|
|
107
|
+
if state is ConfigState.CUSTOM and self.registry is not None:
|
|
108
|
+
matches = [
|
|
109
|
+
name
|
|
110
|
+
for name, provider in self.registry.providers.items()
|
|
111
|
+
if (binding := provider.clients.get("claude")) is not None
|
|
112
|
+
and binding.base_url == base_url
|
|
113
|
+
and binding.api_key == token
|
|
114
|
+
]
|
|
115
|
+
if len(matches) > 1:
|
|
116
|
+
return ObservedConfig(
|
|
117
|
+
ConfigState.UNKNOWN,
|
|
118
|
+
None,
|
|
119
|
+
base_url,
|
|
120
|
+
fingerprint,
|
|
121
|
+
tuple(reasons + ["custom Provider binding is ambiguous"]),
|
|
122
|
+
)
|
|
123
|
+
if matches:
|
|
124
|
+
provider_id = matches[0]
|
|
125
|
+
return ObservedConfig(state, provider_id, base_url, fingerprint, tuple(reasons))
|
|
126
|
+
|
|
127
|
+
def prepare_provider(self, binding: Mapping[str, object]) -> PreparedChange:
|
|
128
|
+
base_url = binding.get("base_url")
|
|
129
|
+
api_key = binding.get("api_key")
|
|
130
|
+
if not isinstance(base_url, str) or not isinstance(api_key, str) or not api_key:
|
|
131
|
+
raise ValidationError("Claude binding requires base_url and api_key")
|
|
132
|
+
settings, raw, _ = self._read_settings()
|
|
133
|
+
if settings is None:
|
|
134
|
+
settings = {}
|
|
135
|
+
raw = b"{}\n"
|
|
136
|
+
env = settings.get("env", {})
|
|
137
|
+
if env is not None and not isinstance(env, dict):
|
|
138
|
+
raise ValidationError("Claude settings env must be an object")
|
|
139
|
+
updated = deepcopy(settings)
|
|
140
|
+
updated.setdefault("env", {})
|
|
141
|
+
updated["env"]["ANTHROPIC_BASE_URL"] = base_url
|
|
142
|
+
updated["env"]["ANTHROPIC_AUTH_TOKEN"] = api_key
|
|
143
|
+
after = json.dumps(updated, ensure_ascii=True, indent=2, sort_keys=True).encode() + b"\n"
|
|
144
|
+
detected = self.inspect()
|
|
145
|
+
return PreparedChange(
|
|
146
|
+
"claude",
|
|
147
|
+
(config_file(self.settings_path, raw),),
|
|
148
|
+
(config_file(self.settings_path, after),),
|
|
149
|
+
detected,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def prepare_snapshot_restore(self, manifest: Mapping[str, object]) -> PreparedChange:
|
|
153
|
+
before = self._read_settings()[1]
|
|
154
|
+
backup = _snapshot_file(manifest, "claude/settings.json", self.home / ".codelux")
|
|
155
|
+
return PreparedChange(
|
|
156
|
+
"claude",
|
|
157
|
+
(config_file(self.settings_path, before),),
|
|
158
|
+
(config_file(self.settings_path, backup),),
|
|
159
|
+
self.inspect(),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def prepare_native_official_login(self) -> PreparedChange:
|
|
163
|
+
"""Remove only Codelux-owned routing fields before native Claude login."""
|
|
164
|
+
settings, raw, _ = self._read_settings()
|
|
165
|
+
if settings is None:
|
|
166
|
+
raise ValidationError("Claude settings are invalid")
|
|
167
|
+
env = settings.get("env", {})
|
|
168
|
+
if env is not None and not isinstance(env, dict):
|
|
169
|
+
raise ValidationError("Claude settings env must be an object")
|
|
170
|
+
updated = deepcopy(settings)
|
|
171
|
+
updated_env = updated.setdefault("env", {})
|
|
172
|
+
updated_env.pop("ANTHROPIC_BASE_URL", None)
|
|
173
|
+
updated_env.pop("ANTHROPIC_AUTH_TOKEN", None)
|
|
174
|
+
after = json.dumps(updated, ensure_ascii=True, indent=2, sort_keys=True).encode() + b"\n"
|
|
175
|
+
return PreparedChange(
|
|
176
|
+
"claude",
|
|
177
|
+
(config_file(self.settings_path, raw),),
|
|
178
|
+
(config_file(self.settings_path, after),),
|
|
179
|
+
self.inspect(),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def validate_files(self, files: Tuple[ConfigFile, ...]) -> None:
|
|
183
|
+
if len(files) != 1 or files[0].path != self.settings_path:
|
|
184
|
+
raise ValidationError("Claude change must contain settings.json only")
|
|
185
|
+
try:
|
|
186
|
+
parsed = json.loads(files[0].content)
|
|
187
|
+
except (TypeError, json.JSONDecodeError) as exc:
|
|
188
|
+
raise ValidationError("candidate Claude settings are invalid JSON") from exc
|
|
189
|
+
if not isinstance(parsed, dict):
|
|
190
|
+
raise ValidationError("Claude settings root must be an object")
|
|
191
|
+
env = parsed.get("env", {})
|
|
192
|
+
if env is not None and not isinstance(env, dict):
|
|
193
|
+
raise ValidationError("Claude settings env must be an object")
|
|
194
|
+
for key in ("ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"):
|
|
195
|
+
if key in (env or {}) and not isinstance((env or {})[key], str):
|
|
196
|
+
raise ValidationError(f"Claude {key} must be a string")
|
|
197
|
+
|
|
198
|
+
def commit(self, change: PreparedChange) -> None:
|
|
199
|
+
self.validate_files(change.after)
|
|
200
|
+
atomic_write_private(self.settings_path, change.after[0].content, self.config_root)
|
|
201
|
+
|
|
202
|
+
def rollback(self, change: PreparedChange) -> None:
|
|
203
|
+
self.validate_files(change.before)
|
|
204
|
+
atomic_write_private(self.settings_path, change.before[0].content, self.config_root)
|
|
205
|
+
|
|
206
|
+
def _read_settings(self) -> Tuple[Optional[dict], bytes, list]:
|
|
207
|
+
if not self.settings_path.exists():
|
|
208
|
+
return {}, b"{}\n", ["settings file is absent; treating as official login"]
|
|
209
|
+
try:
|
|
210
|
+
raw = self.settings_path.read_bytes()
|
|
211
|
+
parsed = json.loads(raw)
|
|
212
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
213
|
+
return None, b"", [f"settings unreadable: {type(exc).__name__}"]
|
|
214
|
+
if not isinstance(parsed, dict):
|
|
215
|
+
return None, raw, ["settings root must be an object"]
|
|
216
|
+
return parsed, raw, []
|
|
217
|
+
|
|
218
|
+
def _environment_override(self, env: Mapping[str, object], reasons: list) -> list:
|
|
219
|
+
conflicts = []
|
|
220
|
+
for key in ("ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"):
|
|
221
|
+
if key not in os.environ:
|
|
222
|
+
continue
|
|
223
|
+
value = os.environ[key]
|
|
224
|
+
if key not in env:
|
|
225
|
+
conflicts.append(f"{key} is externally set but absent from settings")
|
|
226
|
+
elif env[key] != value:
|
|
227
|
+
conflicts.append(f"{key} differs from settings")
|
|
228
|
+
else:
|
|
229
|
+
reasons.append(f"{key} externally set with matching value")
|
|
230
|
+
return conflicts
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _snapshot_file(manifest: Mapping[str, object], source_path: str, root: Path) -> bytes:
|
|
234
|
+
files = manifest.get("files")
|
|
235
|
+
if not isinstance(files, list):
|
|
236
|
+
raise ValidationError("snapshot files must be a list")
|
|
237
|
+
for item in files:
|
|
238
|
+
if not isinstance(item, dict):
|
|
239
|
+
raise ValidationError("snapshot file entry must be an object")
|
|
240
|
+
if item.get("source_path") == source_path:
|
|
241
|
+
backup = root / str(item["backup_path"])
|
|
242
|
+
if backup.is_symlink() or not backup.is_file():
|
|
243
|
+
raise ValidationError("snapshot backup is missing or unsafe")
|
|
244
|
+
return backup.read_bytes()
|
|
245
|
+
raise ValidationError("snapshot does not contain the Claude settings file")
|