coding-agent-wrapper 0.1.0__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.
caw/auth/linker.py ADDED
@@ -0,0 +1,174 @@
1
+ """Link/unlink credential files between original locations and ~/.caw/auth/."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ from rich.console import Console
9
+
10
+ from .manifest import Manifest
11
+
12
+ console = Console()
13
+
14
+ AUTH_DIR = Path.home() / ".caw" / "auth"
15
+ BACKUPS_DIR = AUTH_DIR / ".backups"
16
+
17
+
18
+ def _load_manifest(auth_dir: Path | None = None) -> tuple[Manifest, Path]:
19
+ """Load manifest from auth_dir. Returns (manifest, resolved_auth_dir)."""
20
+ resolved = auth_dir if auth_dir else AUTH_DIR
21
+ manifest_path = resolved / "manifest.json"
22
+ if not manifest_path.exists():
23
+ console.print("[red]Error: manifest.json not found. Run `caw auth setup` first.[/red]")
24
+ raise SystemExit(1)
25
+ return Manifest.load(manifest_path), resolved
26
+
27
+
28
+ def link(
29
+ agents: list[str] | None = None,
30
+ dry_run: bool = False,
31
+ force: bool = False,
32
+ auth_dir: Path | None = None,
33
+ ) -> None:
34
+ """Replace host credential files with symlinks to the auth directory.
35
+
36
+ Only links files with type=credential and strategy=symlink.
37
+ Backs up originals to <auth_dir>/.backups/ first.
38
+
39
+ Args:
40
+ agents: Agent names to link, or None for all.
41
+ dry_run: Show what would be done without making changes.
42
+ force: Overwrite existing backups.
43
+ auth_dir: Custom auth directory. Defaults to ~/.caw/auth/.
44
+ """
45
+ manifest, resolved_dir = _load_manifest(auth_dir)
46
+ backups_dir = resolved_dir / ".backups"
47
+ host_home = Path(manifest.host_home)
48
+
49
+ console.print("[bold]Linking credential files...[/bold]\n")
50
+
51
+ # Filter agents
52
+ agent_names = set(agents) if agents and "all" not in agents else set(manifest.agents.keys())
53
+
54
+ linked = 0
55
+ skipped = 0
56
+
57
+ for agent_name, agent_manifest in manifest.agents.items():
58
+ if agent_name not in agent_names:
59
+ continue
60
+
61
+ for mf in agent_manifest.files:
62
+ if mf.type != "credential" or mf.strategy != "symlink":
63
+ continue
64
+
65
+ canonical = resolved_dir / mf.src
66
+ original = host_home / mf.host_original
67
+
68
+ if not canonical.exists():
69
+ console.print(f" [yellow]Skip {mf.host_original}:[/yellow] canonical file not found at {canonical}")
70
+ skipped += 1
71
+ continue
72
+
73
+ # Check if already symlinked correctly
74
+ if original.is_symlink() and original.resolve() == canonical.resolve():
75
+ console.print(f" [dim]Already linked: {mf.host_original} -> {canonical}[/dim]")
76
+ skipped += 1
77
+ continue
78
+
79
+ if dry_run:
80
+ console.print(f" [cyan]Would link:[/cyan] {mf.host_original} -> {canonical}")
81
+ linked += 1
82
+ continue
83
+
84
+ # Backup original if it exists and is not already a symlink
85
+ if original.exists() and not original.is_symlink():
86
+ backup_path = backups_dir / mf.host_original
87
+ backup_path.parent.mkdir(parents=True, exist_ok=True)
88
+ if backup_path.exists() and not force:
89
+ console.print(
90
+ f" [yellow]Backup already exists for {mf.host_original}.[/yellow] Use --force to overwrite."
91
+ )
92
+ skipped += 1
93
+ continue
94
+ shutil.copy2(str(original), str(backup_path))
95
+ console.print(f" [dim]Backed up: {mf.host_original} -> .backups/{mf.host_original}[/dim]")
96
+
97
+ # Remove original and create symlink
98
+ if original.exists() or original.is_symlink():
99
+ original.unlink()
100
+ original.parent.mkdir(parents=True, exist_ok=True)
101
+ original.symlink_to(canonical)
102
+ console.print(f" [green]Linked:[/green] {mf.host_original} -> {canonical}")
103
+ linked += 1
104
+
105
+ action = "Would link" if dry_run else "Linked"
106
+ console.print(f"\n{action} {linked} file(s), skipped {skipped}.")
107
+
108
+
109
+ def teardown(
110
+ agents: list[str] | None = None,
111
+ dry_run: bool = False,
112
+ auth_dir: Path | None = None,
113
+ ) -> None:
114
+ """Restore original credential files from backups.
115
+
116
+ Removes symlinks and copies backups back to original locations.
117
+
118
+ Args:
119
+ agents: Agent names to restore, or None for all.
120
+ dry_run: Show what would be done without making changes.
121
+ auth_dir: Custom auth directory. Defaults to ~/.caw/auth/.
122
+ """
123
+ manifest, resolved_dir = _load_manifest(auth_dir)
124
+ backups_dir = resolved_dir / ".backups"
125
+ host_home = Path(manifest.host_home)
126
+
127
+ console.print("[bold]Unlinking credential files...[/bold]\n")
128
+
129
+ agent_names = set(agents) if agents and "all" not in agents else set(manifest.agents.keys())
130
+
131
+ restored = 0
132
+ skipped = 0
133
+
134
+ for agent_name, agent_manifest in manifest.agents.items():
135
+ if agent_name not in agent_names:
136
+ continue
137
+
138
+ for mf in agent_manifest.files:
139
+ if mf.type != "credential" or mf.strategy != "symlink":
140
+ continue
141
+
142
+ original = host_home / mf.host_original
143
+ backup_path = backups_dir / mf.host_original
144
+
145
+ # Check if it's currently a symlink pointing to our canonical
146
+ canonical = resolved_dir / mf.src
147
+ if original.is_symlink() and original.resolve() == canonical.resolve():
148
+ if backup_path.exists():
149
+ if dry_run:
150
+ console.print(f" [cyan]Would restore:[/cyan] {mf.host_original} from backup")
151
+ restored += 1
152
+ continue
153
+
154
+ original.unlink()
155
+ shutil.copy2(str(backup_path), str(original))
156
+ console.print(f" [green]Restored:[/green] {mf.host_original} from backup")
157
+ restored += 1
158
+ else:
159
+ if dry_run:
160
+ console.print(f" [cyan]Would copy canonical:[/cyan] {mf.host_original} (no backup found)")
161
+ restored += 1
162
+ continue
163
+
164
+ # No backup — copy the canonical file as a regular file
165
+ original.unlink()
166
+ shutil.copy2(str(canonical), str(original))
167
+ console.print(f" [green]Restored:[/green] {mf.host_original} from canonical (no backup)")
168
+ restored += 1
169
+ else:
170
+ console.print(f" [dim]Skip {mf.host_original}: not a symlink to our canonical[/dim]")
171
+ skipped += 1
172
+
173
+ action = "Would restore" if dry_run else "Restored"
174
+ console.print(f"\n{action} {restored} file(s), skipped {skipped}.")
caw/auth/manifest.py ADDED
@@ -0,0 +1,77 @@
1
+ """Manifest schema for ~/.caw/auth/ — describes all managed auth/config files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict, dataclass, field
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+
10
+
11
+ @dataclass
12
+ class ManifestFile:
13
+ """A single file entry in the manifest."""
14
+
15
+ src: str # relative path within ~/.caw/auth/, e.g. "claude/credentials.json"
16
+ container_target: str # relative to $HOME in container, e.g. ".claude/.credentials.json"
17
+ host_original: str # relative to $HOME on host, e.g. ".claude/.credentials.json"
18
+ type: str # "credential" or "config"
19
+ strategy: str # "symlink" or "copy"
20
+ mode: str # e.g. "0600"
21
+
22
+
23
+ @dataclass
24
+ class AgentManifest:
25
+ """Per-agent manifest."""
26
+
27
+ files: list[ManifestFile] = field(default_factory=list)
28
+
29
+
30
+ @dataclass
31
+ class Manifest:
32
+ """Top-level manifest for ~/.caw/auth/manifest.json."""
33
+
34
+ version: int = 1
35
+ created_at: str = ""
36
+ host_home: str = ""
37
+ container_home: str = "/home/playground"
38
+ mount_point: str = "/tmp/caw_auth"
39
+ agents: dict[str, AgentManifest] = field(default_factory=dict)
40
+
41
+ def to_dict(self) -> dict:
42
+ return asdict(self)
43
+
44
+ def to_json(self, indent: int = 2) -> str:
45
+ return json.dumps(self.to_dict(), indent=indent)
46
+
47
+ @classmethod
48
+ def from_dict(cls, data: dict) -> Manifest:
49
+ agents = {}
50
+ for name, agent_data in data.get("agents", {}).items():
51
+ files = [ManifestFile(**f) for f in agent_data.get("files", [])]
52
+ agents[name] = AgentManifest(files=files)
53
+ return cls(
54
+ version=data.get("version", 1),
55
+ created_at=data.get("created_at", ""),
56
+ host_home=data.get("host_home", ""),
57
+ container_home=data.get("container_home", "/home/playground"),
58
+ mount_point=data.get("mount_point", "/tmp/caw_auth"),
59
+ agents=agents,
60
+ )
61
+
62
+ @classmethod
63
+ def load(cls, path: Path) -> Manifest:
64
+ with open(path) as f:
65
+ return cls.from_dict(json.load(f))
66
+
67
+ def save(self, path: Path) -> None:
68
+ path.parent.mkdir(parents=True, exist_ok=True)
69
+ with open(path, "w") as f:
70
+ f.write(self.to_json())
71
+
72
+ @classmethod
73
+ def create(cls, host_home: str) -> Manifest:
74
+ return cls(
75
+ created_at=datetime.now(timezone.utc).isoformat(),
76
+ host_home=host_home,
77
+ )
caw/auth/providers.py ADDED
@@ -0,0 +1,433 @@
1
+ """Agent auth providers — knows where each agent stores credentials and config."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from abc import ABC, abstractmethod
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from rich.console import Console
11
+
12
+ from .manifest import ManifestFile
13
+
14
+ console = Console()
15
+
16
+ # Default container home directory
17
+ CONTAINER_HOME = "/home/playground"
18
+
19
+
20
+ @dataclass
21
+ class CollectedFile:
22
+ """A file collected from the host, ready to be written to ~/.caw/auth/."""
23
+
24
+ manifest_file: ManifestFile
25
+ content: bytes
26
+
27
+
28
+ class AgentAuthProvider(ABC):
29
+ """Base class for agent auth providers."""
30
+
31
+ name: str
32
+
33
+ @abstractmethod
34
+ def validate(self, src_home: Path) -> list[str]:
35
+ """Return list of missing required file paths (as strings)."""
36
+
37
+ @abstractmethod
38
+ def describe(self, src_home: Path) -> str:
39
+ """Return a short account info summary."""
40
+
41
+ @abstractmethod
42
+ def collect(self, src_home: Path) -> list[CollectedFile]:
43
+ """Collect cleaned auth/config files. Returns list of CollectedFile."""
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Claude
48
+ # ---------------------------------------------------------------------------
49
+
50
+ CLAUDE_JSON_KEEP_KEYS = {
51
+ "oauthAccount",
52
+ "userID",
53
+ "hasCompletedOnboarding",
54
+ "lastOnboardingVersion",
55
+ "numStartups",
56
+ "installMethod",
57
+ "firstStartTime",
58
+ "claudeCodeFirstTokenDate",
59
+ "s1mAccessCache",
60
+ "passesEligibilityCache",
61
+ "groveConfigCache",
62
+ "sonnet45MigrationComplete",
63
+ "opus45MigrationComplete",
64
+ "opusProMigrationComplete",
65
+ "thinkingMigrationComplete",
66
+ "autoUpdates",
67
+ }
68
+
69
+
70
+ def _build_clean_claude_json(source: dict) -> dict:
71
+ """Build a minimal .claude.json, keeping only essential keys."""
72
+ clean = {k: source[k] for k in CLAUDE_JSON_KEEP_KEYS if k in source}
73
+ clean["projects"] = {
74
+ CONTAINER_HOME: {
75
+ "allowedTools": [],
76
+ "mcpContextUris": [],
77
+ "mcpServers": {},
78
+ "enabledMcpjsonServers": [],
79
+ "disabledMcpjsonServers": [],
80
+ "hasTrustDialogAccepted": False,
81
+ "projectOnboardingSeenCount": 1,
82
+ "hasClaudeMdExternalIncludesApproved": False,
83
+ "hasClaudeMdExternalIncludesWarningShown": False,
84
+ "exampleFiles": [],
85
+ "lastTotalWebSearchRequests": 0,
86
+ }
87
+ }
88
+ return clean
89
+
90
+
91
+ class ClaudeAuthProvider(AgentAuthProvider):
92
+ name = "claude"
93
+
94
+ def validate(self, src_home: Path) -> list[str]:
95
+ missing = []
96
+ if not (src_home / ".claude.json").exists():
97
+ missing.append(str(src_home / ".claude.json"))
98
+ if not (src_home / ".claude" / ".credentials.json").exists():
99
+ missing.append(str(src_home / ".claude" / ".credentials.json"))
100
+ return missing
101
+
102
+ def describe(self, src_home: Path) -> str:
103
+ try:
104
+ with open(src_home / ".claude.json") as f:
105
+ cfg = json.load(f)
106
+ account = cfg.get("oauthAccount", {})
107
+ email = account.get("emailAddress", "unknown")
108
+ org = account.get("organizationName", "unknown")
109
+
110
+ with open(src_home / ".claude" / ".credentials.json") as f:
111
+ creds = json.load(f)
112
+ expires_at = creds.get("claudeAiOauth", {}).get("expiresAt")
113
+ parts = [f"Account: {email}", f"Org: {org}"]
114
+ if expires_at:
115
+ from datetime import datetime, timezone
116
+
117
+ dt = datetime.fromtimestamp(expires_at / 1000, tz=timezone.utc)
118
+ parts.append(f"Token expires: {dt.isoformat()}")
119
+ return ", ".join(parts)
120
+ except Exception:
121
+ return "Could not read account info"
122
+
123
+ def collect(self, src_home: Path) -> list[CollectedFile]:
124
+ # credentials.json — credential, symlinked for token refresh write-back
125
+ with open(src_home / ".claude" / ".credentials.json") as f:
126
+ credentials = json.load(f)
127
+
128
+ cred_file = CollectedFile(
129
+ manifest_file=ManifestFile(
130
+ src="claude/credentials.json",
131
+ container_target=".claude/.credentials.json",
132
+ host_original=".claude/.credentials.json",
133
+ type="credential",
134
+ strategy="symlink",
135
+ mode="0600",
136
+ ),
137
+ content=json.dumps(credentials).encode(),
138
+ )
139
+
140
+ # config.json — cleaned .claude.json for containers, copied (not symlinked)
141
+ with open(src_home / ".claude.json") as f:
142
+ local_config = json.load(f)
143
+
144
+ clean_config = _build_clean_claude_json(local_config)
145
+ original_keys = len(local_config)
146
+ clean_keys = len(clean_config)
147
+ original_projects = len(local_config.get("projects", {}))
148
+ console.print(f" [dim]Stripped .claude.json: {original_keys} keys -> {clean_keys} keys[/dim]")
149
+ console.print(f" [dim]Stripped projects: {original_projects} entries -> 1 entry[/dim]")
150
+
151
+ config_file = CollectedFile(
152
+ manifest_file=ManifestFile(
153
+ src="claude/config.json",
154
+ container_target=".claude.json",
155
+ host_original=".claude.json",
156
+ type="config",
157
+ strategy="copy",
158
+ mode="0644",
159
+ ),
160
+ content=json.dumps(clean_config, indent=2).encode(),
161
+ )
162
+
163
+ return [cred_file, config_file]
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # Codex
168
+ # ---------------------------------------------------------------------------
169
+
170
+
171
+ def _build_clean_codex_config(source_toml: str) -> str:
172
+ """Build a minimal config.toml for codex, stripping local project trust."""
173
+ lines: list[str] = []
174
+ skip_section = False
175
+
176
+ for line in source_toml.splitlines():
177
+ stripped = line.strip()
178
+ if stripped.startswith("["):
179
+ if stripped.startswith("[project_trust."):
180
+ skip_section = True
181
+ continue
182
+ skip_section = False
183
+ if skip_section:
184
+ continue
185
+ lines.append(line)
186
+
187
+ lines.append("")
188
+ lines.append(f'[project_trust."{CONTAINER_HOME}"]')
189
+ lines.append('trust_mode = "full"')
190
+ lines.append("")
191
+
192
+ return "\n".join(lines)
193
+
194
+
195
+ class CodexAuthProvider(AgentAuthProvider):
196
+ name = "codex"
197
+
198
+ def validate(self, src_home: Path) -> list[str]:
199
+ missing = []
200
+ if not (src_home / ".codex" / "auth.json").exists():
201
+ missing.append(str(src_home / ".codex" / "auth.json"))
202
+ return missing
203
+
204
+ def describe(self, src_home: Path) -> str:
205
+ try:
206
+ with open(src_home / ".codex" / "auth.json") as f:
207
+ auth_data = json.load(f)
208
+ has_token = bool(auth_data.get("tokens") or auth_data.get("token") or auth_data.get("access_token"))
209
+ has_api_key = bool(auth_data.get("OPENAI_API_KEY"))
210
+ parts = []
211
+ if has_api_key:
212
+ parts.append("API key present")
213
+ if has_token:
214
+ parts.append("OAuth tokens present")
215
+ return ", ".join(parts) if parts else "Auth file found (no recognized keys)"
216
+ except Exception:
217
+ return "Could not read auth info"
218
+
219
+ def collect(self, src_home: Path) -> list[CollectedFile]:
220
+ files: list[CollectedFile] = []
221
+
222
+ # auth.json — credential, symlinked
223
+ with open(src_home / ".codex" / "auth.json", "rb") as f:
224
+ auth_content = f.read()
225
+ files.append(
226
+ CollectedFile(
227
+ manifest_file=ManifestFile(
228
+ src="codex/auth.json",
229
+ container_target=".codex/auth.json",
230
+ host_original=".codex/auth.json",
231
+ type="credential",
232
+ strategy="symlink",
233
+ mode="0600",
234
+ ),
235
+ content=auth_content,
236
+ )
237
+ )
238
+
239
+ # config.toml — config, copied (cleaned)
240
+ config_path = src_home / ".codex" / "config.toml"
241
+ if config_path.exists():
242
+ config_text = config_path.read_text()
243
+ clean_config = _build_clean_codex_config(config_text)
244
+ files.append(
245
+ CollectedFile(
246
+ manifest_file=ManifestFile(
247
+ src="codex/config.toml",
248
+ container_target=".codex/config.toml",
249
+ host_original=".codex/config.toml",
250
+ type="config",
251
+ strategy="copy",
252
+ mode="0644",
253
+ ),
254
+ content=clean_config.encode(),
255
+ )
256
+ )
257
+ console.print(" [dim]Stripped config.toml: local project trust -> container trust only[/dim]")
258
+
259
+ return files
260
+
261
+
262
+ # ---------------------------------------------------------------------------
263
+ # Gemini
264
+ # ---------------------------------------------------------------------------
265
+
266
+
267
+ class GeminiAuthProvider(AgentAuthProvider):
268
+ name = "gemini"
269
+
270
+ _CREDENTIAL_FILES = ["oauth_creds.json"]
271
+ _CONFIG_FILES = ["google_accounts.json", "settings.json", "installation_id"]
272
+
273
+ def validate(self, src_home: Path) -> list[str]:
274
+ creds = src_home / ".gemini" / "oauth_creds.json"
275
+ if not creds.exists():
276
+ return [str(creds)]
277
+ return []
278
+
279
+ def describe(self, src_home: Path) -> str:
280
+ try:
281
+ accounts_path = src_home / ".gemini" / "google_accounts.json"
282
+ if accounts_path.exists():
283
+ with open(accounts_path) as f:
284
+ accounts = json.load(f)
285
+ if isinstance(accounts, list) and accounts:
286
+ email = accounts[0].get("email", "unknown")
287
+ return f"Account: {email}"
288
+ return "Credentials present"
289
+ except Exception:
290
+ return "Could not read account info"
291
+
292
+ def collect(self, src_home: Path) -> list[CollectedFile]:
293
+ files: list[CollectedFile] = []
294
+ gemini_dir = src_home / ".gemini"
295
+
296
+ # Credential files — symlinked
297
+ for filename in self._CREDENTIAL_FILES:
298
+ path = gemini_dir / filename
299
+ if path.exists():
300
+ files.append(
301
+ CollectedFile(
302
+ manifest_file=ManifestFile(
303
+ src=f"gemini/{filename}",
304
+ container_target=f".gemini/{filename}",
305
+ host_original=f".gemini/{filename}",
306
+ type="credential",
307
+ strategy="symlink",
308
+ mode="0600",
309
+ ),
310
+ content=path.read_bytes(),
311
+ )
312
+ )
313
+
314
+ # Config files — copied
315
+ for filename in self._CONFIG_FILES:
316
+ path = gemini_dir / filename
317
+ if path.exists():
318
+ files.append(
319
+ CollectedFile(
320
+ manifest_file=ManifestFile(
321
+ src=f"gemini/{filename}",
322
+ container_target=f".gemini/{filename}",
323
+ host_original=f".gemini/{filename}",
324
+ type="config",
325
+ strategy="copy",
326
+ mode="0600",
327
+ ),
328
+ content=path.read_bytes(),
329
+ )
330
+ )
331
+
332
+ found = [f.manifest_file.src.split("/")[-1] for f in files]
333
+ console.print(f" [dim]Collected {len(found)} files: {', '.join(found)}[/dim]")
334
+ return files
335
+
336
+
337
+ # ---------------------------------------------------------------------------
338
+ # Cursor
339
+ # ---------------------------------------------------------------------------
340
+
341
+ CURSOR_CLI_CONFIG_KEEP_KEYS = {
342
+ "authInfo",
343
+ "permissions",
344
+ "model",
345
+ "approvalMode",
346
+ "sandbox",
347
+ }
348
+
349
+
350
+ class CursorAuthProvider(AgentAuthProvider):
351
+ name = "cursor"
352
+
353
+ def validate(self, src_home: Path) -> list[str]:
354
+ missing = []
355
+ auth_json = src_home / ".config" / "cursor" / "auth.json"
356
+ cli_config = src_home / ".cursor" / "cli-config.json"
357
+ if not auth_json.exists() and not cli_config.exists():
358
+ missing.append(str(auth_json))
359
+ missing.append(str(cli_config))
360
+ return missing
361
+
362
+ def describe(self, src_home: Path) -> str:
363
+ try:
364
+ auth_path = src_home / ".config" / "cursor" / "auth.json"
365
+ if auth_path.exists():
366
+ with open(auth_path) as f:
367
+ auth_data = json.load(f)
368
+ email = auth_data.get("email", auth_data.get("user", "unknown"))
369
+ return f"Account: {email}"
370
+ return "Credentials present"
371
+ except Exception:
372
+ return "Could not read account info"
373
+
374
+ def collect(self, src_home: Path) -> list[CollectedFile]:
375
+ files: list[CollectedFile] = []
376
+
377
+ # .config/cursor/auth.json — credential, symlinked
378
+ auth_path = src_home / ".config" / "cursor" / "auth.json"
379
+ if auth_path.exists():
380
+ files.append(
381
+ CollectedFile(
382
+ manifest_file=ManifestFile(
383
+ src="cursor/auth.json",
384
+ container_target=".config/cursor/auth.json",
385
+ host_original=".config/cursor/auth.json",
386
+ type="credential",
387
+ strategy="symlink",
388
+ mode="0600",
389
+ ),
390
+ content=auth_path.read_bytes(),
391
+ )
392
+ )
393
+
394
+ # .cursor/cli-config.json — config, copied (cleaned)
395
+ cli_config_path = src_home / ".cursor" / "cli-config.json"
396
+ if cli_config_path.exists():
397
+ with open(cli_config_path) as f:
398
+ full_config = json.load(f)
399
+ clean = {k: full_config[k] for k in CURSOR_CLI_CONFIG_KEEP_KEYS if k in full_config}
400
+ stripped = len(full_config) - len(clean)
401
+ console.print(f" [dim]Stripped cli-config.json: removed {stripped} keys, kept {len(clean)}[/dim]")
402
+ files.append(
403
+ CollectedFile(
404
+ manifest_file=ManifestFile(
405
+ src="cursor/cli-config.json",
406
+ container_target=".cursor/cli-config.json",
407
+ host_original=".cursor/cli-config.json",
408
+ type="config",
409
+ strategy="copy",
410
+ mode="0600",
411
+ ),
412
+ content=json.dumps(clean, indent=2).encode(),
413
+ )
414
+ )
415
+
416
+ found = [f.manifest_file.src for f in files]
417
+ console.print(f" [dim]Files: {', '.join(found)}[/dim]")
418
+ return files
419
+
420
+
421
+ # ---------------------------------------------------------------------------
422
+ # Provider registry
423
+ # ---------------------------------------------------------------------------
424
+
425
+ PROVIDERS: dict[str, AgentAuthProvider] = {
426
+ p.name: p
427
+ for p in [
428
+ ClaudeAuthProvider(),
429
+ CodexAuthProvider(),
430
+ GeminiAuthProvider(),
431
+ CursorAuthProvider(),
432
+ ]
433
+ }