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/__init__.py +88 -0
- caw/agent.py +578 -0
- caw/auth/README.md +118 -0
- caw/auth/__init__.py +23 -0
- caw/auth/cli.py +68 -0
- caw/auth/collector.py +324 -0
- caw/auth/linker.py +174 -0
- caw/auth/manifest.py +77 -0
- caw/auth/providers.py +433 -0
- caw/auth/status.py +241 -0
- caw/cli.py +50 -0
- caw/display.py +223 -0
- caw/faststats.py +298 -0
- caw/mcp.py +602 -0
- caw/models.py +385 -0
- caw/pricing.json +15 -0
- caw/pricing.py +33 -0
- caw/provider.py +135 -0
- caw/providers/__init__.py +0 -0
- caw/providers/claude_code.py +648 -0
- caw/providers/codex.py +564 -0
- caw/py.typed +0 -0
- caw/storage.py +184 -0
- caw/toolkit.py +198 -0
- caw/viewer/__init__.py +149 -0
- caw/viewer/static/index.html +847 -0
- coding_agent_wrapper-0.1.0.dist-info/METADATA +213 -0
- coding_agent_wrapper-0.1.0.dist-info/RECORD +31 -0
- coding_agent_wrapper-0.1.0.dist-info/WHEEL +4 -0
- coding_agent_wrapper-0.1.0.dist-info/entry_points.txt +2 -0
- coding_agent_wrapper-0.1.0.dist-info/licenses/LICENSE +202 -0
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
|
+
}
|