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 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__"]
@@ -0,0 +1,7 @@
1
+ """Client adapters."""
2
+
3
+ from codelux.adapters.base import ClientAdapter
4
+ from codelux.adapters.claude import ClaudeAdapter
5
+ from codelux.adapters.codex import CodexAdapter
6
+
7
+ __all__ = ["ClientAdapter", "ClaudeAdapter", "CodexAdapter"]
@@ -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")