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/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()}"