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/main.py
ADDED
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
"""cinna CLI — local development for Cinna Core agents."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from cinna import __version__
|
|
12
|
+
from cinna import console
|
|
13
|
+
from cinna import sync_session
|
|
14
|
+
from cinna.client import PlatformClient
|
|
15
|
+
from cinna.config import (
|
|
16
|
+
find_workspace_root,
|
|
17
|
+
list_agent_registry,
|
|
18
|
+
load_config,
|
|
19
|
+
remove_agent_registry,
|
|
20
|
+
)
|
|
21
|
+
from cinna.mcp_proxy import run_mcp_proxy
|
|
22
|
+
from cinna.mutagen_runtime import ensure_mutagen_ready
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@click.group()
|
|
26
|
+
@click.version_option(version=__version__)
|
|
27
|
+
@click.option("-v", "--verbose", is_flag=True, help="Show debug logs in terminal")
|
|
28
|
+
def cli(verbose: bool):
|
|
29
|
+
"""Local development CLI for Cinna Core agents."""
|
|
30
|
+
from cinna.logging import setup_logging
|
|
31
|
+
|
|
32
|
+
setup_logging(verbose=verbose)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ─── setup ─────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@cli.command(context_settings={"ignore_unknown_options": True})
|
|
39
|
+
@click.argument("setup_input", nargs=-1, type=click.UNPROCESSED, required=True)
|
|
40
|
+
@click.option(
|
|
41
|
+
"--name",
|
|
42
|
+
default=None,
|
|
43
|
+
help="Name for this development session",
|
|
44
|
+
)
|
|
45
|
+
def setup(setup_input: tuple[str, ...], name: str | None):
|
|
46
|
+
"""Set up local development environment for an agent.
|
|
47
|
+
|
|
48
|
+
Accepts any of these formats (paste directly from the platform UI):
|
|
49
|
+
|
|
50
|
+
\b
|
|
51
|
+
cinna setup curl -sL http://host/api/cli-setup/TOKEN | python3 -
|
|
52
|
+
cinna setup http://host/api/cli-setup/TOKEN
|
|
53
|
+
cinna setup TOKEN
|
|
54
|
+
"""
|
|
55
|
+
from cinna.bootstrap import run_setup
|
|
56
|
+
|
|
57
|
+
if name is None:
|
|
58
|
+
default_name = _default_machine_name()
|
|
59
|
+
if sys.stdin.isatty():
|
|
60
|
+
name = click.prompt("Machine name", default=default_name)
|
|
61
|
+
else:
|
|
62
|
+
name = default_name
|
|
63
|
+
|
|
64
|
+
run_setup(" ".join(setup_input), name)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ─── set-token ─────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@cli.command(name="set-token", context_settings={"ignore_unknown_options": True})
|
|
71
|
+
@click.argument("setup_input", nargs=-1, type=click.UNPROCESSED, required=True)
|
|
72
|
+
@click.option(
|
|
73
|
+
"--name",
|
|
74
|
+
default=None,
|
|
75
|
+
help="Machine name to register with the refreshed token",
|
|
76
|
+
)
|
|
77
|
+
def set_token(setup_input: tuple[str, ...], name: str | None):
|
|
78
|
+
"""Refresh the CLI token on the current workspace.
|
|
79
|
+
|
|
80
|
+
Useful when the stored token has expired — swaps ``cli_token`` in
|
|
81
|
+
``.cinna/config.json`` and ``~/.cinna/agents.json`` in place, without
|
|
82
|
+
re-cloning the workspace or regenerating context files. Must be run from
|
|
83
|
+
inside an existing cinna workspace, and the token must belong to the same
|
|
84
|
+
agent.
|
|
85
|
+
|
|
86
|
+
Accepts any of these formats (paste directly from the platform UI):
|
|
87
|
+
|
|
88
|
+
\b
|
|
89
|
+
cinna set-token curl -sL http://host/api/cli-setup/TOKEN | python3 -
|
|
90
|
+
cinna set-token http://host/api/cli-setup/TOKEN
|
|
91
|
+
cinna set-token TOKEN
|
|
92
|
+
"""
|
|
93
|
+
from cinna.bootstrap import run_set_token
|
|
94
|
+
|
|
95
|
+
if name is None:
|
|
96
|
+
default_name = _default_machine_name()
|
|
97
|
+
if sys.stdin.isatty():
|
|
98
|
+
name = click.prompt("Machine name", default=default_name)
|
|
99
|
+
else:
|
|
100
|
+
name = default_name
|
|
101
|
+
|
|
102
|
+
run_set_token(" ".join(setup_input), name)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ─── exec ──────────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@cli.command(name="exec", context_settings={"ignore_unknown_options": True})
|
|
109
|
+
@click.argument("command", nargs=-1, required=True)
|
|
110
|
+
def exec_cmd(command: tuple[str, ...]):
|
|
111
|
+
"""Run a command in the remote agent environment.
|
|
112
|
+
|
|
113
|
+
Output streams back in real time via the platform. Exit code matches the
|
|
114
|
+
remote process's exit code. Ctrl+C aborts the stream.
|
|
115
|
+
|
|
116
|
+
Examples:
|
|
117
|
+
cinna exec python scripts/main.py
|
|
118
|
+
cinna exec pip install pandas
|
|
119
|
+
cinna exec bash -c 'ls -la'
|
|
120
|
+
"""
|
|
121
|
+
root = find_workspace_root()
|
|
122
|
+
config = load_config(root)
|
|
123
|
+
|
|
124
|
+
exit_code = _run_remote_exec(config, " ".join(command))
|
|
125
|
+
sys.exit(exit_code)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _run_remote_exec(config, command_str: str) -> int:
|
|
129
|
+
"""Drive the /exec SSE stream and mirror events to the local terminal."""
|
|
130
|
+
exit_code = 0
|
|
131
|
+
with PlatformClient(config) as client:
|
|
132
|
+
try:
|
|
133
|
+
for event in client.stream_exec(config.agent_id, command_str):
|
|
134
|
+
etype = event.get("type")
|
|
135
|
+
if etype == "exec_id":
|
|
136
|
+
# First event — nothing to print. Remember it in case we
|
|
137
|
+
# later ship an /exec-interrupt endpoint.
|
|
138
|
+
continue
|
|
139
|
+
if etype == "tool_result_delta":
|
|
140
|
+
chunk = event.get("content", "")
|
|
141
|
+
stream = event.get("metadata", {}).get("stream", "stdout")
|
|
142
|
+
target = sys.stderr if stream == "stderr" else sys.stdout
|
|
143
|
+
target.write(chunk)
|
|
144
|
+
target.flush()
|
|
145
|
+
elif etype == "done":
|
|
146
|
+
exit_code = int(event.get("exit_code", 0))
|
|
147
|
+
elif etype == "interrupted":
|
|
148
|
+
exit_code = int(event.get("exit_code", 130))
|
|
149
|
+
elif etype == "error":
|
|
150
|
+
console.error(event.get("content", "unknown error"))
|
|
151
|
+
exit_code = 1
|
|
152
|
+
except KeyboardInterrupt:
|
|
153
|
+
exit_code = 130
|
|
154
|
+
return exit_code
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ─── status ────────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@cli.command()
|
|
161
|
+
def status():
|
|
162
|
+
"""Show agent info and current sync state."""
|
|
163
|
+
root = find_workspace_root()
|
|
164
|
+
config = load_config(root)
|
|
165
|
+
|
|
166
|
+
from rich.table import Table
|
|
167
|
+
|
|
168
|
+
st = sync_session.status(config)
|
|
169
|
+
|
|
170
|
+
with console.spinner("Checking token..."):
|
|
171
|
+
token_status = _probe_token_statuses(
|
|
172
|
+
[
|
|
173
|
+
{
|
|
174
|
+
"agent_id": config.agent_id,
|
|
175
|
+
"platform_url": config.platform_url,
|
|
176
|
+
"cli_token": config.cli_token,
|
|
177
|
+
}
|
|
178
|
+
]
|
|
179
|
+
).get(config.agent_id, "unknown")
|
|
180
|
+
|
|
181
|
+
table = Table(title=f"Agent: {config.agent_name}")
|
|
182
|
+
table.add_column("Property", style="dim")
|
|
183
|
+
table.add_column("Value")
|
|
184
|
+
table.add_row("Platform", config.platform_url)
|
|
185
|
+
table.add_row("Agent ID", config.agent_id)
|
|
186
|
+
table.add_row("Template", config.template)
|
|
187
|
+
table.add_row("Mutagen", config.mutagen_version or "—")
|
|
188
|
+
table.add_row("Sync state", _colored_state(st.state))
|
|
189
|
+
table.add_row("Token", _format_token_label(token_status))
|
|
190
|
+
table.add_row("Pending → remote", str(st.pending_to_remote))
|
|
191
|
+
table.add_row("Pending → local", str(st.pending_to_local))
|
|
192
|
+
table.add_row("Conflicts", str(st.conflict_count))
|
|
193
|
+
if st.last_error:
|
|
194
|
+
table.add_row("Last error", f"[red]{st.last_error}[/red]")
|
|
195
|
+
|
|
196
|
+
console.console.print(table)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _colored_state(state: str) -> str:
|
|
200
|
+
if state == "connected":
|
|
201
|
+
return "[green]connected[/green]"
|
|
202
|
+
if state == "paused":
|
|
203
|
+
return "[yellow]paused[/yellow]"
|
|
204
|
+
if state in {"error", "missing"}:
|
|
205
|
+
return f"[red]{state}[/red]"
|
|
206
|
+
return state
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ─── sync group ────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@cli.command("list")
|
|
213
|
+
def list_cmd():
|
|
214
|
+
"""List every agent registered on this machine.
|
|
215
|
+
|
|
216
|
+
Reads ``~/.cinna/agents.json`` — the same registry the SSH shim uses to
|
|
217
|
+
resolve per-agent credentials. For each agent the table shows agent ID,
|
|
218
|
+
the web UI link, workspace path, current sync state, and whether the
|
|
219
|
+
stored CLI token is still accepted by the backend. Workspace directories
|
|
220
|
+
that no longer exist are flagged as missing (they can be cleaned up with
|
|
221
|
+
``cinna disconnect`` from the parent directory).
|
|
222
|
+
"""
|
|
223
|
+
from rich.table import Table
|
|
224
|
+
|
|
225
|
+
entries = list_agent_registry()
|
|
226
|
+
if not entries:
|
|
227
|
+
console.status(
|
|
228
|
+
"No agents registered yet. Run the setup curl command to register one."
|
|
229
|
+
)
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
# Cheap one-shot lookup: index Mutagen sessions by session name so we can
|
|
233
|
+
# report per-agent sync state without a daemon round-trip per row.
|
|
234
|
+
# Fails silently if the daemon isn't running — sync just reads "–".
|
|
235
|
+
sessions_by_name: dict[str, dict] = {}
|
|
236
|
+
try:
|
|
237
|
+
# sync_session._list_sessions needs a CinnaConfig for env vars. Build
|
|
238
|
+
# a throwaway one off the first entry; MUTAGEN_SSH_PATH is the only
|
|
239
|
+
# env var that matters for `sync list` and it's the same for every
|
|
240
|
+
# agent on this machine.
|
|
241
|
+
from cinna.sync_session import _list_sessions, CinnaConfig as _Cfg
|
|
242
|
+
|
|
243
|
+
probe_entry = entries[0]
|
|
244
|
+
probe = _Cfg(
|
|
245
|
+
platform_url=probe_entry.get("platform_url", ""),
|
|
246
|
+
cli_token=probe_entry.get("cli_token", ""),
|
|
247
|
+
agent_id=probe_entry["agent_id"],
|
|
248
|
+
agent_name="",
|
|
249
|
+
environment_id="",
|
|
250
|
+
template="",
|
|
251
|
+
)
|
|
252
|
+
for s in _list_sessions(probe):
|
|
253
|
+
name = s.get("name")
|
|
254
|
+
if name:
|
|
255
|
+
sessions_by_name[name] = s
|
|
256
|
+
except Exception:
|
|
257
|
+
pass
|
|
258
|
+
|
|
259
|
+
with console.spinner("Checking tokens..."):
|
|
260
|
+
token_statuses = _probe_token_statuses(entries)
|
|
261
|
+
|
|
262
|
+
table = Table(
|
|
263
|
+
title=f"Registered agents ({len(entries)})",
|
|
264
|
+
title_style="bold",
|
|
265
|
+
show_lines=True,
|
|
266
|
+
)
|
|
267
|
+
table.add_column("#", style="dim", justify="right")
|
|
268
|
+
table.add_column("Agent")
|
|
269
|
+
table.add_column("Location")
|
|
270
|
+
table.add_column("Sync")
|
|
271
|
+
|
|
272
|
+
for i, entry in enumerate(entries, 1):
|
|
273
|
+
agent_id = entry["agent_id"]
|
|
274
|
+
platform_url = entry.get("platform_url", "")
|
|
275
|
+
frontend_url = entry.get("frontend_url") or platform_url
|
|
276
|
+
workspace_path = Path(entry.get("workspace_path", ""))
|
|
277
|
+
|
|
278
|
+
# Default display = short agent_id; enrich with the agent's display
|
|
279
|
+
# name if the workspace's .cinna/config.json is still intact.
|
|
280
|
+
display_name = agent_id[:8]
|
|
281
|
+
if workspace_path and workspace_path.exists():
|
|
282
|
+
ws_display = str(workspace_path)
|
|
283
|
+
try:
|
|
284
|
+
cfg = load_config(workspace_path)
|
|
285
|
+
display_name = cfg.agent_name
|
|
286
|
+
except Exception:
|
|
287
|
+
pass
|
|
288
|
+
else:
|
|
289
|
+
ws_display = f"[red]missing:[/red] {workspace_path or '?'}"
|
|
290
|
+
|
|
291
|
+
agent_link = (
|
|
292
|
+
f"{frontend_url.rstrip('/')}/agent/{agent_id}" if frontend_url else "?"
|
|
293
|
+
)
|
|
294
|
+
sync_cell = _format_sync_cell(
|
|
295
|
+
agent_id, sessions_by_name, token_statuses.get(agent_id, "unknown")
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
agent_cell = f"[bold]{display_name}[/bold]\n[dim]{agent_id}[/dim]"
|
|
299
|
+
location_cell = f"{ws_display}\n[dim]{agent_link}[/dim]"
|
|
300
|
+
|
|
301
|
+
table.add_row(
|
|
302
|
+
str(i),
|
|
303
|
+
agent_cell,
|
|
304
|
+
location_cell,
|
|
305
|
+
sync_cell,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
console.console.print(table)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _probe_token_statuses(entries: list[dict]) -> dict[str, str]:
|
|
312
|
+
"""Check each agent's backend in parallel and classify the CLI token.
|
|
313
|
+
|
|
314
|
+
Returns a mapping ``agent_id -> status`` where status is one of:
|
|
315
|
+
- ``valid`` — backend answered 2xx
|
|
316
|
+
- ``expired`` — backend answered 401
|
|
317
|
+
- ``unreachable`` — connection/timeout/other error
|
|
318
|
+
"""
|
|
319
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
320
|
+
|
|
321
|
+
def probe(entry: dict) -> tuple[str, str]:
|
|
322
|
+
agent_id = entry["agent_id"]
|
|
323
|
+
platform_url = (entry.get("platform_url") or "").rstrip("/")
|
|
324
|
+
cli_token = entry.get("cli_token") or ""
|
|
325
|
+
if not platform_url or not cli_token:
|
|
326
|
+
return agent_id, "unreachable"
|
|
327
|
+
try:
|
|
328
|
+
import httpx
|
|
329
|
+
|
|
330
|
+
response = httpx.get(
|
|
331
|
+
f"{platform_url}/api/v1/cli/agents/{agent_id}/sync-runtime",
|
|
332
|
+
headers={"Authorization": f"Bearer {cli_token}"},
|
|
333
|
+
timeout=httpx.Timeout(5.0, connect=3.0),
|
|
334
|
+
follow_redirects=True,
|
|
335
|
+
)
|
|
336
|
+
except Exception:
|
|
337
|
+
return agent_id, "unreachable"
|
|
338
|
+
if response.status_code == 401:
|
|
339
|
+
return agent_id, "expired"
|
|
340
|
+
if 200 <= response.status_code < 300:
|
|
341
|
+
return agent_id, "valid"
|
|
342
|
+
return agent_id, "unreachable"
|
|
343
|
+
|
|
344
|
+
results: dict[str, str] = {}
|
|
345
|
+
max_workers = min(8, max(1, len(entries)))
|
|
346
|
+
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
|
347
|
+
for agent_id, status in pool.map(probe, entries):
|
|
348
|
+
results[agent_id] = status
|
|
349
|
+
return results
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _format_sync_cell(
|
|
353
|
+
agent_id: str,
|
|
354
|
+
sessions_by_name: dict[str, dict],
|
|
355
|
+
token_status: str = "unknown",
|
|
356
|
+
) -> str:
|
|
357
|
+
"""Render the Sync column for one row.
|
|
358
|
+
|
|
359
|
+
Top line is the Mutagen session state (running / paused / error / idle);
|
|
360
|
+
bottom line reports whether the stored CLI token is still accepted by the
|
|
361
|
+
backend.
|
|
362
|
+
"""
|
|
363
|
+
from cinna.sync_session import session_name
|
|
364
|
+
|
|
365
|
+
session = sessions_by_name.get(session_name(agent_id))
|
|
366
|
+
if session is None:
|
|
367
|
+
sync_label = "[dim]–[/dim]"
|
|
368
|
+
elif session.get("paused"):
|
|
369
|
+
sync_label = "[yellow]paused[/yellow]"
|
|
370
|
+
elif session.get("lastError"):
|
|
371
|
+
sync_label = "[red]error[/red]"
|
|
372
|
+
else:
|
|
373
|
+
alpha_conn = bool((session.get("alpha") or {}).get("connected"))
|
|
374
|
+
beta_conn = bool((session.get("beta") or {}).get("connected"))
|
|
375
|
+
if alpha_conn and beta_conn:
|
|
376
|
+
sync_label = "[green]active[/green]"
|
|
377
|
+
else:
|
|
378
|
+
sync_label = "[yellow]connecting[/yellow]"
|
|
379
|
+
|
|
380
|
+
token_label = _format_token_label(token_status)
|
|
381
|
+
return f"{sync_label}\n{token_label}"
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _format_token_label(status: str) -> str:
|
|
385
|
+
if status == "valid":
|
|
386
|
+
return "[green]valid token[/green]"
|
|
387
|
+
if status == "expired":
|
|
388
|
+
return "[red]expired token[/red]"
|
|
389
|
+
if status == "unreachable":
|
|
390
|
+
return "[yellow]no connection[/yellow]"
|
|
391
|
+
return "[dim]–[/dim]"
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@cli.command()
|
|
395
|
+
def dev():
|
|
396
|
+
"""Start a foreground dev session: live workspace sync + TUI.
|
|
397
|
+
|
|
398
|
+
Creates the Mutagen sync session for this agent and attaches the terminal
|
|
399
|
+
to a two-tab TUI (status + raw Mutagen details). Ctrl-C terminates the
|
|
400
|
+
session — sync does not outlive the TUI. To observe sync from another
|
|
401
|
+
terminal without affecting it, use ``cinna sync status``.
|
|
402
|
+
"""
|
|
403
|
+
root = find_workspace_root()
|
|
404
|
+
config = load_config(root)
|
|
405
|
+
|
|
406
|
+
with PlatformClient(config) as client:
|
|
407
|
+
ensure_mutagen_ready(client, config, root, interactive=sys.stdin.isatty())
|
|
408
|
+
|
|
409
|
+
st = sync_session.start(config, root)
|
|
410
|
+
console.status(f"Sync session created ({st.state}) — attaching live view. Press Ctrl-C to stop.")
|
|
411
|
+
sync_session.run_foreground(config)
|
|
412
|
+
console.status("Sync session terminated.")
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@cli.group()
|
|
416
|
+
def sync():
|
|
417
|
+
"""Inspect the continuous workspace sync session.
|
|
418
|
+
|
|
419
|
+
Use ``cinna dev`` to start sync. These subcommands are read-only views —
|
|
420
|
+
safe to run from another terminal while a dev session is live.
|
|
421
|
+
"""
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
@sync.command("status")
|
|
425
|
+
def sync_status():
|
|
426
|
+
"""Print the sync session state."""
|
|
427
|
+
root = find_workspace_root()
|
|
428
|
+
config = load_config(root)
|
|
429
|
+
|
|
430
|
+
st = sync_session.status(config)
|
|
431
|
+
from rich.table import Table
|
|
432
|
+
|
|
433
|
+
table = Table(title=f"Sync — {config.agent_name}")
|
|
434
|
+
table.add_column("Property", style="dim")
|
|
435
|
+
table.add_column("Value")
|
|
436
|
+
table.add_row("Session", st.session_name)
|
|
437
|
+
table.add_row("State", _colored_state(st.state))
|
|
438
|
+
table.add_row("Pending → remote", str(st.pending_to_remote))
|
|
439
|
+
table.add_row("Pending → local", str(st.pending_to_local))
|
|
440
|
+
table.add_row("Conflicts", str(st.conflict_count))
|
|
441
|
+
if st.last_error:
|
|
442
|
+
table.add_row("Last error", f"[red]{st.last_error}[/red]")
|
|
443
|
+
console.console.print(table)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
@sync.command("conflicts")
|
|
447
|
+
def sync_conflicts():
|
|
448
|
+
"""List sync conflicts Mutagen has surfaced."""
|
|
449
|
+
root = find_workspace_root()
|
|
450
|
+
config = load_config(root)
|
|
451
|
+
|
|
452
|
+
conflicts = sync_session.list_conflicts(config, root)
|
|
453
|
+
if not conflicts:
|
|
454
|
+
console.status("No conflicts.")
|
|
455
|
+
return
|
|
456
|
+
|
|
457
|
+
from rich.table import Table
|
|
458
|
+
|
|
459
|
+
table = Table(title=f"Conflicts ({len(conflicts)})")
|
|
460
|
+
table.add_column("#", style="dim", justify="right")
|
|
461
|
+
table.add_column("Path")
|
|
462
|
+
table.add_column("Side", style="dim")
|
|
463
|
+
for i, c in enumerate(conflicts, 1):
|
|
464
|
+
rel = c.path.relative_to(root)
|
|
465
|
+
table.add_row(str(i), str(rel), c.kind)
|
|
466
|
+
console.console.print(table)
|
|
467
|
+
console.console.print(
|
|
468
|
+
"\nResolve by opening the file(s) in your editor, picking the keeper,"
|
|
469
|
+
" and deleting the .conflict.* copy."
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
# ─── disconnect ────────────────────────────────────────────────────────────
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@cli.command()
|
|
477
|
+
def disconnect():
|
|
478
|
+
"""Stop sync and remove local config (workspace files preserved)."""
|
|
479
|
+
root = find_workspace_root()
|
|
480
|
+
config = load_config(root)
|
|
481
|
+
|
|
482
|
+
console.warn(
|
|
483
|
+
"This will stop sync, remove .cinna/ config, and delete generated files."
|
|
484
|
+
)
|
|
485
|
+
console.console.print("Workspace files will be preserved.")
|
|
486
|
+
if not click.confirm("Continue?"):
|
|
487
|
+
raise click.Abort()
|
|
488
|
+
|
|
489
|
+
try:
|
|
490
|
+
sync_session.stop(config)
|
|
491
|
+
except Exception as exc:
|
|
492
|
+
console.warn(f"Could not stop sync session cleanly: {exc}")
|
|
493
|
+
|
|
494
|
+
remove_agent_registry(config.agent_id)
|
|
495
|
+
|
|
496
|
+
from cinna.context import list_synced_prompt_refs
|
|
497
|
+
|
|
498
|
+
synced_refs = list_synced_prompt_refs(root)
|
|
499
|
+
|
|
500
|
+
shutil.rmtree(root / ".cinna", ignore_errors=True)
|
|
501
|
+
|
|
502
|
+
for f in [
|
|
503
|
+
"CLAUDE.md",
|
|
504
|
+
"BUILDING_AGENT.md",
|
|
505
|
+
".mcp.json",
|
|
506
|
+
"opencode.json",
|
|
507
|
+
"cinna.log",
|
|
508
|
+
"mutagen.yml",
|
|
509
|
+
*synced_refs,
|
|
510
|
+
]:
|
|
511
|
+
p = root / f
|
|
512
|
+
if p.exists():
|
|
513
|
+
p.unlink()
|
|
514
|
+
|
|
515
|
+
console.status("Disconnected. Workspace files preserved.")
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
@cli.command(name="disconnect-all")
|
|
519
|
+
def disconnect_all():
|
|
520
|
+
"""Remove all agent workspaces in the current directory.
|
|
521
|
+
|
|
522
|
+
Scans subdirectories for cinna workspaces (.cinna/config.json), stops each
|
|
523
|
+
sync session, and deletes the directories entirely.
|
|
524
|
+
"""
|
|
525
|
+
from rich.panel import Panel
|
|
526
|
+
from rich.table import Table
|
|
527
|
+
from rich.text import Text
|
|
528
|
+
|
|
529
|
+
cwd = Path.cwd()
|
|
530
|
+
agents: list[tuple[Path, object | None]] = []
|
|
531
|
+
for child in sorted(cwd.iterdir()):
|
|
532
|
+
if child.is_dir() and (child / ".cinna" / "config.json").is_file():
|
|
533
|
+
try:
|
|
534
|
+
agents.append((child, load_config(child)))
|
|
535
|
+
except Exception:
|
|
536
|
+
agents.append((child, None))
|
|
537
|
+
|
|
538
|
+
if not agents:
|
|
539
|
+
console.status("No cinna workspaces found in current directory.")
|
|
540
|
+
return
|
|
541
|
+
|
|
542
|
+
table = Table(
|
|
543
|
+
title=f"Found {len(agents)} workspace{'s' if len(agents) != 1 else ''}",
|
|
544
|
+
border_style="yellow",
|
|
545
|
+
title_style="bold yellow",
|
|
546
|
+
)
|
|
547
|
+
table.add_column("#", style="dim", justify="right")
|
|
548
|
+
table.add_column("Directory", style="bold")
|
|
549
|
+
table.add_column("Agent")
|
|
550
|
+
|
|
551
|
+
for i, (ws_dir, config) in enumerate(agents, 1):
|
|
552
|
+
name = config.agent_name if config else "[dim]unknown[/dim]"
|
|
553
|
+
table.add_row(str(i), f"{ws_dir.name}/", name)
|
|
554
|
+
|
|
555
|
+
console.console.print()
|
|
556
|
+
console.console.print(table)
|
|
557
|
+
console.console.print()
|
|
558
|
+
|
|
559
|
+
warning = Text()
|
|
560
|
+
warning.append(" This will ", style="yellow")
|
|
561
|
+
warning.append("stop all sync sessions", style="bold red")
|
|
562
|
+
warning.append(" and ", style="yellow")
|
|
563
|
+
warning.append("delete all directories", style="bold red")
|
|
564
|
+
warning.append(" listed above.", style="yellow")
|
|
565
|
+
console.console.print(
|
|
566
|
+
Panel(
|
|
567
|
+
warning,
|
|
568
|
+
border_style="red",
|
|
569
|
+
title="[bold red]Warning[/bold red]",
|
|
570
|
+
padding=(0, 1),
|
|
571
|
+
)
|
|
572
|
+
)
|
|
573
|
+
console.console.print()
|
|
574
|
+
|
|
575
|
+
if not click.confirm("Are you sure?"):
|
|
576
|
+
raise click.Abort()
|
|
577
|
+
|
|
578
|
+
console.console.print()
|
|
579
|
+
|
|
580
|
+
results: list[tuple[str, str, str]] = [] # (label, phase, result)
|
|
581
|
+
|
|
582
|
+
with console.file_progress() as progress:
|
|
583
|
+
task = progress.add_task("Cleaning up workspaces...", total=len(agents) * 2)
|
|
584
|
+
|
|
585
|
+
for ws_dir, config in agents:
|
|
586
|
+
label = config.agent_name if config else ws_dir.name
|
|
587
|
+
|
|
588
|
+
progress.update(task, description=f"Stopping sync — {label}")
|
|
589
|
+
if config is not None:
|
|
590
|
+
try:
|
|
591
|
+
sync_session.stop(config)
|
|
592
|
+
remove_agent_registry(config.agent_id)
|
|
593
|
+
results.append((label, "Sync", "stopped"))
|
|
594
|
+
except Exception as e:
|
|
595
|
+
results.append((label, "Sync", f"failed: {e}"))
|
|
596
|
+
else:
|
|
597
|
+
results.append((label, "Sync", "skipped (no config)"))
|
|
598
|
+
progress.advance(task)
|
|
599
|
+
|
|
600
|
+
progress.update(task, description=f"Deleting directory — {label}")
|
|
601
|
+
try:
|
|
602
|
+
shutil.rmtree(ws_dir)
|
|
603
|
+
results.append((label, "Directory", "deleted"))
|
|
604
|
+
except Exception as e:
|
|
605
|
+
results.append((label, "Directory", f"failed: {e}"))
|
|
606
|
+
progress.advance(task)
|
|
607
|
+
|
|
608
|
+
log_file = cwd / "cinna.log"
|
|
609
|
+
if log_file.exists():
|
|
610
|
+
log_file.unlink()
|
|
611
|
+
|
|
612
|
+
console.console.print()
|
|
613
|
+
summary = Table(title="Results", border_style="green", title_style="bold green")
|
|
614
|
+
summary.add_column("Agent", style="bold")
|
|
615
|
+
summary.add_column("Action")
|
|
616
|
+
summary.add_column("Result")
|
|
617
|
+
|
|
618
|
+
for label, phase, result in results:
|
|
619
|
+
if "failed" in result:
|
|
620
|
+
result_styled = f"[red]{result}[/red]"
|
|
621
|
+
else:
|
|
622
|
+
result_styled = f"[green]{result}[/green]"
|
|
623
|
+
summary.add_row(label, phase, result_styled)
|
|
624
|
+
|
|
625
|
+
console.console.print(summary)
|
|
626
|
+
console.console.print()
|
|
627
|
+
console.status("All agent workspaces cleaned up.")
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
# ─── completion (unchanged) ────────────────────────────────────────────────
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
@cli.command()
|
|
634
|
+
@click.argument(
|
|
635
|
+
"shell", required=False, type=click.Choice(["bash", "zsh", "fish"]), default=None
|
|
636
|
+
)
|
|
637
|
+
@click.option("--install", is_flag=True, help="Install completion to your shell config")
|
|
638
|
+
def completion(shell: str | None, install: bool):
|
|
639
|
+
"""Output shell completion script.
|
|
640
|
+
|
|
641
|
+
\b
|
|
642
|
+
cinna completion zsh # print script to stdout
|
|
643
|
+
cinna completion --install # auto-detect shell and install
|
|
644
|
+
eval "$(cinna completion zsh)" # activate in current session
|
|
645
|
+
"""
|
|
646
|
+
import subprocess as sp
|
|
647
|
+
|
|
648
|
+
if shell is None:
|
|
649
|
+
shell = _detect_shell()
|
|
650
|
+
|
|
651
|
+
env_var = "_CINNA_COMPLETE"
|
|
652
|
+
source_cmd = f"{shell}_source"
|
|
653
|
+
|
|
654
|
+
if install:
|
|
655
|
+
result = sp.run(
|
|
656
|
+
["cinna"],
|
|
657
|
+
capture_output=True,
|
|
658
|
+
text=True,
|
|
659
|
+
env={**os.environ, env_var: source_cmd},
|
|
660
|
+
)
|
|
661
|
+
script = result.stdout.strip()
|
|
662
|
+
if not script:
|
|
663
|
+
raise click.ClickException("Failed to generate completion script.")
|
|
664
|
+
|
|
665
|
+
rc_file, snippet = _install_target(shell, script)
|
|
666
|
+
rc = Path(rc_file).expanduser()
|
|
667
|
+
|
|
668
|
+
if rc.exists() and "cinna completion" in rc.read_text():
|
|
669
|
+
console.status(f"Completion already installed in {rc_file}")
|
|
670
|
+
return
|
|
671
|
+
|
|
672
|
+
with open(rc, "a") as f:
|
|
673
|
+
f.write(f"\n# cinna CLI completion\n{snippet}\n")
|
|
674
|
+
console.status(f"Completion installed in {rc_file}. Restart your shell or run:")
|
|
675
|
+
console.console.print(f" source {rc_file}")
|
|
676
|
+
else:
|
|
677
|
+
result = sp.run(
|
|
678
|
+
["cinna"],
|
|
679
|
+
capture_output=True,
|
|
680
|
+
text=True,
|
|
681
|
+
env={**os.environ, env_var: source_cmd},
|
|
682
|
+
)
|
|
683
|
+
click.echo(result.stdout)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
@cli.command(name="mcp-proxy", hidden=True)
|
|
687
|
+
def mcp_proxy():
|
|
688
|
+
"""Run MCP stdio server for knowledge queries. Called by Claude Code, not directly."""
|
|
689
|
+
run_mcp_proxy()
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _detect_shell() -> str:
|
|
693
|
+
"""Detect current shell from SHELL env var."""
|
|
694
|
+
shell_path = os.environ.get("SHELL", "")
|
|
695
|
+
for name in ("zsh", "bash", "fish"):
|
|
696
|
+
if name in shell_path:
|
|
697
|
+
return name
|
|
698
|
+
return "bash"
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _install_target(shell: str, script: str) -> tuple[str, str]:
|
|
702
|
+
"""Return (rc_file, snippet_to_append) for each shell type."""
|
|
703
|
+
if shell == "zsh":
|
|
704
|
+
return "~/.zshrc", 'eval "$(_CINNA_COMPLETE=zsh_source cinna)"'
|
|
705
|
+
elif shell == "fish":
|
|
706
|
+
return (
|
|
707
|
+
"~/.config/fish/completions/cinna.fish",
|
|
708
|
+
script,
|
|
709
|
+
)
|
|
710
|
+
else:
|
|
711
|
+
return "~/.bashrc", 'eval "$(_CINNA_COMPLETE=bash_source cinna)"'
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _default_machine_name() -> str:
|
|
715
|
+
return f"{os.environ.get('USER', 'dev')}'s {platform.node()}"
|