cinna-cli 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.
cinna/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """cinna-cli — Local development CLI for Cinna Core agents."""
2
+
3
+ __version__ = "0.1.0"
cinna/auth.py ADDED
@@ -0,0 +1,42 @@
1
+ """CLI token management — storage, header injection, validation."""
2
+
3
+ import json
4
+ import base64
5
+ import time
6
+
7
+ from cinna.config import CinnaConfig
8
+
9
+
10
+ def get_auth_headers(config: CinnaConfig) -> dict[str, str]:
11
+ """Return Authorization header dict for API calls."""
12
+ return {"Authorization": f"Bearer {config.cli_token}"}
13
+
14
+
15
+ def validate_token_locally(token: str) -> dict:
16
+ """Decode JWT without verification to check expiry.
17
+
18
+ Returns payload dict. Used for local "is this token probably expired?"
19
+ check before making API calls. The real validation happens server-side.
20
+
21
+ NOTE: This is NOT security validation — the backend validates the token.
22
+ This is a UX convenience to show a clear message instead of a 401.
23
+ """
24
+ try:
25
+ parts = token.split(".")
26
+ if len(parts) != 3:
27
+ return {}
28
+ # Add padding
29
+ payload_b64 = parts[1] + "=" * (4 - len(parts[1]) % 4)
30
+ payload = json.loads(base64.urlsafe_b64decode(payload_b64))
31
+ return payload
32
+ except Exception:
33
+ return {}
34
+
35
+
36
+ def is_token_expired(token: str) -> bool:
37
+ """Check if the JWT token is probably expired (local check only)."""
38
+ payload = validate_token_locally(token)
39
+ exp = payload.get("exp")
40
+ if exp is None:
41
+ return False
42
+ return time.time() > exp
cinna/bootstrap.py ADDED
@@ -0,0 +1,278 @@
1
+ """Setup flow: exchange token, install Mutagen, clone workspace, start sync."""
2
+
3
+ import logging
4
+ import os
5
+ import platform
6
+ import re
7
+ import sys
8
+ from pathlib import Path
9
+ from urllib.parse import urlparse
10
+
11
+ import click
12
+ import httpx
13
+
14
+ from cinna.config import (
15
+ CinnaConfig,
16
+ KnowledgeSource,
17
+ find_workspace_root,
18
+ load_config,
19
+ save_config,
20
+ upsert_agent_registry,
21
+ workspace_dir,
22
+ )
23
+ from cinna.client import PlatformClient
24
+ from cinna.sync import extract_workspace_tarball, ensure_workspace_dirs
25
+ from cinna.mutagen_runtime import ensure_mutagen_ready
26
+ from cinna import sync_session
27
+ from cinna.context import (
28
+ generate_context_files,
29
+ generate_mcp_json,
30
+ generate_opencode_json,
31
+ generate_gitignore,
32
+ )
33
+ from cinna import console
34
+
35
+ logger = logging.getLogger("cinna.bootstrap")
36
+
37
+
38
+ def parse_setup_input(
39
+ raw_input: str, fallback_platform_url: str | None = None
40
+ ) -> tuple[str, str]:
41
+ """Parse setup input into (platform_url, token).
42
+
43
+ Accepts any of:
44
+ - Full curl command: 'curl -sL http://host:8000/cli-setup/TOKEN | python3 -'
45
+ - URL: 'http://host:8000/cli-setup/TOKEN'
46
+ - Raw token: 'TOKEN' (falls back to ``fallback_platform_url`` or
47
+ the ``CINNA_PLATFORM_URL`` env var — in that order)
48
+
49
+ Returns (platform_url, token).
50
+ """
51
+ text = raw_input.strip().strip("'\"")
52
+
53
+ url_match = re.search(r"(https?://[^\s]+/cli-setup/[^\s|\"']+)", text)
54
+ if url_match:
55
+ url = url_match.group(1)
56
+ parsed = urlparse(url)
57
+ path_parts = parsed.path.rstrip("/").split("/cli-setup/")
58
+ if len(path_parts) == 2 and path_parts[1]:
59
+ token = path_parts[1]
60
+ prefix = path_parts[0]
61
+ platform_url = f"{parsed.scheme}://{parsed.netloc}{prefix}"
62
+ return platform_url, token
63
+
64
+ if text.startswith("http://") or text.startswith("https://") or "curl" in text:
65
+ raise click.ClickException(
66
+ "Could not parse setup URL from input. Expected a URL containing /cli-setup/TOKEN."
67
+ )
68
+
69
+ platform_url = fallback_platform_url or os.environ.get("CINNA_PLATFORM_URL", "")
70
+ if not platform_url:
71
+ raise click.ClickException(
72
+ "Cannot determine platform URL from the provided token.\n"
73
+ "Either paste the full curl command / URL from the platform UI,\n"
74
+ "or set the CINNA_PLATFORM_URL environment variable."
75
+ )
76
+ return platform_url, text
77
+
78
+
79
+ def normalize_agent_dir_name(name: str) -> str:
80
+ """Normalize agent name to a lowercase, dash-separated directory name."""
81
+ slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
82
+ return slug or "agent"
83
+
84
+
85
+ def _exchange_setup_token(
86
+ platform_url: str, token: str, machine_name: str
87
+ ) -> dict:
88
+ """POST /cli-setup/{token} and return the decoded payload.
89
+
90
+ Wraps the HTTP call in a uniform ClickException on failure so both
91
+ `setup` and `set-token` report errors the same way.
92
+ """
93
+ setup_url = f"{platform_url.rstrip('/')}/cli-setup/{token}"
94
+ machine_info = f"{platform.system()}/{platform.machine()}"
95
+ logger.info("Exchanging setup token at %s", setup_url)
96
+
97
+ response = httpx.post(
98
+ setup_url,
99
+ json={"machine_name": machine_name, "machine_info": machine_info},
100
+ timeout=30.0,
101
+ )
102
+ logger.debug("Setup response: %s %s", response.status_code, response.text[:500])
103
+ if response.status_code != 200:
104
+ try:
105
+ detail = response.json().get("detail", response.text)
106
+ except Exception:
107
+ detail = response.text
108
+ raise click.ClickException(f"Setup failed: {detail}")
109
+ return response.json()
110
+
111
+
112
+ def run_set_token(setup_input: str, machine_name: str) -> None:
113
+ """Replace the CLI token on an existing workspace without rebuilding.
114
+
115
+ Called by `cinna set-token <token_or_url>`. Accepts the same input forms
116
+ as `cinna setup`. Verifies the exchanged token belongs to the agent this
117
+ workspace is already bound to before writing it.
118
+ """
119
+ root = find_workspace_root()
120
+ config = load_config(root)
121
+
122
+ # ``config.platform_url`` is stored as the bare host (PlatformClient adds
123
+ # ``/api/...`` itself). The cli-setup endpoint lives under ``/api``, so
124
+ # append it here before handing to parse_setup_input as a fallback.
125
+ stored_base = config.platform_url.rstrip("/")
126
+ fallback = stored_base if stored_base.endswith("/api") else f"{stored_base}/api"
127
+ platform_url, token = parse_setup_input(
128
+ setup_input, fallback_platform_url=fallback
129
+ )
130
+ payload = _exchange_setup_token(platform_url, token, machine_name)
131
+
132
+ new_agent_id = payload["agent"]["id"]
133
+ if new_agent_id != config.agent_id:
134
+ raise click.ClickException(
135
+ f"Token belongs to a different agent ({new_agent_id}) than this "
136
+ f"workspace ({config.agent_id}). Run 'cinna setup' in a new "
137
+ f"directory to register it."
138
+ )
139
+
140
+ config.cli_token = payload["cli_token"]
141
+ config.platform_url = payload["platform_url"]
142
+ if payload.get("frontend_url"):
143
+ config.frontend_url = payload["frontend_url"]
144
+ save_config(config, root)
145
+ upsert_agent_registry(
146
+ config.agent_id,
147
+ config.platform_url,
148
+ config.cli_token,
149
+ root,
150
+ frontend_url=config.frontend_url,
151
+ )
152
+ console.status(f"Token refreshed for agent: {config.agent_name}")
153
+
154
+
155
+ def run_setup(setup_input: str, machine_name: str) -> None:
156
+ """Full setup flow — called by `cinna setup <token_or_url>`."""
157
+ total = 5
158
+
159
+ # Step 1: Authenticate
160
+ console.step(1, total, "Authenticating...")
161
+
162
+ platform_url, token = parse_setup_input(setup_input)
163
+ payload = _exchange_setup_token(platform_url, token, machine_name)
164
+ agent_info = payload["agent"]
165
+ agent_name = agent_info["name"]
166
+ dir_name = normalize_agent_dir_name(agent_name)
167
+ logger.info("Agent: %s (dir: %s)", agent_name, dir_name)
168
+
169
+ workspace_root = Path.cwd() / dir_name
170
+ if (workspace_root / ".cinna" / "config.json").exists():
171
+ raise click.ClickException(
172
+ f"Directory '{dir_name}/' already contains a cinna workspace.\n"
173
+ f"Remove it first with 'cinna disconnect' or delete the directory."
174
+ )
175
+ workspace_root.mkdir(exist_ok=True)
176
+
177
+ config = CinnaConfig(
178
+ platform_url=payload["platform_url"],
179
+ cli_token=payload["cli_token"],
180
+ agent_id=agent_info["id"],
181
+ agent_name=agent_name,
182
+ environment_id=agent_info["environment_id"],
183
+ template=agent_info["template"],
184
+ frontend_url=payload.get("frontend_url"),
185
+ knowledge_sources=[
186
+ KnowledgeSource(**ks) for ks in payload.get("knowledge_sources", [])
187
+ ],
188
+ )
189
+ save_config(config, workspace_root)
190
+ upsert_agent_registry(
191
+ config.agent_id,
192
+ config.platform_url,
193
+ config.cli_token,
194
+ workspace_root,
195
+ frontend_url=config.frontend_url,
196
+ )
197
+ console.status(f"Authenticated as agent: {agent_name}")
198
+
199
+ client = PlatformClient(config)
200
+ try:
201
+ # Step 2: Mutagen
202
+ console.step(2, total, "Checking Mutagen install...")
203
+ ensure_mutagen_ready(
204
+ client, config, workspace_root, interactive=sys.stdin.isatty()
205
+ )
206
+ console.status(f"Mutagen ready (version {config.mutagen_version})")
207
+
208
+ # Step 3: Initial clone
209
+ console.step(3, total, "Cloning workspace...")
210
+ ws_dir = workspace_dir(workspace_root)
211
+ ws_dir.mkdir(exist_ok=True)
212
+ try:
213
+ logger.info("Downloading workspace for agent %s", config.agent_id)
214
+ ws_tarball = client.download_workspace(config.agent_id)
215
+ logger.info("Workspace downloaded (%d bytes)", len(ws_tarball))
216
+ extract_workspace_tarball(ws_tarball, ws_dir)
217
+ console.status("Workspace cloned")
218
+ except Exception as e:
219
+ logger.warning("Workspace download failed: %s", e)
220
+ console.warn(f"Workspace download failed: {e}")
221
+ console.warn("Mutagen will reconcile on first sync start.")
222
+ ensure_workspace_dirs(ws_dir)
223
+
224
+ # Step 4: Context files + MCP config
225
+ console.step(4, total, "Configuring development environment...")
226
+ try:
227
+ building_ctx = client.get_building_context(config.agent_id)
228
+ generate_context_files(building_ctx, config, workspace_root)
229
+ except Exception as e:
230
+ logger.warning("Building context fetch failed: %s", e)
231
+ console.warn(f"Building context fetch failed: {e}")
232
+
233
+ generate_mcp_json(config, workspace_root)
234
+ generate_opencode_json(config, workspace_root)
235
+ generate_gitignore(workspace_root)
236
+
237
+ # Step 5: Start continuous sync (foreground — blocks until Ctrl-C)
238
+ console.step(5, total, "Starting continuous sync...")
239
+ sync_session.write_mutagen_yml(workspace_root)
240
+ sync_started = False
241
+ try:
242
+ sync_session.start(config, workspace_root)
243
+ sync_started = True
244
+ console.status("Sync session started")
245
+ except click.ClickException as e:
246
+ logger.warning("Sync start failed: %s", e.format_message())
247
+ console.warn(f"Sync start failed: {e.format_message()}")
248
+ console.warn("Run 'cinna dev' from the agent directory to retry.")
249
+
250
+ console.status("Setup complete!")
251
+ console.console.print()
252
+ console.console.print(f" cd {dir_name}/")
253
+ console.console.print(
254
+ " cinna dev # start a foreground dev session"
255
+ )
256
+ console.console.print(
257
+ " claude # open Claude Code with MCP tools"
258
+ )
259
+ console.console.print(
260
+ " cinna list # see all registered agents"
261
+ )
262
+ console.console.print(
263
+ " cinna sync status # view sync state (from another terminal)"
264
+ )
265
+ console.console.print(
266
+ " cinna exec python scripts/main.py # run a command in the remote env"
267
+ )
268
+ console.console.print()
269
+
270
+ # Attach the foreground sync TUI. Sync lives exactly as long as this
271
+ # process — Ctrl-C terminates the session so nothing is left dangling
272
+ # in the shared Mutagen daemon.
273
+ if sync_started:
274
+ console.status("Live sync attached — press Ctrl-C to stop.")
275
+ sync_session.run_foreground(config)
276
+ console.status("Sync session terminated.")
277
+ finally:
278
+ client.close()
cinna/client.py ADDED
@@ -0,0 +1,169 @@
1
+ """HTTP client for platform API. All backend communication goes through here."""
2
+
3
+ import json
4
+ import logging
5
+ from typing import Iterator
6
+
7
+ import httpx
8
+
9
+ from cinna.config import CinnaConfig
10
+ from cinna.auth import get_auth_headers
11
+ from cinna.errors import AuthenticationError, PlatformError
12
+
13
+ logger = logging.getLogger("cinna.client")
14
+
15
+ DEFAULT_TIMEOUT = httpx.Timeout(30.0, connect=10.0)
16
+ DOWNLOAD_TIMEOUT = httpx.Timeout(300.0, connect=10.0)
17
+ # Exec streams can be long-running — disable read timeout so idle output doesn't abort.
18
+ EXEC_STREAM_TIMEOUT = httpx.Timeout(None, connect=10.0)
19
+
20
+
21
+ class PlatformClient:
22
+ """HTTP client wrapping httpx with CLI token authentication."""
23
+
24
+ def __init__(self, config: CinnaConfig):
25
+ self.config = config
26
+ self.base_url = config.platform_url.rstrip("/")
27
+ self._client = httpx.Client(
28
+ base_url=self.base_url,
29
+ headers=get_auth_headers(config),
30
+ timeout=DEFAULT_TIMEOUT,
31
+ follow_redirects=True,
32
+ )
33
+
34
+ def _handle_response(self, response: httpx.Response) -> httpx.Response:
35
+ """Check response status. Raise typed exceptions for known error codes."""
36
+ logger.debug(
37
+ "%s %s -> %s (%d bytes)",
38
+ response.request.method,
39
+ response.request.url,
40
+ response.status_code,
41
+ len(response.content),
42
+ )
43
+ if response.status_code == 401:
44
+ detail = ""
45
+ try:
46
+ detail = response.json().get("detail", "")
47
+ except Exception:
48
+ pass
49
+ logger.error("Authentication failed: %s", detail)
50
+ raise AuthenticationError(detail)
51
+ if response.status_code == 404:
52
+ logger.error("Resource not found: %s", response.request.url)
53
+ raise PlatformError(404, "Agent not found. It may have been deleted.")
54
+ if response.status_code >= 400:
55
+ try:
56
+ detail = response.json().get("detail", response.text)
57
+ except Exception:
58
+ detail = response.text
59
+ logger.error(
60
+ "Platform error %s: %s (url: %s, body: %.500s)",
61
+ response.status_code,
62
+ detail,
63
+ response.request.url,
64
+ response.text,
65
+ )
66
+ raise PlatformError(response.status_code, detail)
67
+ return response
68
+
69
+ # --- Setup (no auth) ---
70
+
71
+ def exchange_setup_token(
72
+ self, token: str, machine_name: str, machine_info: str
73
+ ) -> dict:
74
+ """POST /api/cli-setup/{token} — exchange setup token for bootstrap payload."""
75
+ response = httpx.post(
76
+ f"{self.base_url}/api/cli-setup/{token}",
77
+ json={"machine_name": machine_name, "machine_info": machine_info},
78
+ timeout=DEFAULT_TIMEOUT,
79
+ )
80
+ return self._handle_response(response).json()
81
+
82
+ # --- Workspace (initial clone only; Mutagen owns it afterwards) ---
83
+
84
+ def download_workspace(self, agent_id: str) -> bytes:
85
+ """GET /api/v1/cli/agents/{id}/workspace — one-shot tarball for initial clone."""
86
+ response = self._client.get(
87
+ f"/api/v1/cli/agents/{agent_id}/workspace",
88
+ timeout=DOWNLOAD_TIMEOUT,
89
+ )
90
+ return self._handle_response(response).content
91
+
92
+ # --- Building Context ---
93
+
94
+ def get_building_context(self, agent_id: str) -> dict:
95
+ """GET /api/v1/cli/agents/{id}/building-context — assembled prompt + settings."""
96
+ response = self._client.get(
97
+ f"/api/v1/cli/agents/{agent_id}/building-context",
98
+ timeout=DOWNLOAD_TIMEOUT,
99
+ )
100
+ return self._handle_response(response).json()
101
+
102
+ # --- Knowledge ---
103
+
104
+ def search_knowledge(
105
+ self, agent_id: str, query: str, topic: str | None = None
106
+ ) -> dict:
107
+ """POST /api/v1/cli/agents/{id}/knowledge/search — search knowledge base."""
108
+ payload: dict = {"query": query}
109
+ if topic:
110
+ payload["topic"] = topic
111
+ response = self._client.post(
112
+ f"/api/v1/cli/agents/{agent_id}/knowledge/search",
113
+ json=payload,
114
+ )
115
+ return self._handle_response(response).json()
116
+
117
+ # --- Live Sync Runtime ---
118
+
119
+ def get_sync_runtime(self, agent_id: str) -> dict:
120
+ """GET /api/v1/cli/agents/{id}/sync-runtime — required Mutagen version + hash."""
121
+ response = self._client.get(
122
+ f"/api/v1/cli/agents/{agent_id}/sync-runtime",
123
+ )
124
+ return self._handle_response(response).json()
125
+
126
+ # --- Remote exec (SSE stream) ---
127
+
128
+ def stream_exec(self, agent_id: str, command: str) -> Iterator[dict]:
129
+ """POST /api/v1/cli/agents/{id}/exec — stream command output events.
130
+
131
+ Yields parsed event dicts. Known shapes:
132
+ {"type": "exec_id", "exec_id": "<uuid>"}
133
+ {"type": "tool_result_delta", "content": "...", "metadata": {...}}
134
+ {"type": "done", "exit_code": N, "duration_seconds": F}
135
+ {"type": "interrupted", "exit_code": -1}
136
+ {"type": "error", "content": "..."}
137
+
138
+ The caller is responsible for interpreting `done`/`interrupted` and
139
+ mapping to a process exit code.
140
+ """
141
+ url = f"/api/v1/cli/agents/{agent_id}/exec"
142
+ payload = {"command": command}
143
+ with self._client.stream(
144
+ "POST", url, json=payload, timeout=EXEC_STREAM_TIMEOUT
145
+ ) as response:
146
+ if response.status_code >= 400:
147
+ # Read the body so _handle_response can surface the error.
148
+ response.read()
149
+ self._handle_response(response)
150
+ return
151
+
152
+ for line in response.iter_lines():
153
+ if not line:
154
+ continue
155
+ if line.startswith("data: "):
156
+ data_str = line[6:]
157
+ try:
158
+ yield json.loads(data_str)
159
+ except json.JSONDecodeError:
160
+ logger.warning("Could not parse SSE event: %s", data_str[:200])
161
+
162
+ def close(self):
163
+ self._client.close()
164
+
165
+ def __enter__(self):
166
+ return self
167
+
168
+ def __exit__(self, *args):
169
+ self.close()
cinna/config.py ADDED
@@ -0,0 +1,193 @@
1
+ """Manages .cinna/config.json — the single source of truth for CLI state."""
2
+
3
+ import json
4
+ import os
5
+ import threading
6
+ from pathlib import Path
7
+ from dataclasses import dataclass, field, asdict
8
+
9
+ from cinna.errors import ConfigNotFoundError
10
+
11
+ CONFIG_DIR = ".cinna"
12
+ CONFIG_FILE = "config.json"
13
+ BUILD_DIR = "build"
14
+
15
+ # Global per-user state — lives outside any single workspace so that one
16
+ # Mutagen daemon can serve multiple agent syncs concurrently. The SSH shim
17
+ # reads `agents.json` to resolve the CLI token / platform URL for whichever
18
+ # agent Mutagen is asking it to connect to on each invocation.
19
+ GLOBAL_STATE_DIR = Path.home() / ".cinna"
20
+ AGENTS_REGISTRY_FILE = "agents.json"
21
+
22
+
23
+ @dataclass
24
+ class KnowledgeSource:
25
+ id: str
26
+ name: str
27
+ topics: list[str]
28
+
29
+
30
+ @dataclass
31
+ class CinnaConfig:
32
+ platform_url: str
33
+ cli_token: str
34
+ agent_id: str
35
+ agent_name: str
36
+ environment_id: str
37
+ template: str
38
+ # User-facing frontend URL (the platform's web UI). Set by the bootstrap
39
+ # exchange response; falls back to ``platform_url`` for backwards compat
40
+ # with configs written before this field existed.
41
+ frontend_url: str | None = None
42
+ knowledge_sources: list[KnowledgeSource] = field(default_factory=list)
43
+ mutagen_version: str | None = None
44
+ last_sync_runtime_check_at: str | None = None
45
+ last_sync_connected_at: str | None = None
46
+
47
+
48
+ def find_workspace_root(start: Path | None = None) -> Path:
49
+ """Walk up from start (or cwd) looking for .cinna/config.json.
50
+
51
+ Returns the workspace root directory (parent of .cinna/).
52
+ Raises ConfigNotFoundError if not found.
53
+ """
54
+ current = (start or Path.cwd()).resolve()
55
+ while True:
56
+ if (current / CONFIG_DIR / CONFIG_FILE).is_file():
57
+ return current
58
+ parent = current.parent
59
+ if parent == current:
60
+ raise ConfigNotFoundError()
61
+ current = parent
62
+
63
+
64
+ def load_config(workspace_root: Path | None = None) -> CinnaConfig:
65
+ """Load and validate config from .cinna/config.json."""
66
+ if workspace_root is None:
67
+ workspace_root = find_workspace_root()
68
+ config_path = workspace_root / CONFIG_DIR / CONFIG_FILE
69
+ if not config_path.is_file():
70
+ raise ConfigNotFoundError()
71
+ data = json.loads(config_path.read_text())
72
+ ks_list = [KnowledgeSource(**ks) for ks in data.pop("knowledge_sources", [])]
73
+ # Tolerate legacy fields (e.g. container_name from pre-live-sync configs).
74
+ known_fields = {f for f in CinnaConfig.__dataclass_fields__ if f != "knowledge_sources"}
75
+ data = {k: v for k, v in data.items() if k in known_fields}
76
+ return CinnaConfig(**data, knowledge_sources=ks_list)
77
+
78
+
79
+ def save_config(config: CinnaConfig, workspace_root: Path) -> None:
80
+ """Write config to .cinna/config.json."""
81
+ cfg_dir = workspace_root / CONFIG_DIR
82
+ cfg_dir.mkdir(parents=True, exist_ok=True)
83
+ data = asdict(config)
84
+ (cfg_dir / CONFIG_FILE).write_text(json.dumps(data, indent=2) + "\n")
85
+
86
+
87
+ def config_dir(workspace_root: Path) -> Path:
88
+ """Return path to .cinna/ directory."""
89
+ return workspace_root / CONFIG_DIR
90
+
91
+
92
+ def workspace_dir(workspace_root: Path) -> Path:
93
+ """Return path to workspace/ directory."""
94
+ return workspace_root / "workspace"
95
+
96
+
97
+ def build_dir(workspace_root: Path) -> Path:
98
+ """Return path to .cinna/build/ directory.
99
+
100
+ Historically held the Docker build context. In live-sync mode the directory
101
+ is usually absent; the helper is retained so any prompt reference docs that
102
+ do land there continue to be discovered.
103
+ """
104
+ return config_dir(workspace_root) / BUILD_DIR
105
+
106
+
107
+ # ── Global agent registry ────────────────────────────────────────────────
108
+ #
109
+ # `~/.cinna/agents.json` maps agent_id → {platform_url, cli_token,
110
+ # workspace_path}. The SSH shim reads this on every Mutagen SSH invocation
111
+ # to resolve per-agent credentials; needed because a single Mutagen daemon
112
+ # serves SSH subprocesses for every agent the user has synced, and the
113
+ # daemon's own env is captured once at start.
114
+
115
+ _registry_lock = threading.Lock()
116
+
117
+
118
+ def agents_registry_path() -> Path:
119
+ return GLOBAL_STATE_DIR / AGENTS_REGISTRY_FILE
120
+
121
+
122
+ def _read_registry() -> dict:
123
+ path = agents_registry_path()
124
+ if not path.is_file():
125
+ return {}
126
+ try:
127
+ data = json.loads(path.read_text())
128
+ except (OSError, json.JSONDecodeError):
129
+ return {}
130
+ return data if isinstance(data, dict) else {}
131
+
132
+
133
+ def _write_registry(data: dict) -> None:
134
+ path = agents_registry_path()
135
+ path.parent.mkdir(parents=True, exist_ok=True)
136
+ tmp = path.with_suffix(".tmp")
137
+ tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
138
+ # Restrict perms: the file holds long-lived CLI JWTs.
139
+ try:
140
+ os.chmod(tmp, 0o600)
141
+ except OSError:
142
+ pass
143
+ tmp.replace(path)
144
+
145
+
146
+ def upsert_agent_registry(
147
+ agent_id: str,
148
+ platform_url: str,
149
+ cli_token: str,
150
+ workspace_path: Path,
151
+ frontend_url: str | None = None,
152
+ ) -> None:
153
+ """Register or refresh an agent's credentials in the global registry.
154
+
155
+ ``frontend_url`` is optional for backwards compatibility with callers
156
+ written before the field existed; ``cinna list`` will fall back to
157
+ ``platform_url`` when it's missing.
158
+ """
159
+ with _registry_lock:
160
+ data = _read_registry()
161
+ entry = {
162
+ "platform_url": platform_url,
163
+ "cli_token": cli_token,
164
+ "workspace_path": str(workspace_path),
165
+ }
166
+ if frontend_url:
167
+ entry["frontend_url"] = frontend_url
168
+ data[agent_id] = entry
169
+ _write_registry(data)
170
+
171
+
172
+ def remove_agent_registry(agent_id: str) -> None:
173
+ """Drop an agent's entry. No-op if it wasn't present."""
174
+ with _registry_lock:
175
+ data = _read_registry()
176
+ if agent_id in data:
177
+ del data[agent_id]
178
+ _write_registry(data)
179
+
180
+
181
+ def lookup_agent_registry(agent_id: str) -> dict | None:
182
+ """Return the registry entry for an agent, or None."""
183
+ return _read_registry().get(agent_id)
184
+
185
+
186
+ def list_agent_registry() -> list[dict]:
187
+ """Return every registered agent as a list of dicts, sorted by agent_id.
188
+
189
+ Each entry contains ``agent_id`` plus the registry fields
190
+ (``platform_url``, ``cli_token``, ``workspace_path``).
191
+ """
192
+ registry = _read_registry()
193
+ return [{"agent_id": aid, **entry} for aid, entry in sorted(registry.items())]