heyecco 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.
ecco_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Deterministic Ecco client setup; never a model or an auth server."""
ecco_cli/bootstrap.py ADDED
@@ -0,0 +1,282 @@
1
+ """Signup-free board bootstrap with caller-owned, private recovery state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import re
9
+ import secrets
10
+ import subprocess
11
+ import tempfile
12
+ import urllib.error
13
+ import urllib.parse
14
+ import urllib.request
15
+ from pathlib import Path
16
+ from typing import Any
17
+ from uuid import uuid4
18
+
19
+ CONTRACT = "ecco.signup-free.v1"
20
+ DEFAULT_API = "https://api.heyecco.com"
21
+ MAX_RESPONSE = 262_144
22
+
23
+
24
+ class BootstrapFailure(ValueError):
25
+ pass
26
+
27
+
28
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
29
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
30
+ return None
31
+
32
+
33
+ _OPENER = urllib.request.build_opener(_NoRedirect())
34
+
35
+
36
+ def _sha(value: str) -> str:
37
+ return hashlib.sha256(value.encode()).hexdigest()
38
+
39
+
40
+ def _state_root() -> Path:
41
+ if os.name == "nt":
42
+ base = os.environ.get("LOCALAPPDATA")
43
+ if not base:
44
+ raise BootstrapFailure("LOCALAPPDATA is required for private state.")
45
+ return Path(base) / "ecco" / "boards"
46
+ base = os.environ.get("XDG_STATE_HOME")
47
+ root = Path(base) if base else Path.home() / ".local" / "state"
48
+ return root / "ecco" / "boards"
49
+
50
+
51
+ def _private_directory(path: Path) -> None:
52
+ path.mkdir(mode=0o700, parents=True, exist_ok=True)
53
+ if path.is_symlink() or not path.is_dir():
54
+ raise BootstrapFailure("State directory must be a real directory.")
55
+ if os.name != "nt":
56
+ status = path.stat()
57
+ if status.st_uid != os.getuid() or status.st_mode & 0o077:
58
+ raise BootstrapFailure("State directory must be private (mode 0700).")
59
+
60
+
61
+ def _write_private(path: Path, data: bytes) -> None:
62
+ _private_directory(path.parent)
63
+ fd, temporary = tempfile.mkstemp(prefix=".ecco-state-", dir=path.parent)
64
+ try:
65
+ if os.name != "nt":
66
+ os.fchmod(fd, 0o600)
67
+ with os.fdopen(fd, "wb") as stream:
68
+ stream.write(data)
69
+ stream.flush()
70
+ os.fsync(stream.fileno())
71
+ os.replace(temporary, path)
72
+ temporary = ""
73
+ if os.name != "nt" and path.stat().st_mode & 0o077:
74
+ raise BootstrapFailure("Private state permissions could not be enforced.")
75
+ finally:
76
+ if temporary:
77
+ Path(temporary).unlink(missing_ok=True)
78
+
79
+
80
+ def _read_private(path: Path) -> dict[str, Any]:
81
+ path = path.absolute()
82
+ if path != path.resolve() or not path.is_file():
83
+ raise BootstrapFailure("State must be a direct path to a regular file.")
84
+ status = path.stat()
85
+ if os.name != "nt" and (status.st_uid != os.getuid() or status.st_mode & 0o077):
86
+ raise BootstrapFailure("State file must be private (mode 0600).")
87
+ try:
88
+ value = json.loads(path.read_bytes())
89
+ except (OSError, ValueError, TypeError):
90
+ raise BootstrapFailure("State file is unavailable or invalid.") from None
91
+ if not isinstance(value, dict) or value.get("contract_version") != CONTRACT:
92
+ raise BootstrapFailure("State file uses an unsupported contract.")
93
+ return value
94
+
95
+
96
+ def _api_origin(value: str) -> str:
97
+ parsed = urllib.parse.urlsplit(value)
98
+ if (
99
+ parsed.scheme != "https"
100
+ or not parsed.hostname
101
+ or parsed.username
102
+ or parsed.password
103
+ or parsed.path not in {"", "/"}
104
+ or parsed.query
105
+ or parsed.fragment
106
+ ):
107
+ raise BootstrapFailure("API base must be an exact HTTPS origin.")
108
+ return value.rstrip("/")
109
+
110
+
111
+ def _request_json(
112
+ url: str, payload: dict[str, Any], *, attempt_id: str, recovery: str | None
113
+ ) -> tuple[int, dict[str, Any]]:
114
+ headers = {
115
+ "Content-Type": "application/json",
116
+ "Accept": "application/json",
117
+ "User-Agent": "ecco-cli/0.1",
118
+ "Idempotency-Key": attempt_id,
119
+ }
120
+ if recovery is not None:
121
+ headers["Ecco-Recovery"] = recovery
122
+ request = urllib.request.Request(
123
+ url, data=json.dumps(payload, separators=(",", ":")).encode(), headers=headers
124
+ )
125
+ try:
126
+ response = _OPENER.open(request, timeout=15)
127
+ except urllib.error.HTTPError as exc:
128
+ body = exc.read(MAX_RESPONSE + 1)
129
+ if len(body) > MAX_RESPONSE:
130
+ raise BootstrapFailure(f"Bootstrap failed with HTTP {exc.code}.") from None
131
+ try:
132
+ document = json.loads(body)
133
+ except (ValueError, TypeError):
134
+ document = {}
135
+ code = document.get("code") if isinstance(document, dict) else None
136
+ if not isinstance(code, str) or re.fullmatch(r"[a-z0-9_]{1,64}", code) is None:
137
+ code = None
138
+ raise BootstrapFailure(
139
+ f"Bootstrap failed with HTTP {exc.code}"
140
+ + (f" ({code})." if code is not None else ".")
141
+ ) from None
142
+ with response:
143
+ body = response.read(MAX_RESPONSE + 1)
144
+ if len(body) > MAX_RESPONSE:
145
+ raise BootstrapFailure("Bootstrap returned an oversized response.")
146
+ try:
147
+ document = json.loads(body)
148
+ except (ValueError, TypeError):
149
+ raise BootstrapFailure("Bootstrap returned invalid JSON.") from None
150
+ if not isinstance(document, dict) or document.get("contract_version") != CONTRACT:
151
+ raise BootstrapFailure("Bootstrap returned an unsupported contract.")
152
+ return response.status, document
153
+
154
+
155
+ def create(
156
+ *,
157
+ board_name: str,
158
+ task_title: str | None,
159
+ task_body: str | None,
160
+ client_name: str,
161
+ client_version: str,
162
+ api_base: str = DEFAULT_API,
163
+ state_directory: Path | None = None,
164
+ ) -> dict[str, Any]:
165
+ api_base = _api_origin(api_base)
166
+ attempt_id = str(uuid4())
167
+ # Worker authentication intentionally reserves exact 64-character hex
168
+ # credentials; other bearer shapes are routed through OAuth.
169
+ worker = secrets.token_hex(32)
170
+ capability = secrets.token_urlsafe(32)
171
+ recovery = secrets.token_urlsafe(32)
172
+ payload: dict[str, Any] = {
173
+ "contract_version": CONTRACT,
174
+ "board_name": board_name,
175
+ "client": {"name": client_name, "version": client_version},
176
+ "worker_token_sha256": _sha(worker),
177
+ "claim_capability_sha256": _sha(capability),
178
+ "recovery_secret_sha256": _sha(recovery),
179
+ }
180
+ if task_title is not None:
181
+ payload["first_task"] = {"title": task_title, "body": task_body}
182
+ selected_root = state_directory or _state_root()
183
+ if selected_root.is_symlink():
184
+ raise BootstrapFailure("State directory cannot be a symbolic link.")
185
+ root = selected_root.expanduser().resolve()
186
+ state_path = root / f"{attempt_id}.json"
187
+ state: dict[str, Any] = {
188
+ "contract_version": CONTRACT,
189
+ "attempt_id": attempt_id,
190
+ "api_base": api_base,
191
+ "request": payload,
192
+ "worker_token": worker,
193
+ "claim_capability": capability,
194
+ "recovery_secret": recovery,
195
+ "manifest": None,
196
+ }
197
+ _write_private(state_path, json.dumps(state, separators=(",", ":")).encode())
198
+ try:
199
+ _, manifest = _request_json(
200
+ api_base + "/agent-start",
201
+ payload,
202
+ attempt_id=attempt_id,
203
+ recovery=None,
204
+ )
205
+ except (urllib.error.URLError, TimeoutError):
206
+ try:
207
+ _, manifest = _request_json(
208
+ api_base + "/agent-start",
209
+ payload,
210
+ attempt_id=attempt_id,
211
+ recovery=recovery,
212
+ )
213
+ except (urllib.error.URLError, TimeoutError) as exc:
214
+ raise BootstrapFailure(
215
+ f"Bootstrap outcome is unknown; retry with state {state_path}."
216
+ ) from exc
217
+ state["manifest"] = manifest
218
+ claim_base = manifest.get("claim", {}).get("url_base")
219
+ if not isinstance(claim_base, str):
220
+ raise BootstrapFailure("Bootstrap response omitted the claim URL.")
221
+ claim_file = root / f"{attempt_id}.claim-link.txt"
222
+ full_claim = claim_base + "#capability=" + urllib.parse.quote(capability, safe="")
223
+ _write_private(claim_file, (full_claim + "\n").encode())
224
+ _write_private(state_path, json.dumps(state, separators=(",", ":")).encode())
225
+ return _public_result(state_path, claim_file, manifest)
226
+
227
+
228
+ def retry(state_path: Path) -> dict[str, Any]:
229
+ state = _read_private(state_path)
230
+ manifest = state.get("manifest")
231
+ if manifest is None:
232
+ _, manifest = _request_json(
233
+ _api_origin(str(state["api_base"])) + "/agent-start",
234
+ state["request"],
235
+ attempt_id=str(state["attempt_id"]),
236
+ recovery=str(state["recovery_secret"]),
237
+ )
238
+ state["manifest"] = manifest
239
+ _write_private(state_path, json.dumps(state, separators=(",", ":")).encode())
240
+ claim_file = state_path.with_name(state_path.stem + ".claim-link.txt")
241
+ claim_base = manifest["claim"]["url_base"]
242
+ value = (
243
+ claim_base
244
+ + "#capability="
245
+ + urllib.parse.quote(str(state["claim_capability"]), safe="")
246
+ )
247
+ _write_private(claim_file, (value + "\n").encode())
248
+ return _public_result(state_path, claim_file, manifest)
249
+
250
+
251
+ def run(state_path: Path, command: list[str]) -> int:
252
+ if not command:
253
+ raise BootstrapFailure("Resume requires a command after --.")
254
+ state = _read_private(state_path)
255
+ manifest = state.get("manifest")
256
+ if not isinstance(manifest, dict):
257
+ raise BootstrapFailure("Bootstrap is incomplete; retry it first.")
258
+ environment = os.environ.copy()
259
+ environment.update(
260
+ {
261
+ "ECCO_AGENT_TOKEN": str(state["worker_token"]),
262
+ "ECCO_MCP_URL": str(manifest["connection"]["mcp_url"]),
263
+ "ECCO_WORKSPACE_ID": str(manifest["board"]["workspace_id"]),
264
+ }
265
+ )
266
+ return subprocess.run(command, env=environment, check=False).returncode
267
+
268
+
269
+ def _public_result(
270
+ state_path: Path, claim_file: Path, manifest: dict[str, Any]
271
+ ) -> dict[str, Any]:
272
+ return {
273
+ "status": "ready",
274
+ "board": manifest.get("board"),
275
+ "first_task": manifest.get("first_task"),
276
+ "connection": manifest.get("connection"),
277
+ "limits": manifest.get("limits"),
278
+ "claim_url_base": manifest.get("claim", {}).get("url_base"),
279
+ "state_file": str(state_path),
280
+ "claim_link_file": str(claim_file),
281
+ "secrets_printed": False,
282
+ }
ecco_cli/discovery.py ADDED
@@ -0,0 +1,161 @@
1
+ """Find explicit native config targets without reading credential stores.
2
+
3
+ Inventory is deliberately conservative: duplicate names require resolution in
4
+ the native client rather than guessing about trust, launch flags or profiles.
5
+ This does not claim to reproduce a running client's effective configuration.
6
+ """
7
+
8
+ import os
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ import tomlkit
13
+
14
+ from ecco_cli.files import snapshot
15
+ from ecco_cli.migration import MigrationError
16
+ from ecco_cli.serialization import _DECODER
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Target:
21
+ path: Path
22
+ scope: str
23
+ project: str | None = None
24
+
25
+
26
+ def native_target(*, client: str, name: str, scope: str, project: Path) -> Target:
27
+ """Resolve on-disk CLI config; do not inspect authentication caches."""
28
+ if client == "claude":
29
+ custom = os.environ.get("CLAUDE_CONFIG_DIR")
30
+ config_home = Path(custom) if custom else Path.home() / ".claude"
31
+ user_config = (config_home if custom else Path.home()) / ".claude.json"
32
+ directories = (
33
+ Path("/Library/Application Support/ClaudeCode"),
34
+ Path("/etc/claude-code"),
35
+ )
36
+ managed = tuple(
37
+ directory / filename
38
+ for directory in directories
39
+ for filename in (
40
+ "managed-mcp.json",
41
+ "managed-settings.json",
42
+ "managed-settings.d",
43
+ )
44
+ ) + (
45
+ config_home / "remote-settings.json",
46
+ Path("/Library/Managed Preferences/com.anthropic.claudecode.plist"),
47
+ Path("/Library/Managed Preferences")
48
+ / Path.home().name
49
+ / "com.anthropic.claudecode.plist",
50
+ )
51
+ else:
52
+ config_home = Path(os.environ.get("CODEX_HOME", Path.home() / ".codex"))
53
+ user_config = config_home / "config.toml"
54
+ managed = (
55
+ Path("/etc/codex/requirements.toml"),
56
+ Path("/etc/codex/managed_config.toml"),
57
+ Path("/etc/codex/config.toml"),
58
+ config_home / "managed_config.toml",
59
+ )
60
+ return discover(
61
+ client=client,
62
+ name=name,
63
+ scope=scope,
64
+ project=project,
65
+ user_config=user_config,
66
+ managed_paths=managed,
67
+ )
68
+
69
+
70
+ def _document(path: Path, client: str) -> dict:
71
+ if not path.exists() and not path.is_symlink():
72
+ return {}
73
+ try:
74
+ source = snapshot(path).content.decode("utf-8")
75
+ document = (
76
+ _DECODER.decode(source) if client == "claude" else tomlkit.parse(source)
77
+ )
78
+ if not isinstance(document, dict):
79
+ raise ValueError
80
+ return document
81
+ except MigrationError:
82
+ raise
83
+ except (ValueError, TypeError, RecursionError):
84
+ raise MigrationError("Cannot inspect native configuration safely.") from None
85
+
86
+
87
+ def _contains(document: dict, key: str, name: str) -> bool:
88
+ entries = document.get(key, {})
89
+ if not isinstance(entries, dict):
90
+ raise MigrationError("Native MCP configuration is not an object.")
91
+ return name in entries
92
+
93
+
94
+ def discover(
95
+ *,
96
+ client: str,
97
+ name: str,
98
+ scope: str,
99
+ project: Path,
100
+ user_config: Path,
101
+ managed_paths: tuple[Path, ...],
102
+ ) -> Target:
103
+ """Resolve one named CLI connector within supplied native config locations.
104
+
105
+ user_config is the native user's configuration file, resolved by the caller.
106
+ managed_paths includes system config/policy
107
+ files: their presence stops this migration, without reading their contents.
108
+ No fallback to another name, scope, workspace or credential is permitted.
109
+ """
110
+ if any(path.exists() or path.is_symlink() for path in managed_paths):
111
+ raise MigrationError("Managed configuration requires administrator review.")
112
+ if client not in {"claude", "codex"}:
113
+ raise MigrationError("Unsupported client.")
114
+ project = project.absolute()
115
+ if project != project.resolve() or not project.is_dir():
116
+ raise MigrationError("Select an existing, non-symlink project directory.")
117
+ found: list[Target] = []
118
+ if client == "claude":
119
+ user_path = user_config
120
+ user = _document(user_path, client)
121
+ if _contains(user, "mcpServers", name):
122
+ found.append(Target(user_path, "user"))
123
+ projects = user.get("projects", {})
124
+ if not isinstance(projects, dict):
125
+ raise MigrationError("Native project configuration is not an object.")
126
+ local = projects.get(str(project), {})
127
+ if not isinstance(local, dict):
128
+ raise MigrationError("Native project configuration is not an object.")
129
+ if _contains(local, "mcpServers", name):
130
+ found.append(Target(user_path, "local", str(project)))
131
+ path = project / ".mcp.json"
132
+ if _contains(_document(path, client), "mcpServers", name):
133
+ found.append(Target(path, "project"))
134
+ else:
135
+ path = user_config
136
+ user = _document(path, client)
137
+ if user.get("profile") or user.get("profiles"):
138
+ raise MigrationError("Profile configuration requires native-client review.")
139
+ if _contains(user, "mcp_servers", name):
140
+ found.append(Target(path, "user"))
141
+ # Include every ancestor conservatively. Even an untrusted project layer
142
+ # may become active later; migration must never silently trust that project.
143
+ for directory in (project, *project.parents):
144
+ path = directory / ".codex" / "config.toml"
145
+ if path == user_config:
146
+ continue
147
+ if _contains(_document(path, client), "mcp_servers", name):
148
+ found.append(Target(path, "project"))
149
+ if not found:
150
+ raise MigrationError("The selected connector was not found in native config.")
151
+ if len(found) != 1:
152
+ raise MigrationError(
153
+ "This connector name exists in multiple scopes or layers; "
154
+ "resolve the overlap in the native client before migrating."
155
+ )
156
+ target = found[0]
157
+ if target.scope != scope:
158
+ raise MigrationError(
159
+ "The connector exists in a different scope; select it explicitly."
160
+ )
161
+ return target
ecco_cli/files.py ADDED
@@ -0,0 +1,195 @@
1
+ """Guarded local configuration edits; caller selects and approves the target.
2
+
3
+ Only POSIX is supported here. Advisory locks coordinate Ecco invocations, not
4
+ arbitrary editors: close the native client while applying. Recheck content and
5
+ inode immediately before the atomic replacement. Never auto-retry a conflict.
6
+ """
7
+
8
+ import fcntl
9
+ import os
10
+ import stat
11
+ import tempfile
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ from ecco_cli.migration import MigrationError, Plan, UncertainWriteError
16
+ from ecco_cli.serialization import MAX_CONFIG_BYTES, RenderedMigration, render
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Snapshot:
21
+ path: Path
22
+ device: int
23
+ inode: int
24
+ mode: int
25
+ content: bytes = field(repr=False)
26
+
27
+
28
+ def _safe_path(path: Path) -> Path:
29
+ path = path.absolute()
30
+ if path != path.resolve():
31
+ raise MigrationError("Use a direct configuration path, not a symbolic link.")
32
+ parent = path.parent.stat()
33
+ if parent.st_uid != os.getuid() or parent.st_mode & 0o022:
34
+ raise MigrationError(
35
+ "Configuration directory must be owned and writable only by you."
36
+ )
37
+ return path
38
+
39
+
40
+ def snapshot(path: Path) -> Snapshot:
41
+ """Read one bounded, owned, non-linked file without echoing its contents."""
42
+ try:
43
+ path = _safe_path(path)
44
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
45
+ with os.fdopen(fd, "rb") as stream:
46
+ before = os.fstat(stream.fileno())
47
+ if (
48
+ not stat.S_ISREG(before.st_mode)
49
+ or before.st_uid != os.getuid()
50
+ or before.st_nlink != 1
51
+ or before.st_mode & 0o022
52
+ ):
53
+ raise MigrationError(
54
+ "Configuration must be an owned, non-linked regular file."
55
+ )
56
+ data = stream.read(MAX_CONFIG_BYTES + 1)
57
+ after = os.fstat(stream.fileno())
58
+ if len(data) > MAX_CONFIG_BYTES:
59
+ raise MigrationError("Configuration exceeds the supported size limit.")
60
+ if (before.st_mtime_ns, before.st_size) != (
61
+ after.st_mtime_ns,
62
+ after.st_size,
63
+ ):
64
+ raise MigrationError(
65
+ "Configuration changed during read; preview again."
66
+ )
67
+ return Snapshot(
68
+ path, before.st_dev, before.st_ino, stat.S_IMODE(before.st_mode), data
69
+ )
70
+ except OSError:
71
+ raise MigrationError(
72
+ "Cannot safely access the selected configuration."
73
+ ) from None
74
+
75
+
76
+ def preview(path: Path, **selection) -> tuple[Snapshot, RenderedMigration]:
77
+ current = snapshot(path)
78
+ return current, render(current.content, **selection)
79
+
80
+
81
+ def _same(expected: Snapshot, current: Snapshot) -> bool:
82
+ return (
83
+ expected.path == current.path
84
+ and expected.device == current.device
85
+ and expected.inode == current.inode
86
+ and expected.mode == current.mode
87
+ and expected.content == current.content
88
+ )
89
+
90
+
91
+ def apply(expected: Snapshot, edit: RenderedMigration) -> Path | None:
92
+ """Apply an approved preview and return a private recovery copy, if changed.
93
+
94
+ Callers must not present this as authenticated connectivity. No token cache
95
+ is opened, migrated or deleted. Recovery copies can contain existing config
96
+ secrets, so they are private (0600) and must never be printed or committed.
97
+ """
98
+ if edit.before != expected.content:
99
+ raise MigrationError("Preview does not belong to this configuration.")
100
+ if not edit.plan.changed:
101
+ if not _same(expected, snapshot(expected.path)):
102
+ raise MigrationError("Configuration changed after preview; preview again.")
103
+ return None
104
+ lock_path = expected.path.with_name(expected.path.name + ".ecco-lock")
105
+ temporary: str | None = None
106
+ replaced = False
107
+ try:
108
+ _safe_path(expected.path)
109
+ lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
110
+ with os.fdopen(lock_fd, "rb") as lock:
111
+ lock_stat = os.fstat(lock.fileno())
112
+ if (
113
+ not stat.S_ISREG(lock_stat.st_mode)
114
+ or lock_stat.st_uid != os.getuid()
115
+ or lock_stat.st_nlink != 1
116
+ or stat.S_IMODE(lock_stat.st_mode) != 0o600
117
+ ):
118
+ raise MigrationError("Unsafe configuration lock; no change made.")
119
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
120
+ if not _same(expected, snapshot(expected.path)):
121
+ raise MigrationError(
122
+ "Configuration changed after preview; preview again."
123
+ )
124
+ # Backup is never used to overwrite a later edit automatically.
125
+ backup_fd, backup_name = tempfile.mkstemp(
126
+ prefix=expected.path.name + ".ecco-backup-", dir=expected.path.parent
127
+ )
128
+ with os.fdopen(backup_fd, "wb") as backup:
129
+ backup.write(expected.content)
130
+ backup.flush()
131
+ os.fsync(backup.fileno())
132
+ target_fd, temporary = tempfile.mkstemp(
133
+ prefix=".ecco-edit-", dir=expected.path.parent
134
+ )
135
+ with os.fdopen(target_fd, "wb") as target:
136
+ target.write(edit.after)
137
+ os.fchmod(target.fileno(), expected.mode)
138
+ target.flush()
139
+ os.fsync(target.fileno())
140
+ if not _same(expected, snapshot(expected.path)):
141
+ raise MigrationError(
142
+ "Configuration changed during apply; preview again."
143
+ )
144
+ os.replace(temporary, expected.path)
145
+ temporary = None
146
+ replaced = True
147
+ directory_fd = os.open(expected.path.parent, os.O_RDONLY)
148
+ try:
149
+ os.fsync(directory_fd)
150
+ finally:
151
+ os.close(directory_fd)
152
+ return Path(backup_name)
153
+ except OSError:
154
+ if replaced:
155
+ raise UncertainWriteError(
156
+ "Configuration was replaced but durability is uncertain; "
157
+ "inspect it before any retry. The private backup is retained."
158
+ ) from None
159
+ raise MigrationError(
160
+ "Configuration edit unavailable; inspect status before retrying."
161
+ ) from None
162
+ finally:
163
+ if temporary is not None:
164
+ os.unlink(temporary)
165
+
166
+
167
+ def rollback(path: Path, recovery: Path, **selection) -> Path | None:
168
+ """Restore only a matching migration; refuse to discard subsequent edits."""
169
+ current = snapshot(path)
170
+ saved = snapshot(recovery)
171
+ if (
172
+ saved.path.parent != current.path.parent
173
+ or not saved.path.name.startswith(current.path.name + ".ecco-backup-")
174
+ or saved.mode != 0o600
175
+ ):
176
+ raise MigrationError("Recovery copy does not belong to this configuration.")
177
+ original_edit = render(saved.content, **selection)
178
+ if not original_edit.plan.changed:
179
+ raise MigrationError("Recovery copy does not contain a migration source.")
180
+ if current.content == saved.content:
181
+ return None
182
+ if current.content != original_edit.after:
183
+ raise MigrationError(
184
+ "Configuration has later edits; automatic rollback refused."
185
+ )
186
+ reverse = RenderedMigration(
187
+ Plan(
188
+ original_edit.plan.path,
189
+ original_edit.plan.new_url,
190
+ original_edit.plan.old_url,
191
+ ),
192
+ current.content,
193
+ saved.content,
194
+ )
195
+ return apply(current, reverse)
ecco_cli/main.py ADDED
@@ -0,0 +1,178 @@
1
+ """Explicit preview/apply migration commands; login is owned by ECO-1035."""
2
+
3
+ import argparse
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from ecco_cli import bootstrap
11
+ from ecco_cli.migration import MigrationError, UncertainWriteError
12
+
13
+ if os.name != "nt":
14
+ from ecco_cli.discovery import native_target
15
+ from ecco_cli.files import apply, preview, rollback, snapshot
16
+ from ecco_cli.metadata import verify
17
+
18
+
19
+ def main(argv: list[str] | None = None) -> int:
20
+ parser = argparse.ArgumentParser(prog=Path(sys.argv[0]).name)
21
+ commands = parser.add_subparsers(dest="command", required=True)
22
+ new = commands.add_parser("new", help="Create a signup-free agent board")
23
+ new.add_argument("name", help="Board name")
24
+ new.add_argument("--task", "--task-title", dest="task_title")
25
+ new.add_argument("--task-body")
26
+ new.add_argument("--client", default="heyecco")
27
+ new.add_argument("--client-version", default="0.1.0")
28
+ new.add_argument("--api-base", default=bootstrap.DEFAULT_API)
29
+ new.add_argument("--state-directory", type=Path)
30
+ resume = commands.add_parser("resume", help="Resume from private board state")
31
+ resume.add_argument("--state", type=Path, required=True)
32
+ resume.add_argument("--retry-bootstrap", action="store_true")
33
+ resume.add_argument("child", nargs=argparse.REMAINDER)
34
+ migrate = commands.add_parser(
35
+ "migrate", help="Preview one legacy MCP endpoint update"
36
+ )
37
+ migrate.add_argument("--client", choices=("claude", "codex"), required=True)
38
+ migrate.add_argument("--scope", choices=("user", "project", "local"), required=True)
39
+ migrate.add_argument("--name", required=True)
40
+ migrate.add_argument(
41
+ "--config", type=Path, help="Assert the discovered config path"
42
+ )
43
+ migrate.add_argument("--project", help="Exact project key for Claude local scope")
44
+ migrate.add_argument("--project-dir", type=Path, default=Path.cwd())
45
+ action = migrate.add_mutually_exclusive_group()
46
+ action.add_argument("--apply", action="store_true")
47
+ action.add_argument("--rollback", type=Path, metavar="RECOVERY_COPY")
48
+ migrate.add_argument("--expected-digest", help="SHA-256 from the preview")
49
+ migrate.add_argument(
50
+ "--client-closed",
51
+ action="store_true",
52
+ help="Confirm the client is closed while its config is changed",
53
+ )
54
+ args = parser.parse_args(argv)
55
+ if args.command == "new":
56
+ try:
57
+ result = bootstrap.create(
58
+ board_name=args.name,
59
+ task_title=args.task_title,
60
+ task_body=args.task_body,
61
+ client_name=args.client,
62
+ client_version=args.client_version,
63
+ api_base=args.api_base,
64
+ state_directory=args.state_directory,
65
+ )
66
+ print(json.dumps(result))
67
+ return 0
68
+ except bootstrap.BootstrapFailure as exc:
69
+ print(json.dumps({"status": "not_ready", "error": str(exc)}))
70
+ return 2
71
+ if args.command == "resume":
72
+ try:
73
+ if args.retry_bootstrap:
74
+ if args.child:
75
+ raise bootstrap.BootstrapFailure(
76
+ "Retry bootstrap and child execution are separate steps."
77
+ )
78
+ print(json.dumps(bootstrap.retry(args.state)))
79
+ return 0
80
+ child = args.child[1:] if args.child[:1] == ["--"] else args.child
81
+ return bootstrap.run(args.state, child)
82
+ except bootstrap.BootstrapFailure as exc:
83
+ print(json.dumps({"status": "not_ready", "error": str(exc)}))
84
+ return 2
85
+ if os.name == "nt":
86
+ print(
87
+ json.dumps(
88
+ {
89
+ "status": "not_applied",
90
+ "error": "Connector migration is supported on POSIX hosts only.",
91
+ "authenticated": False,
92
+ }
93
+ )
94
+ )
95
+ return 2
96
+ try:
97
+ target = native_target(
98
+ client=args.client,
99
+ name=args.name,
100
+ scope=args.scope,
101
+ project=args.project_dir,
102
+ )
103
+ if args.config is not None and args.config.absolute() != target.path.absolute():
104
+ raise MigrationError("The supplied path does not match native discovery.")
105
+ if args.project is not None and args.project != target.project:
106
+ raise MigrationError("The selected local project does not match discovery.")
107
+ args.config = target.path
108
+ selection = {
109
+ "client": args.client,
110
+ "scope": args.scope,
111
+ "name": args.name,
112
+ "project": target.project,
113
+ }
114
+ before = snapshot(args.config)
115
+ digest = hashlib.sha256(before.content).hexdigest()
116
+ writing = args.apply or args.rollback is not None
117
+ if writing and (not args.client_closed or args.expected_digest != digest):
118
+ raise MigrationError(
119
+ "Apply requires the preview digest and confirmation "
120
+ "the client is closed."
121
+ )
122
+ if args.rollback is not None:
123
+ backup = rollback(args.config, args.rollback, **selection)
124
+ result = {
125
+ "status": "restored" if backup else "already_restored",
126
+ "authenticated": False,
127
+ }
128
+ else:
129
+ before, edit = preview(args.config, **selection)
130
+ # Recheck the digest after parsing, before passing the snapshot to apply.
131
+ if hashlib.sha256(before.content).hexdigest() != digest:
132
+ raise MigrationError("Configuration changed during preview; try again.")
133
+ result = {
134
+ "status": "preview" if edit.plan.changed else "already_canonical",
135
+ "from": edit.plan.old_url,
136
+ "to": edit.plan.new_url,
137
+ "digest": digest,
138
+ "authenticated": False,
139
+ }
140
+ backup = None
141
+ if args.apply:
142
+ verify()
143
+ if (
144
+ native_target(
145
+ client=args.client,
146
+ name=args.name,
147
+ scope=args.scope,
148
+ project=args.project_dir,
149
+ )
150
+ != target
151
+ ):
152
+ raise MigrationError("Native configuration changed; preview again.")
153
+ backup = apply(before, edit)
154
+ result["status"] = "reload_required"
155
+ result["next"] = (
156
+ "Reload the connector; native reauthorization may be required."
157
+ )
158
+ if backup is not None:
159
+ result["recovery_copy"] = str(backup)
160
+ print(json.dumps(result))
161
+ return 0
162
+ except MigrationError as exc:
163
+ print(
164
+ json.dumps(
165
+ {
166
+ "status": "inspect_required"
167
+ if isinstance(exc, UncertainWriteError)
168
+ else "not_applied",
169
+ "error": str(exc),
170
+ "authenticated": False,
171
+ }
172
+ )
173
+ )
174
+ return 2
175
+
176
+
177
+ if __name__ == "__main__":
178
+ raise SystemExit(main())
ecco_cli/metadata.py ADDED
@@ -0,0 +1,122 @@
1
+ """Bounded, credential-free check of the canonical MCP resource identity."""
2
+
3
+ import json
4
+ import socket
5
+ import ssl
6
+ import subprocess
7
+ import sys
8
+ from urllib.error import HTTPError, URLError
9
+ from urllib.parse import urlsplit
10
+ from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
11
+
12
+ from ecco_cli.migration import CANONICAL, MigrationError
13
+
14
+ METADATA_URL = "https://api.heyecco.com/.well-known/oauth-protected-resource/mcp"
15
+ MAX_BYTES = 65536
16
+
17
+
18
+ class NoRedirect(HTTPRedirectHandler):
19
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
20
+ return None
21
+
22
+
23
+ def validate(document: object) -> None:
24
+ if not isinstance(document, dict) or document.get("resource") != CANONICAL:
25
+ raise MigrationError(
26
+ "Public OAuth resource does not match the canonical MCP URL."
27
+ )
28
+ issuers = document.get("authorization_servers")
29
+ if not isinstance(issuers, list) or not issuers:
30
+ raise MigrationError("Public OAuth metadata has no authorization server.")
31
+ for issuer in issuers:
32
+ if not isinstance(issuer, str):
33
+ raise MigrationError("Public OAuth authorization server is invalid.")
34
+ parsed = urlsplit(issuer)
35
+ if (
36
+ parsed.scheme != "https"
37
+ or not parsed.hostname
38
+ or parsed.username is not None
39
+ or parsed.password is not None
40
+ or parsed.query
41
+ or parsed.fragment
42
+ ):
43
+ raise MigrationError("Public OAuth authorization server is invalid.")
44
+
45
+
46
+ def probe() -> dict:
47
+ """Only a fixed public endpoint; no client config, invite or auth header."""
48
+ try:
49
+ opener = build_opener(NoRedirect(), ProxyHandler({}))
50
+ request = Request(METADATA_URL, headers={"Accept": "application/json"})
51
+ with opener.open(request, timeout=8) as response:
52
+ if response.status != 200:
53
+ return {"ok": False, "category": "http", "status": response.status}
54
+ body = response.read(MAX_BYTES + 1)
55
+ if len(body) > MAX_BYTES:
56
+ return {"ok": False, "category": "metadata_size"}
57
+ validate(json.loads(body))
58
+ return {"ok": True, "resource": CANONICAL, "authenticated": False}
59
+ except HTTPError as exc:
60
+ return {"ok": False, "category": "http", "status": exc.code}
61
+ except URLError as exc:
62
+ reason = exc.reason
63
+ category = (
64
+ "tls"
65
+ if isinstance(reason, ssl.SSLError)
66
+ else "dns"
67
+ if isinstance(reason, socket.gaierror)
68
+ else "timeout"
69
+ if isinstance(reason, TimeoutError)
70
+ else "transport"
71
+ )
72
+ return {"ok": False, "category": category}
73
+ except TimeoutError:
74
+ return {"ok": False, "category": "timeout"}
75
+ except (ValueError, UnicodeError):
76
+ return {"ok": False, "category": "metadata"}
77
+ except OSError:
78
+ return {"ok": False, "category": "transport"}
79
+
80
+
81
+ def verify() -> None:
82
+ # A child enforces a wall-clock deadline even if OS DNS resolution stalls.
83
+ # This runs installed package code, never a downloaded script or a model.
84
+ try:
85
+ completed = subprocess.run(
86
+ [sys.executable, "-m", "ecco_cli.metadata"],
87
+ stdin=subprocess.DEVNULL,
88
+ capture_output=True,
89
+ timeout=12,
90
+ check=False,
91
+ )
92
+ report = json.loads(completed.stdout)
93
+ if completed.returncode != 0 or not isinstance(report, dict):
94
+ raise ValueError
95
+ except subprocess.TimeoutExpired:
96
+ raise MigrationError(
97
+ "Public metadata check timed out after 12 seconds; no credential was used."
98
+ ) from None
99
+ except (OSError, ValueError):
100
+ raise MigrationError("Public metadata check could not complete.") from None
101
+ if report.get("ok") is True and report.get("resource") == CANONICAL:
102
+ return
103
+ category = report.get("category")
104
+ if category not in {
105
+ "http",
106
+ "tls",
107
+ "dns",
108
+ "timeout",
109
+ "transport",
110
+ "metadata",
111
+ "metadata_size",
112
+ }:
113
+ category = "metadata"
114
+ status = report.get("status")
115
+ detail = f" HTTP {status}" if type(status) is int and 100 <= status <= 599 else ""
116
+ raise MigrationError(
117
+ f"Public metadata check failed ({category}{detail}); no credential was used."
118
+ )
119
+
120
+
121
+ if __name__ == "__main__":
122
+ print(json.dumps(probe())) # CLI protocol only; no request/response body logging.
ecco_cli/migration.py ADDED
@@ -0,0 +1,87 @@
1
+ """Pure migration planning. No credentials, filesystem writes or network calls."""
2
+
3
+ from copy import deepcopy
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ CANONICAL = "https://api.heyecco.com/mcp"
8
+ LEGACY = frozenset(
9
+ "https://eco-backend-production-4659.up.railway.app" + path
10
+ for path in ("", "/", "/mcp", "/mcp/")
11
+ )
12
+
13
+
14
+ class MigrationError(ValueError):
15
+ """A safe, credential-free explanation of why no change was made."""
16
+
17
+
18
+ class UncertainWriteError(MigrationError):
19
+ """Replacement happened but durability could not be confirmed."""
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Plan:
24
+ path: tuple[str, ...]
25
+ old_url: str
26
+ new_url: str = CANONICAL
27
+
28
+ @property
29
+ def changed(self) -> bool:
30
+ return self.old_url != self.new_url
31
+
32
+
33
+ def plan(
34
+ document: dict[str, Any],
35
+ *,
36
+ client: str,
37
+ name: str,
38
+ scope: str,
39
+ project: str | None = None,
40
+ ) -> Plan:
41
+ """Select one explicit client/name/scope; never guess from display names.
42
+
43
+ Documents are supplied by the caller. This planner neither discovers nor
44
+ reads token stores. Callers must separately enforce managed configuration,
45
+ effective-scope precedence and file ownership before applying a plan.
46
+ """
47
+ if client == "claude":
48
+ if scope in {"user", "project"}:
49
+ path = ("mcpServers", name, "url")
50
+ elif scope == "local" and project:
51
+ path = ("projects", project, "mcpServers", name, "url")
52
+ else:
53
+ raise MigrationError("Select a supported scope and project explicitly.")
54
+ elif client == "codex" and scope in {"user", "project"}:
55
+ path = ("mcp_servers", name, "url")
56
+ else:
57
+ raise MigrationError("Unsupported client or configuration scope.")
58
+ entry: Any = document
59
+ for key in path[:-1]:
60
+ if not isinstance(entry, dict) or key not in entry:
61
+ raise MigrationError("The selected connector is not configured here.")
62
+ entry = entry[key]
63
+ if not isinstance(entry, dict):
64
+ raise MigrationError("The selected connector configuration is invalid.")
65
+ if "command" in entry or (
66
+ client == "claude" and entry.get("type") not in {"http", "streamable-http"}
67
+ ):
68
+ raise MigrationError("Only an explicit HTTP connector can be migrated.")
69
+ url = entry.get("url")
70
+ if not isinstance(url, str) or url not in LEGACY | {CANONICAL}:
71
+ # Never echo arbitrary URLs: they may contain credentials or invite codes.
72
+ raise MigrationError("Endpoint is not a recognized Ecco migration source.")
73
+ return Plan(path=path, old_url=url)
74
+
75
+
76
+ def apply_to_document(document: dict[str, Any], migration: Plan) -> dict[str, Any]:
77
+ """Reference semantic change used to verify a lossless serialized edit."""
78
+ updated = deepcopy(document)
79
+ entry: Any = updated
80
+ for key in migration.path[:-1]:
81
+ if not isinstance(entry, dict) or key not in entry:
82
+ raise MigrationError("Configuration changed after preview; preview again.")
83
+ entry = entry[key]
84
+ if not isinstance(entry, dict) or entry.get("url") != migration.old_url:
85
+ raise MigrationError("Configuration changed after preview; preview again.")
86
+ entry["url"] = migration.new_url
87
+ return updated
@@ -0,0 +1,110 @@
1
+ """Preserve configuration formatting and secrets without displaying either."""
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+ import tomlkit
8
+
9
+ from ecco_cli.migration import MigrationError, Plan, apply_to_document, plan
10
+
11
+ MAX_CONFIG_BYTES = 1024 * 1024
12
+
13
+
14
+ def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
15
+ value: dict[str, Any] = {}
16
+ for key, item in pairs:
17
+ if key in value:
18
+ raise MigrationError("Duplicate configuration keys; resolve them first.")
19
+ value[key] = item
20
+ return value
21
+
22
+
23
+ def _reject_constant(value: str) -> None:
24
+ raise MigrationError("Nonstandard JSON constants are not supported.")
25
+
26
+
27
+ _DECODER = json.JSONDecoder(
28
+ object_pairs_hook=_unique_object, parse_constant=_reject_constant
29
+ )
30
+
31
+
32
+ def _skip_space(source: str, start: int) -> int:
33
+ while start < len(source) and source[start] in " \r\n\t":
34
+ start += 1
35
+ return start
36
+
37
+
38
+ def _json_value_span(source: str, start: int, path: tuple[str, ...]) -> tuple[int, int]:
39
+ """Find one value in already-validated JSON, respecting escaped keys."""
40
+ start = _skip_space(source, start)
41
+ if not path:
42
+ _, end = _DECODER.raw_decode(source, start)
43
+ return start, end
44
+ if source[start] != "{":
45
+ raise MigrationError("Selected configuration path is not an object.")
46
+ cursor = _skip_space(source, start + 1)
47
+ while source[cursor] != "}":
48
+ key, cursor = _DECODER.raw_decode(source, cursor)
49
+ cursor = _skip_space(source, cursor)
50
+ cursor = _skip_space(source, cursor + 1) # Validated colon.
51
+ if key == path[0]:
52
+ return _json_value_span(source, cursor, path[1:])
53
+ _, cursor = _DECODER.raw_decode(source, cursor)
54
+ cursor = _skip_space(source, cursor)
55
+ if source[cursor] == ",":
56
+ cursor = _skip_space(source, cursor + 1)
57
+ raise MigrationError("Selected connector is no longer available.")
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class RenderedMigration:
62
+ plan: Plan
63
+ before: bytes = field(repr=False)
64
+ after: bytes = field(repr=False)
65
+
66
+
67
+ def render(
68
+ data: bytes,
69
+ *,
70
+ client: str,
71
+ name: str,
72
+ scope: str,
73
+ project: str | None = None,
74
+ ) -> RenderedMigration:
75
+ """Return an in-memory preview; never read/write a client configuration."""
76
+ if len(data) > MAX_CONFIG_BYTES:
77
+ raise MigrationError("Configuration exceeds the supported size limit.")
78
+ try:
79
+ source = data.decode("utf-8")
80
+ if client == "claude":
81
+ document = _DECODER.decode(source)
82
+ elif client == "codex":
83
+ document = tomlkit.parse(source)
84
+ else:
85
+ raise MigrationError("Unsupported client.")
86
+ if not isinstance(document, dict):
87
+ raise MigrationError("Configuration must be an object.")
88
+ migration = plan(
89
+ document, client=client, name=name, scope=scope, project=project
90
+ )
91
+ if not migration.changed:
92
+ return RenderedMigration(migration, data, data)
93
+ expected = apply_to_document(document, migration)
94
+ if client == "claude":
95
+ start, end = _json_value_span(source, 0, migration.path)
96
+ changed = source[:start] + json.dumps(migration.new_url) + source[end:]
97
+ parsed = _DECODER.decode(changed)
98
+ else:
99
+ changed = tomlkit.dumps(expected)
100
+ parsed = tomlkit.parse(changed)
101
+ if parsed != expected:
102
+ raise MigrationError("Configuration preservation check failed.")
103
+ return RenderedMigration(migration, data, changed.encode("utf-8"))
104
+ except MigrationError:
105
+ raise
106
+ except (ValueError, TypeError, KeyError, IndexError, RecursionError):
107
+ # Parser diagnostics may quote private configuration. Never forward them.
108
+ raise MigrationError(
109
+ "Cannot safely parse this configuration; no change made."
110
+ ) from None
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.5
2
+ Name: heyecco
3
+ Version: 0.1.0
4
+ Summary: Start and resume an Ecco agent board without an account
5
+ Project-URL: Homepage, https://heyecco.com/for-agents
6
+ Project-URL: Documentation, https://heyecco.com/for-agents
7
+ License-Expression: AGPL-3.0-only
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Topic :: Office/Business :: Groupware
13
+ Requires-Python: >=3.11
14
+ Requires-Dist: tomlkit<1,>=0.13.2
15
+ Description-Content-Type: text/markdown
16
+
17
+ # Ecco CLI
18
+
19
+ This package supplies signup-free agent boards and safe connector migration.
20
+ Python 3.11+ is required. Start from any directory without a GitHub checkout or
21
+ Ecco account:
22
+
23
+ ```sh
24
+ uvx heyecco new "Launch Acme" --task "Draft the launch plan"
25
+ ```
26
+
27
+ For local development:
28
+
29
+ ```sh
30
+ cd cli
31
+ uv sync
32
+ uv run ecco --help
33
+ ```
34
+
35
+ ## Start a board without an account
36
+
37
+ ```sh
38
+ heyecco new "Launch Acme" \
39
+ --task "Draft the launch plan" \
40
+ --task-body "## Outcome\nA reviewable launch plan exists."
41
+ ```
42
+
43
+ The public PyPI package is named `heyecco` because the `ecco` package name belongs
44
+ to an unrelated project. It installs both `heyecco` and `ecco` commands; existing
45
+ `--task-title` calls remain supported as an alias for `--task`.
46
+
47
+ The command creates caller-owned random worker, claim and recovery secrets, writes
48
+ them to a private state file before making the HTTPS request, and prints one JSON
49
+ receipt containing public board metadata plus the state-file and claim-link-file
50
+ paths. It never prints a raw secret or writes one into a repository or native
51
+ client configuration. The board accepts 100 task creations and agent access ends
52
+ after seven days; the private claim link remains usable for 30 more days.
53
+
54
+ If the first response is lost, the CLI retries the exact request once with the
55
+ stored recovery proof. If the outcome remains unknown, use the reported state:
56
+
57
+ ```sh
58
+ ecco resume --state /private/path/ATTEMPT.json --retry-bootstrap
59
+ ```
60
+
61
+ To run an agent with the saved bearer without printing it, place the agent command
62
+ after `--`. The child receives `ECCO_AGENT_TOKEN`, `ECCO_MCP_URL`, and
63
+ `ECCO_WORKSPACE_ID` in its environment:
64
+
65
+ ```sh
66
+ ecco resume --state /private/path/ATTEMPT.json -- your-agent-command
67
+ ```
68
+
69
+ The child or its supported MCP configuration should read the token from
70
+ `ECCO_AGENT_TOKEN`. The full human handoff URL is in the mode-0600
71
+ `*.claim-link.txt` file named in the receipt; share it only with the intended
72
+ owner.
73
+
74
+ ### Supported paths
75
+
76
+ | Platform | `ecco new` / `resume` | Private state |
77
+ | --- | --- | --- |
78
+ | macOS | Supported | `$XDG_STATE_HOME/ecco/boards` or `~/.local/state/ecco/boards`, mode 0700/0600 |
79
+ | Linux | Supported | `$XDG_STATE_HOME/ecco/boards` or `~/.local/state/ecco/boards`, mode 0700/0600 |
80
+ | Windows | Supported | `%LOCALAPPDATA%\ecco\boards`, protected by the user profile ACL |
81
+
82
+ Connector migration below remains POSIX-only because it uses advisory file locks.
83
+
84
+ ## Migrate an existing connector
85
+
86
+ Close the client before applying a change. Preview from the project where the
87
+ connector is used; select its existing name and scope, not a new identity:
88
+
89
+ ```sh
90
+ ecco migrate --client claude --name ecco --scope user --project-dir /absolute/project
91
+ ```
92
+
93
+ For Codex use `--client codex`. Claude supports `user`, `project`, and `local`;
94
+ Codex supports `user` and `project`. `CLAUDE_CONFIG_DIR` and `CODEX_HOME` are
95
+ honored. No credential-store files are read and no Keychain process is invoked.
96
+ Preview is read-only and prints a digest, not configuration contents.
97
+
98
+ Repeat with the returned digest to apply:
99
+
100
+ ```sh
101
+ ecco migrate --client claude --name ecco --scope user --project-dir /absolute/project \
102
+ --apply --expected-digest DIGEST_FROM_PREVIEW --client-closed
103
+ ```
104
+
105
+ Only the exact legacy Ecco Railway URL is replaced by
106
+ `https://api.heyecco.com/mcp`. Existing connector names, OAuth registration,
107
+ permissions, unrelated settings and formatting are retained. The canonical URL
108
+ is a no-op. Public OAuth metadata must match before a changed file is written.
109
+ Its check has a 12-second wall-clock deadline and never sends credentials.
110
+
111
+ Duplicated names across local config layers, selected profiles, known managed
112
+ files, unsafe files and changed previews stop migration. The command never edits
113
+ managed policy, trust settings or OAuth caches. On-disk discovery does not prove
114
+ the effective configuration of a running app: cloud policy, launch overrides and
115
+ desktop-specific behavior remain the native client's responsibility. If the
116
+ client still reports a different URL, inspect that client's active source; do not
117
+ delete other connectors or weaken resource validation.
118
+
119
+ Apply returns `reload_required`, **not connected**. Reload the connector. A native
120
+ OAuth client may require authorization for the canonical resource because cached
121
+ grants can be bound to the old resource or client registration. This command does
122
+ not transplant refresh tokens, trigger login or assume the old grant is valid for
123
+ the new URL. Under the no-Keychain policy, do not launch macOS Claude OAuth login
124
+ until a supported non-Keychain path has been selected. Configuration parsing,
125
+ authorization and an authenticated `list_projects` call are separate proofs.
126
+
127
+ ## Recovery
128
+
129
+ Apply retains a private mode-0600 backup beside the config and reports its path.
130
+ It may contain existing configuration secrets: never share or commit it. To undo,
131
+ preview the current file again and use its new digest:
132
+
133
+ ```sh
134
+ ecco migrate --client claude --name ecco --scope user --project-dir /absolute/project \
135
+ --rollback /absolute/config.ecco-backup-SUFFIX \
136
+ --expected-digest CURRENT_DIGEST --client-closed
137
+ ```
138
+
139
+ Rollback works offline and refuses to overwrite later edits. An `inspect_required`
140
+ result means replacement occurred but filesystem durability could not be confirmed;
141
+ inspect the file and retained backup rather than retrying blindly. Ecco's advisory
142
+ lock coordinates its own processes, not arbitrary editors.
143
+
144
+ ## Verification
145
+
146
+ ```sh
147
+ uv run ruff check .
148
+ uv run ruff format --check .
149
+ uv run python -m unittest discover -s tests -v
150
+ uv build --wheel
151
+ # macOS only, with installed Claude and Codex; no auth or model calls:
152
+ ECCO_NATIVE_CLIENT_SMOKE=1 uv run python -m unittest discover -s tests -p test_native_clients.py -v
153
+ ```
154
+
155
+ The opt-in native checks use disposable configs and deny network, Keychain and
156
+ real-client configuration access at the OS boundary. They verify parsing, not
157
+ the normal-permission end-to-end login acceptance owned by ECO-1032.
@@ -0,0 +1,12 @@
1
+ ecco_cli/__init__.py,sha256=Foo0vlpy2GeWWm3a2M39FhCX_Tk6MaGwWNyMQ7krst4,72
2
+ ecco_cli/bootstrap.py,sha256=L2N71NIBTWEP_wdaY49B6cKv9MeryZjta9uHikRtDOc,10157
3
+ ecco_cli/discovery.py,sha256=S0wYPol4VVMnEEASBGpl0694EbsIGueFWszTgP2JF8k,6148
4
+ ecco_cli/files.py,sha256=twY9biaKvs4bSPWSZr0QkMW1DJ6RIQD8Dl6OMsjmeIk,7558
5
+ ecco_cli/main.py,sha256=R3RbGn4f2AiOKEW8fZwXujmAco2w0uIuu5LH1MNO5MA,7212
6
+ ecco_cli/metadata.py,sha256=vhqHGkOx8Xj_t3lU95Pwx3GWPijGU29g66fpwkEOKXo,4465
7
+ ecco_cli/migration.py,sha256=RaVq730l-AVBOX25cEx63KLSwFS57D_aM0v7Lmh5nSY,3230
8
+ ecco_cli/serialization.py,sha256=QFcPC-nJtP3X3WrltSwX7Dn89PDi4vkZwDYLlFHLRiU,3904
9
+ heyecco-0.1.0.dist-info/METADATA,sha256=CtPgPng8TnV2-DOguJ410oyt45jVOXw_iOWEewH5C6o,6556
10
+ heyecco-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
11
+ heyecco-0.1.0.dist-info/entry_points.txt,sha256=uONFc77PgGysT4CQqqrCX5p0zuVO3ttO-zVTHNCyPtc,73
12
+ heyecco-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ ecco = ecco_cli.main:main
3
+ heyecco = ecco_cli.main:main