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/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")
|
cinna/mutagen_runtime.py
ADDED
|
@@ -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
|