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/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)