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/mcp_proxy.py ADDED
@@ -0,0 +1,151 @@
1
+ """MCP stdio server that proxies knowledge queries to the platform backend.
2
+
3
+ Launched by Claude Code (or other MCP clients) via .mcp.json config.
4
+ Runs as a subprocess — reads from stdin, writes to stdout (MCP stdio transport).
5
+ """
6
+
7
+ import logging
8
+ import os
9
+ import asyncio
10
+ from pathlib import Path
11
+
12
+ from mcp.server import Server
13
+ from mcp.server.stdio import stdio_server
14
+ from mcp.types import Tool, TextContent
15
+
16
+ from cinna.config import load_config, CinnaConfig
17
+ from cinna.client import PlatformClient
18
+
19
+ logger = logging.getLogger("cinna.mcp_proxy")
20
+
21
+
22
+ def create_mcp_server(config: CinnaConfig) -> Server:
23
+ """Create the MCP server with knowledge query tool."""
24
+
25
+ server = Server("agent-knowledge")
26
+ client = PlatformClient(config)
27
+ logger.info("MCP server created for agent %s (%s)", config.agent_name, config.agent_id)
28
+
29
+ @server.list_tools()
30
+ async def list_tools():
31
+ return [
32
+ Tool(
33
+ name="knowledge_query",
34
+ description="Search the agent's knowledge base for relevant documentation and articles",
35
+ inputSchema={
36
+ "type": "object",
37
+ "properties": {
38
+ "query": {
39
+ "type": "string",
40
+ "description": "Natural language search query",
41
+ },
42
+ "topic": {
43
+ "type": "string",
44
+ "description": f"Knowledge topic to search in. Available: {_topic_list(config)}",
45
+ },
46
+ },
47
+ "required": ["query"],
48
+ },
49
+ )
50
+ ]
51
+
52
+ @server.call_tool()
53
+ async def call_tool(name: str, arguments: dict):
54
+ if name != "knowledge_query":
55
+ logger.warning("Unknown tool called: %s", name)
56
+ return [TextContent(type="text", text=f"Unknown tool: {name}")]
57
+
58
+ query = arguments.get("query", "")
59
+ topic = arguments.get("topic")
60
+ logger.info("knowledge_query: query=%r topic=%r", query, topic)
61
+
62
+ try:
63
+ response = client.search_knowledge(config.agent_id, query, topic)
64
+ except Exception:
65
+ logger.exception("knowledge_query failed for query=%r topic=%r", query, topic)
66
+ raise
67
+ results = response.get("results", [])
68
+ logger.info("knowledge_query returned %d results", len(results))
69
+
70
+ if not results:
71
+ return [TextContent(type="text", text="No results found.")]
72
+
73
+ formatted = _format_results(results)
74
+ return [TextContent(type="text", text=formatted)]
75
+
76
+ return server
77
+
78
+
79
+ def _topic_list(config: CinnaConfig) -> str:
80
+ topics = [t for ks in config.knowledge_sources for t in ks.topics]
81
+ return ", ".join(topics) if topics else "all topics"
82
+
83
+
84
+ def _format_results(results: list[dict]) -> str:
85
+ parts = []
86
+ for r in results:
87
+ source = r.get("source", "unknown")
88
+ similarity = r.get("similarity", 0)
89
+ content = r.get("content", "")
90
+ parts.append(f"## [{source}] (relevance: {similarity:.0%})\n\n{content}")
91
+ return "\n\n---\n\n".join(parts)
92
+
93
+
94
+ def _setup_mcp_logging(workspace_root: Path) -> None:
95
+ """Set up file logging for the MCP proxy subprocess.
96
+
97
+ The proxy is launched directly by the MCP client (not via the Click CLI
98
+ group), so the normal setup_logging() path is never hit. We configure
99
+ logging to the same cinna.log used by the rest of the CLI.
100
+ """
101
+ from cinna.logging import LOG_FILE
102
+ import logging.handlers
103
+
104
+ log_path = workspace_root / LOG_FILE
105
+ handler = logging.handlers.RotatingFileHandler(
106
+ log_path, maxBytes=5 * 1024 * 1024, backupCount=3,
107
+ )
108
+ handler.setFormatter(
109
+ logging.Formatter("%(asctime)s %(levelname)s [%(name)s] %(message)s")
110
+ )
111
+ root = logging.getLogger("cinna")
112
+ root.setLevel(logging.DEBUG)
113
+ root.addHandler(handler)
114
+
115
+
116
+ def run_mcp_proxy():
117
+ """Entry point for `cinna mcp-proxy` — run as MCP stdio server."""
118
+ config_path = os.environ.get("CINNA_CONFIG")
119
+ if not config_path:
120
+ raise SystemExit("CINNA_CONFIG environment variable not set")
121
+
122
+ workspace_root = Path(config_path).parent.parent
123
+ _setup_mcp_logging(workspace_root)
124
+
125
+ logger.info("MCP proxy starting (config=%s)", config_path)
126
+
127
+ try:
128
+ config = load_config(workspace_root)
129
+ except Exception:
130
+ logger.exception("Failed to load config from %s", workspace_root)
131
+ raise
132
+
133
+ try:
134
+ server = create_mcp_server(config)
135
+ except Exception:
136
+ logger.exception("Failed to create MCP server")
137
+ raise
138
+
139
+ async def main():
140
+ async with stdio_server() as (read_stream, write_stream):
141
+ logger.info("MCP stdio transport connected, serving requests")
142
+ init_options = server.create_initialization_options()
143
+ await server.run(read_stream, write_stream, init_options)
144
+
145
+ try:
146
+ asyncio.run(main())
147
+ except Exception:
148
+ logger.exception("MCP proxy crashed")
149
+ raise
150
+ finally:
151
+ logger.info("MCP proxy shut down")
@@ -0,0 +1,168 @@
1
+ """Mutagen install/verification helpers.
2
+
3
+ The platform pins a specific Mutagen version and exposes it via GET /sync-runtime.
4
+ This module checks what the local machine has, prompts to install if missing,
5
+ and gates `cinna setup` / `cinna sync start` on a version match.
6
+ """
7
+
8
+ import logging
9
+ import platform
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ from dataclasses import dataclass
14
+ from datetime import datetime, timezone
15
+
16
+ import click
17
+
18
+ from cinna.client import PlatformClient
19
+ from cinna.config import CinnaConfig, save_config
20
+ from cinna.errors import MutagenNotFoundError, MutagenVersionMismatchError
21
+ from cinna import console
22
+
23
+ logger = logging.getLogger("cinna.mutagen_runtime")
24
+
25
+
26
+ @dataclass
27
+ class InstalledMutagen:
28
+ path: str
29
+ version: str
30
+
31
+
32
+ @dataclass
33
+ class RequiredMutagen:
34
+ version: str
35
+ agent_sha256: str
36
+ platform_api_version: str
37
+
38
+
39
+ def detect_local_mutagen() -> InstalledMutagen | None:
40
+ """Locate `mutagen` on PATH and parse its version."""
41
+ path = shutil.which("mutagen")
42
+ if not path:
43
+ return None
44
+
45
+ try:
46
+ result = subprocess.run(
47
+ [path, "version"],
48
+ capture_output=True,
49
+ text=True,
50
+ timeout=5,
51
+ )
52
+ except (FileNotFoundError, subprocess.TimeoutExpired):
53
+ return None
54
+
55
+ version = _parse_mutagen_version(result.stdout)
56
+ if not version:
57
+ logger.warning("Could not parse Mutagen version from: %r", result.stdout)
58
+ return None
59
+ return InstalledMutagen(path=path, version=version)
60
+
61
+
62
+ def _parse_mutagen_version(text: str) -> str | None:
63
+ """Extract a semver-ish version string from `mutagen version` output."""
64
+ match = re.search(r"(\d+\.\d+\.\d+(?:[-.][A-Za-z0-9]+)*)", text)
65
+ return match.group(1) if match else None
66
+
67
+
68
+ def _minor_version(v: str) -> tuple[int, int] | None:
69
+ """Return (major, minor) from a version string, ignoring patch/suffix."""
70
+ match = re.match(r"(\d+)\.(\d+)", v or "")
71
+ if not match:
72
+ return None
73
+ return int(match.group(1)), int(match.group(2))
74
+
75
+
76
+ def fetch_required_mutagen(
77
+ client: PlatformClient, agent_id: str
78
+ ) -> RequiredMutagen:
79
+ """Ask the platform which Mutagen version to pin to."""
80
+ data = client.get_sync_runtime(agent_id)
81
+ return RequiredMutagen(
82
+ version=data.get("mutagen_version", ""),
83
+ agent_sha256=data.get("mutagen_agent_sha256", ""),
84
+ platform_api_version=data.get("platform_api_version", ""),
85
+ )
86
+
87
+
88
+ def install_mutagen(required: RequiredMutagen) -> None:
89
+ """Platform-aware install. Prints the one-liner; the user runs it.
90
+
91
+ We deliberately do not shell out to a package manager automatically — that
92
+ is a privileged operation the user should authorize. We print the install
93
+ command, wait for confirmation that they've run it, and re-detect.
94
+ """
95
+ system = platform.system()
96
+ if system == "Darwin":
97
+ cmd = "brew install mutagen-io/mutagen/mutagen"
98
+ elif system == "Linux":
99
+ cmd = (
100
+ "See https://mutagen.io/documentation/introduction/installation for "
101
+ "Linux install instructions (prebuilt tarballs on GitHub releases)."
102
+ )
103
+ else:
104
+ cmd = "Mutagen supports Windows via WSL. See https://mutagen.io/ for details."
105
+
106
+ console.console.print()
107
+ console.console.print(
108
+ f"Mutagen {required.version} is required for continuous sync."
109
+ )
110
+ console.console.print(f" {cmd}")
111
+ console.console.print()
112
+
113
+
114
+ def ensure_mutagen_ready(
115
+ client: PlatformClient,
116
+ config: CinnaConfig,
117
+ workspace_root,
118
+ *,
119
+ interactive: bool = True,
120
+ ) -> InstalledMutagen:
121
+ """Verify a matching Mutagen install; prompt to install if missing.
122
+
123
+ Updates `config.mutagen_version` and `last_sync_runtime_check_at` on success.
124
+ Raises MutagenNotFoundError / MutagenVersionMismatchError on hard failures.
125
+ """
126
+ required = fetch_required_mutagen(client, config.agent_id)
127
+ installed = detect_local_mutagen()
128
+
129
+ if installed is None:
130
+ install_mutagen(required)
131
+ if interactive and click.confirm(
132
+ "Run the command above, then press Enter to continue.", default=True
133
+ ):
134
+ installed = detect_local_mutagen()
135
+ if installed is None:
136
+ raise MutagenNotFoundError(required.version)
137
+
138
+ if required.version and installed.version != required.version:
139
+ req_minor = _minor_version(required.version)
140
+ inst_minor = _minor_version(installed.version)
141
+ same_minor = req_minor is not None and req_minor == inst_minor
142
+
143
+ if same_minor:
144
+ # Patch-level differences within the same minor version — Mutagen's
145
+ # wire protocol is stable across these, so warn and continue.
146
+ console.warn(
147
+ f"Mutagen {installed.version} differs from platform pin "
148
+ f"{required.version} (patch-level only — proceeding)."
149
+ )
150
+ else:
151
+ if interactive:
152
+ console.warn(
153
+ f"Installed Mutagen {installed.version} does not match required "
154
+ f"{required.version}."
155
+ )
156
+ if not click.confirm("Continue anyway?", default=False):
157
+ raise MutagenVersionMismatchError(
158
+ installed.version, required.version
159
+ )
160
+ else:
161
+ raise MutagenVersionMismatchError(installed.version, required.version)
162
+
163
+ config.mutagen_version = installed.version
164
+ config.last_sync_runtime_check_at = datetime.now(timezone.utc).strftime(
165
+ "%Y-%m-%dT%H:%M:%SZ"
166
+ )
167
+ save_config(config, workspace_root)
168
+ return installed
cinna/sync.py ADDED
@@ -0,0 +1,120 @@
1
+ """Workspace archive helpers for the initial clone.
2
+
3
+ Continuous sync is handled by Mutagen (see sync_session.py). What remains here
4
+ is just the tarball/zip extraction used once, when `cinna setup` seeds the
5
+ workspace from `GET /workspace`.
6
+ """
7
+
8
+ import io
9
+ import logging
10
+ import tarfile
11
+ import zipfile
12
+ from pathlib import Path
13
+
14
+ from cinna import console
15
+
16
+ logger = logging.getLogger("cinna.sync")
17
+
18
+ MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB
19
+
20
+
21
+ def ensure_workspace_dirs(workspace: Path) -> None:
22
+ """Ensure required workspace subdirectories exist.
23
+
24
+ Mirrors the layout the remote agent environment guarantees so the local
25
+ workspace matches production even when the downloaded tarball is sparse:
26
+
27
+ - ``files/``, ``knowledge/`` — bundle-owned folders that always exist on the
28
+ env but may be empty.
29
+ - ``app-data/storage/``, ``app-data/uploads/``, ``app-data/cache/`` — the
30
+ per-user persistent volume mounted at ``/app/workspace/app-data`` in the
31
+ env. Created locally so script paths resolve immediately and Mutagen has
32
+ a target to sync into.
33
+ """
34
+ for rel in (
35
+ "files",
36
+ "knowledge",
37
+ "app-data/storage",
38
+ "app-data/uploads",
39
+ "app-data/cache",
40
+ ):
41
+ (workspace / rel).mkdir(parents=True, exist_ok=True)
42
+
43
+
44
+ def extract_workspace_tarball(
45
+ archive_bytes: bytes,
46
+ workspace: Path,
47
+ only_files: set[str] | None = None,
48
+ ) -> list[str]:
49
+ """Extract workspace archive to the workspace directory.
50
+
51
+ Supports tar (gz/bz2/xz/plain) and zip formats — auto-detected from content.
52
+ Returns list of extracted file paths.
53
+ Validates: no path traversal, no symlinks, max file size.
54
+
55
+ If only_files is provided, only extracts files whose relative path is in the set.
56
+ """
57
+ workspace.mkdir(parents=True, exist_ok=True)
58
+
59
+ if zipfile.is_zipfile(io.BytesIO(archive_bytes)):
60
+ logger.debug("Detected zip archive (%d bytes)", len(archive_bytes))
61
+ return _extract_zip(archive_bytes, workspace, only_files)
62
+ else:
63
+ logger.debug("Detected tar archive (%d bytes)", len(archive_bytes))
64
+ return _extract_tar(archive_bytes, workspace, only_files)
65
+
66
+
67
+ def _extract_tar(
68
+ archive_bytes: bytes,
69
+ workspace: Path,
70
+ only_files: set[str] | None = None,
71
+ ) -> list[str]:
72
+ """Extract a tar archive (any compression) to workspace."""
73
+ extracted = []
74
+ with tarfile.open(fileobj=io.BytesIO(archive_bytes), mode="r:*") as tar:
75
+ for member in tar.getmembers():
76
+ member_path = Path(member.name)
77
+ if member_path.is_absolute() or ".." in member_path.parts:
78
+ console.warn(f"Skipping unsafe path: {member.name}")
79
+ continue
80
+ if member.issym() or member.islnk():
81
+ console.warn(f"Skipping symlink: {member.name}")
82
+ continue
83
+ if member.size > MAX_FILE_SIZE:
84
+ console.warn(f"Skipping large file: {member.name}")
85
+ continue
86
+ if only_files is not None and member.name not in only_files:
87
+ continue
88
+
89
+ tar.extract(member, path=workspace, filter="data")
90
+ extracted.append(member.name)
91
+ return extracted
92
+
93
+
94
+ def _extract_zip(
95
+ archive_bytes: bytes,
96
+ workspace: Path,
97
+ only_files: set[str] | None = None,
98
+ ) -> list[str]:
99
+ """Extract a zip archive to workspace."""
100
+ extracted = []
101
+ with zipfile.ZipFile(io.BytesIO(archive_bytes)) as zf:
102
+ for info in zf.infolist():
103
+ member_path = Path(info.filename)
104
+ if member_path.is_absolute() or ".." in member_path.parts:
105
+ console.warn(f"Skipping unsafe path: {info.filename}")
106
+ continue
107
+ if info.file_size > MAX_FILE_SIZE:
108
+ console.warn(f"Skipping large file: {info.filename}")
109
+ continue
110
+ if info.is_dir():
111
+ continue
112
+ if only_files is not None and info.filename not in only_files:
113
+ continue
114
+
115
+ target = workspace / info.filename
116
+ target.parent.mkdir(parents=True, exist_ok=True)
117
+ with zf.open(info) as src, open(target, "wb") as dst:
118
+ dst.write(src.read())
119
+ extracted.append(info.filename)
120
+ return extracted