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 +3 -0
- cinna/auth.py +42 -0
- cinna/bootstrap.py +278 -0
- cinna/client.py +169 -0
- cinna/config.py +193 -0
- cinna/console.py +39 -0
- cinna/context.py +216 -0
- cinna/errors.py +56 -0
- cinna/logging.py +38 -0
- cinna/main.py +715 -0
- cinna/mcp_proxy.py +151 -0
- cinna/mutagen_runtime.py +168 -0
- cinna/sync.py +120 -0
- cinna/sync_session.py +418 -0
- cinna/sync_ssh_shim.py +232 -0
- cinna/sync_tui.py +352 -0
- cinna/templates/CLAUDE.md.template +558 -0
- cinna/templates/__init__.py +0 -0
- cinna_cli-0.1.0.dist-info/METADATA +231 -0
- cinna_cli-0.1.0.dist-info/RECORD +23 -0
- cinna_cli-0.1.0.dist-info/WHEEL +4 -0
- cinna_cli-0.1.0.dist-info/entry_points.txt +3 -0
- cinna_cli-0.1.0.dist-info/licenses/LICENSE.md +21 -0
cinna/__init__.py
ADDED
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())]
|