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/console.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Consistent terminal output using Rich."""
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
|
|
5
|
+
|
|
6
|
+
console = Console()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def status(msg: str):
|
|
10
|
+
"""Print a status message."""
|
|
11
|
+
console.print(f"[green]\u2713[/green] {msg}")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def warn(msg: str):
|
|
15
|
+
console.print(f"[yellow]![/yellow] {msg}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def error(msg: str):
|
|
19
|
+
console.print(f"[red]\u2717[/red] {msg}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def step(n: int, total: int, msg: str):
|
|
23
|
+
"""Print a setup step: [1/6] msg"""
|
|
24
|
+
console.print(f"[dim]\\[{n}/{total}][/dim] {msg}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def spinner(msg: str):
|
|
28
|
+
"""Return a Rich status context manager."""
|
|
29
|
+
return console.status(f"[bold]{msg}[/bold]", spinner="dots")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def file_progress():
|
|
33
|
+
"""Return a progress bar for file operations."""
|
|
34
|
+
return Progress(
|
|
35
|
+
SpinnerColumn(),
|
|
36
|
+
TextColumn("[progress.description]{task.description}"),
|
|
37
|
+
BarColumn(),
|
|
38
|
+
TextColumn("{task.completed}/{task.total} files"),
|
|
39
|
+
)
|
cinna/context.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Generate CLAUDE.md and BUILDING_AGENT.md from building context."""
|
|
2
|
+
|
|
3
|
+
import importlib.resources
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
|
|
10
|
+
from cinna.config import CinnaConfig, build_dir
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("cinna.context")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Matches container-internal references like `/app/core/prompts/WEBAPP_BUILDING.md`
|
|
16
|
+
# inside the building prompt. In live-sync mode the companion guides ship
|
|
17
|
+
# inline inside the building-context response (`prompt_files`); the legacy
|
|
18
|
+
# `.cinna/build/app/core/prompts/` Docker-build-context layout is still
|
|
19
|
+
# honoured as a fallback for older backends.
|
|
20
|
+
_PROMPT_REF_RE = re.compile(r"/app/core/prompts/([A-Za-z0-9_]+\.md)")
|
|
21
|
+
|
|
22
|
+
# Matches rewritten references like `./WEBAPP_BUILDING.md` — used by
|
|
23
|
+
# `list_synced_prompt_refs` to recover which files the CLI mirrored when no
|
|
24
|
+
# `.cinna/build/` directory exists (i.e. the live-sync path).
|
|
25
|
+
_LOCAL_REF_RE = re.compile(r"\./([A-Z][A-Z0-9_]*\.md)")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _load_template(name: str) -> str:
|
|
29
|
+
"""Load a template file from the templates package."""
|
|
30
|
+
return importlib.resources.files("cinna.templates").joinpath(name).read_text()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _sync_prompt_references(
|
|
34
|
+
building_prompt: str,
|
|
35
|
+
workspace_root: Path,
|
|
36
|
+
prompt_files: dict[str, str] | None = None,
|
|
37
|
+
) -> tuple[str, list[str]]:
|
|
38
|
+
"""Copy reference docs to workspace root, rewrite container paths.
|
|
39
|
+
|
|
40
|
+
The building prompt is assembled to run inside the agent container and
|
|
41
|
+
references auxiliary guides by their container path (e.g.
|
|
42
|
+
`/app/core/prompts/WEBAPP_BUILDING.md`). On the host those paths don't
|
|
43
|
+
resolve, so we mirror each referenced file next to BUILDING_AGENT.md and
|
|
44
|
+
rewrite the references to `./<NAME>.md`.
|
|
45
|
+
|
|
46
|
+
File contents come from ``prompt_files`` (the inline dict shipped by the
|
|
47
|
+
platform in the building-context response). When that's missing — older
|
|
48
|
+
backends, or the minimal-context fallback — we fall back to reading from
|
|
49
|
+
``.cinna/build/app/core/prompts/`` which used to be populated by the
|
|
50
|
+
legacy Docker-build-context sync.
|
|
51
|
+
|
|
52
|
+
Returns (rewritten_prompt, list of filenames synced to workspace_root).
|
|
53
|
+
"""
|
|
54
|
+
prompt_files = prompt_files or {}
|
|
55
|
+
src_dir = build_dir(workspace_root) / "app" / "core" / "prompts"
|
|
56
|
+
referenced = sorted(set(_PROMPT_REF_RE.findall(building_prompt)))
|
|
57
|
+
synced: list[str] = []
|
|
58
|
+
|
|
59
|
+
for filename in referenced:
|
|
60
|
+
content: str | None = prompt_files.get(filename)
|
|
61
|
+
if content is None:
|
|
62
|
+
src = src_dir / filename
|
|
63
|
+
if src.is_file():
|
|
64
|
+
content = src.read_text()
|
|
65
|
+
if content is None:
|
|
66
|
+
logger.debug(
|
|
67
|
+
"Referenced prompt file not available locally or inline: %s", filename
|
|
68
|
+
)
|
|
69
|
+
continue
|
|
70
|
+
(workspace_root / filename).write_text(content)
|
|
71
|
+
synced.append(filename)
|
|
72
|
+
|
|
73
|
+
rewritten = _PROMPT_REF_RE.sub(r"./\1", building_prompt)
|
|
74
|
+
return rewritten, synced
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def generate_context_files(
|
|
78
|
+
building_context: dict,
|
|
79
|
+
config: CinnaConfig,
|
|
80
|
+
workspace_root: Path,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Generate CLAUDE.md and BUILDING_AGENT.md from building context response.
|
|
83
|
+
|
|
84
|
+
Also mirrors any `/app/core/prompts/*.md` files referenced by the building
|
|
85
|
+
prompt into the workspace root, so they resolve for host-side AI tools.
|
|
86
|
+
"""
|
|
87
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
88
|
+
|
|
89
|
+
# --- BUILDING_AGENT.md ---
|
|
90
|
+
building_prompt = building_context.get("building_prompt", "")
|
|
91
|
+
prompt_files = building_context.get("prompt_files") or {}
|
|
92
|
+
building_prompt, synced_refs = _sync_prompt_references(
|
|
93
|
+
building_prompt, workspace_root, prompt_files=prompt_files
|
|
94
|
+
)
|
|
95
|
+
if synced_refs:
|
|
96
|
+
logger.info("Synced referenced prompt docs: %s", ", ".join(synced_refs))
|
|
97
|
+
|
|
98
|
+
building_md = (
|
|
99
|
+
f"> This file is auto-generated by cinna. Do not edit manually.\n"
|
|
100
|
+
f"> Contains the building mode system prompt pulled from the platform.\n"
|
|
101
|
+
f"> Regenerated on: {timestamp}\n\n"
|
|
102
|
+
f"{building_prompt}"
|
|
103
|
+
)
|
|
104
|
+
(workspace_root / "BUILDING_AGENT.md").write_text(building_md)
|
|
105
|
+
|
|
106
|
+
# --- CLAUDE.md ---
|
|
107
|
+
mcp_tools = _format_mcp_tools(config)
|
|
108
|
+
claude_md = (
|
|
109
|
+
_load_template("CLAUDE.md.template")
|
|
110
|
+
.replace("{agent_name}", config.agent_name)
|
|
111
|
+
.replace("{timestamp}", timestamp)
|
|
112
|
+
.replace("{mcp_tools_section}", mcp_tools)
|
|
113
|
+
)
|
|
114
|
+
(workspace_root / "CLAUDE.md").write_text(claude_md)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def generate_mcp_json(config: CinnaConfig, workspace_root: Path) -> None:
|
|
118
|
+
"""Generate .mcp.json for Claude Code MCP tool discovery."""
|
|
119
|
+
mcp_config = {
|
|
120
|
+
"mcpServers": {
|
|
121
|
+
"agent-knowledge": {
|
|
122
|
+
"command": "cinna",
|
|
123
|
+
"args": ["mcp-proxy"],
|
|
124
|
+
"env": {
|
|
125
|
+
"CINNA_CONFIG": str(workspace_root / ".cinna" / "config.json"),
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
(workspace_root / ".mcp.json").write_text(json.dumps(mcp_config, indent=2) + "\n")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def generate_opencode_json(config: CinnaConfig, workspace_root: Path) -> None:
|
|
134
|
+
"""Generate opencode.json for opencode MCP tool discovery."""
|
|
135
|
+
opencode_config = {
|
|
136
|
+
"mcp": {
|
|
137
|
+
"agent-knowledge": {
|
|
138
|
+
"type": "local",
|
|
139
|
+
"command": ["cinna", "mcp-proxy"],
|
|
140
|
+
"environment": {
|
|
141
|
+
"CINNA_CONFIG": str(workspace_root / ".cinna" / "config.json"),
|
|
142
|
+
},
|
|
143
|
+
"enabled": True,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
(workspace_root / "opencode.json").write_text(
|
|
148
|
+
json.dumps(opencode_config, indent=2) + "\n"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def list_synced_prompt_refs(workspace_root: Path) -> list[str]:
|
|
153
|
+
"""Return filenames of prompt reference docs currently present at the workspace root.
|
|
154
|
+
|
|
155
|
+
Used by ``disconnect`` to know which auto-generated files to clean up.
|
|
156
|
+
Covers two cases:
|
|
157
|
+
|
|
158
|
+
* Legacy Docker-build-context layout — files mirrored from
|
|
159
|
+
``.cinna/build/app/core/prompts/``.
|
|
160
|
+
* Live-sync layout — no ``.cinna/build/`` directory; the filenames are
|
|
161
|
+
recovered by parsing the rewritten ``BUILDING_AGENT.md`` for
|
|
162
|
+
``./<NAME>.md`` references.
|
|
163
|
+
"""
|
|
164
|
+
result: set[str] = set()
|
|
165
|
+
|
|
166
|
+
src_dir = build_dir(workspace_root) / "app" / "core" / "prompts"
|
|
167
|
+
if src_dir.is_dir():
|
|
168
|
+
for src in src_dir.iterdir():
|
|
169
|
+
if src.is_file() and src.suffix == ".md" and src.name != "BUILDING_AGENT.md":
|
|
170
|
+
if (workspace_root / src.name).is_file():
|
|
171
|
+
result.add(src.name)
|
|
172
|
+
|
|
173
|
+
building_md = workspace_root / "BUILDING_AGENT.md"
|
|
174
|
+
if building_md.is_file():
|
|
175
|
+
content = building_md.read_text()
|
|
176
|
+
for name in _LOCAL_REF_RE.findall(content):
|
|
177
|
+
if name == "BUILDING_AGENT.md":
|
|
178
|
+
continue
|
|
179
|
+
if (workspace_root / name).is_file():
|
|
180
|
+
result.add(name)
|
|
181
|
+
|
|
182
|
+
return sorted(result)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def generate_gitignore(workspace_root: Path) -> None:
|
|
186
|
+
"""Generate .gitignore for the local dev workspace."""
|
|
187
|
+
content = """\
|
|
188
|
+
.cinna/
|
|
189
|
+
.claude/settings.local.json
|
|
190
|
+
CLAUDE.md
|
|
191
|
+
BUILDING_AGENT.md
|
|
192
|
+
WEBAPP_BUILDING.md
|
|
193
|
+
COMPLEX_AGENT_DESIGN.md
|
|
194
|
+
.mcp.json
|
|
195
|
+
opencode.json
|
|
196
|
+
cinna.log
|
|
197
|
+
workspace/credentials/
|
|
198
|
+
workspace/app-data/
|
|
199
|
+
workspace/__pycache__/
|
|
200
|
+
workspace/*.pyc
|
|
201
|
+
"""
|
|
202
|
+
gitignore = workspace_root / ".gitignore"
|
|
203
|
+
if not gitignore.exists():
|
|
204
|
+
gitignore.write_text(content)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _format_mcp_tools(config: CinnaConfig) -> str:
|
|
208
|
+
"""Format MCP tools list for CLAUDE.md."""
|
|
209
|
+
tools = [
|
|
210
|
+
"- `knowledge_query` — Search the agent's knowledge base for documentation"
|
|
211
|
+
]
|
|
212
|
+
if config.knowledge_sources:
|
|
213
|
+
topics = ", ".join(t for ks in config.knowledge_sources for t in ks.topics)
|
|
214
|
+
if topics:
|
|
215
|
+
tools.append(f" Available topics: {topics}")
|
|
216
|
+
return "\n".join(tools)
|
cinna/errors.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Custom exceptions for cinna CLI."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CinnaError(click.ClickException):
|
|
7
|
+
"""Base exception — all cinna errors are Click exceptions so they display nicely."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigNotFoundError(CinnaError):
|
|
11
|
+
"""No .cinna/config.json found. User needs to run setup."""
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
super().__init__(
|
|
15
|
+
"Not in a cinna workspace. Run the setup command from the platform UI first."
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuthenticationError(CinnaError):
|
|
20
|
+
"""CLI token rejected by the platform."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, detail: str = ""):
|
|
23
|
+
msg = "Authentication failed. Your session may have expired."
|
|
24
|
+
if detail:
|
|
25
|
+
msg += f" ({detail})"
|
|
26
|
+
msg += "\nRun the setup command again from the platform UI."
|
|
27
|
+
super().__init__(msg)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PlatformError(CinnaError):
|
|
31
|
+
"""Backend returned an unexpected error."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, status_code: int, detail: str):
|
|
34
|
+
super().__init__(f"Platform error ({status_code}): {detail}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MutagenNotFoundError(CinnaError):
|
|
38
|
+
"""Mutagen is not installed or not on PATH."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, required_version: str | None = None):
|
|
41
|
+
msg = "Mutagen is required but was not found on PATH."
|
|
42
|
+
if required_version:
|
|
43
|
+
msg += f" (required version: {required_version})"
|
|
44
|
+
msg += "\nInstall with: brew install mutagen-io/mutagen/mutagen"
|
|
45
|
+
msg += "\nOther platforms: https://mutagen.io/documentation/introduction/installation"
|
|
46
|
+
super().__init__(msg)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MutagenVersionMismatchError(CinnaError):
|
|
50
|
+
"""Installed Mutagen version does not match what the platform requires."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, installed: str, required: str):
|
|
53
|
+
super().__init__(
|
|
54
|
+
f"Mutagen version mismatch: installed {installed}, platform requires {required}.\n"
|
|
55
|
+
"Upgrade with: brew upgrade mutagen-io/mutagen/mutagen"
|
|
56
|
+
)
|
cinna/logging.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""File-based logging for debugging CLI issues."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import logging.handlers
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
LOG_FILE = "cinna.log"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def setup_logging(verbose: bool = False) -> None:
|
|
11
|
+
"""Configure file logging. Logs to ./cinna.log in the current directory."""
|
|
12
|
+
try:
|
|
13
|
+
log_path = Path.cwd() / LOG_FILE
|
|
14
|
+
except (FileNotFoundError, OSError):
|
|
15
|
+
raise SystemExit(
|
|
16
|
+
"Error: Current directory no longer exists.\n"
|
|
17
|
+
"This usually happens after 'cinna disconnect-all' deletes the workspace.\n"
|
|
18
|
+
"Run: cd ~ (or any existing directory) and try again."
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
file_handler = logging.handlers.RotatingFileHandler(
|
|
22
|
+
log_path,
|
|
23
|
+
maxBytes=5 * 1024 * 1024, # 5MB
|
|
24
|
+
backupCount=3,
|
|
25
|
+
)
|
|
26
|
+
file_handler.setFormatter(
|
|
27
|
+
logging.Formatter("%(asctime)s %(levelname)s [%(name)s] %(message)s")
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
root = logging.getLogger("cinna")
|
|
31
|
+
root.setLevel(logging.DEBUG)
|
|
32
|
+
root.addHandler(file_handler)
|
|
33
|
+
|
|
34
|
+
if verbose:
|
|
35
|
+
console_handler = logging.StreamHandler()
|
|
36
|
+
console_handler.setLevel(logging.DEBUG)
|
|
37
|
+
console_handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
|
|
38
|
+
root.addHandler(console_handler)
|