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 +1 -0
- ecco_cli/bootstrap.py +282 -0
- ecco_cli/discovery.py +161 -0
- ecco_cli/files.py +195 -0
- ecco_cli/main.py +178 -0
- ecco_cli/metadata.py +122 -0
- ecco_cli/migration.py +87 -0
- ecco_cli/serialization.py +110 -0
- heyecco-0.1.0.dist-info/METADATA +157 -0
- heyecco-0.1.0.dist-info/RECORD +12 -0
- heyecco-0.1.0.dist-info/WHEEL +4 -0
- heyecco-0.1.0.dist-info/entry_points.txt +3 -0
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,,
|