snapdoczilla-mcp 1.0.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.
@@ -0,0 +1,3 @@
1
+ """SnapDoczilla MCP server: offline docs-as-code driven by your AI agent."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,377 @@
1
+ """Deterministic helpers behind the SnapDoczilla MCP tools (no MCP imports here).
2
+
3
+ The model writes the documentation. This module does the parts that must not
4
+ depend on a model: locating the repo, running the bundled installer and the
5
+ strict HTML build, tracking the last documented commit per area, and keeping
6
+ every write inside ``documentation/source/``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ import shutil
15
+ import subprocess
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ MAX_OUTPUT = 4000
20
+ MAX_PAGE_BYTES = 1_000_000
21
+ HASH_RE = re.compile(r"^[0-9a-f]{7,64}$")
22
+ LANG_RE = re.compile(r"^[A-Za-z]{2,3}([_-][A-Za-z0-9]{2,8})?$")
23
+ AREA_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
24
+ PAGE_RE = re.compile(r"^[\w.-]+(/[\w.-]+)*\.md$")
25
+
26
+
27
+ class SnapDoczillaError(Exception):
28
+ """A problem the model can read and act on."""
29
+
30
+
31
+ # --------------------------------------------------------------------------- #
32
+ # Locating things
33
+ # --------------------------------------------------------------------------- #
34
+
35
+ def skill_dir() -> Path:
36
+ """Folder with scripts/ and assets/: bundled in the wheel, or the repo checkout in dev."""
37
+ here = Path(__file__).resolve().parent
38
+ for candidate in (here / "skill", here.parents[1] / "skills" / "snapdoczilla"):
39
+ if (candidate / "scripts" / "install.sh").is_file():
40
+ return candidate
41
+ raise SnapDoczillaError("The bundled SnapDoczilla skill files are missing; reinstall snapdoczilla-mcp.")
42
+
43
+
44
+ def bash_exe() -> str:
45
+ """bash for the bundled .sh scripts. On Windows only Git for Windows' bash (never WSL's)."""
46
+ if os.name == "nt":
47
+ roots = [os.environ.get(v) for v in ("ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA")]
48
+ candidates = [Path(r) / sub / "bin" / "bash.exe" for r in roots if r for sub in ("Git", "Programs/Git")]
49
+ git = shutil.which("git")
50
+ if git:
51
+ candidates.append(Path(git).resolve().parent.parent / "bin" / "bash.exe")
52
+ for c in candidates:
53
+ if c.is_file():
54
+ return str(c)
55
+ raise SnapDoczillaError("Git for Windows (which ships bash) was not found. Install it from https://git-scm.com.")
56
+ found = shutil.which("bash")
57
+ if not found:
58
+ raise SnapDoczillaError("bash was not found on PATH.")
59
+ return found
60
+
61
+
62
+ def _run(cmd: list[str], cwd: Path | None = None, timeout: int = 60) -> tuple[int, str]:
63
+ """Run a command with stdin closed: stdin belongs to the MCP protocol, children must not read it."""
64
+ extra: dict[str, Any] = {}
65
+ if os.name == "nt":
66
+ extra["creationflags"] = 0x08000000 # CREATE_NO_WINDOW
67
+ try:
68
+ p = subprocess.run(
69
+ cmd, cwd=cwd, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
70
+ text=True, encoding="utf-8", errors="replace", timeout=timeout, **extra,
71
+ )
72
+ except FileNotFoundError:
73
+ raise SnapDoczillaError(f"Command not found: {cmd[0]}") from None
74
+ except subprocess.TimeoutExpired:
75
+ raise SnapDoczillaError(f"Timed out after {timeout}s: {' '.join(cmd[:3])} ...") from None
76
+ return p.returncode, p.stdout
77
+
78
+
79
+ def _git(repo: Path, *args: str, timeout: int = 60) -> tuple[int, str]:
80
+ return _run(["git", "-C", str(repo), *args], timeout=timeout)
81
+
82
+
83
+ def _tail(text: str, limit: int = MAX_OUTPUT) -> str:
84
+ text = text.strip()
85
+ return text if len(text) <= limit else "...[truncated]...\n" + text[-limit:]
86
+
87
+
88
+ def resolve_repo(path: str) -> Path:
89
+ if not path or not str(path).strip():
90
+ raise SnapDoczillaError("repo is required: the path of a git repository.")
91
+ p = Path(str(path).strip()).expanduser()
92
+ if not p.is_dir():
93
+ raise SnapDoczillaError(f"Not a directory: {p}")
94
+ rc, out = _git(p, "rev-parse", "--show-toplevel")
95
+ if rc != 0:
96
+ raise SnapDoczillaError(f"{p} is not inside a git repository.")
97
+ return Path(out.strip())
98
+
99
+
100
+ def _doc(repo: Path) -> Path:
101
+ return repo / "documentation"
102
+
103
+
104
+ def _installed(repo: Path) -> bool:
105
+ d = _doc(repo)
106
+ return (d / "mkdocs.yml").is_file() and (d / "update.sh").is_file()
107
+
108
+
109
+ def _require_installed(repo: Path) -> Path:
110
+ if not _installed(repo):
111
+ raise SnapDoczillaError("SnapDoczilla is not installed in this repo. Call snapdoczilla_install first.")
112
+ return _doc(repo)
113
+
114
+
115
+ def read_areas(doc: Path) -> list[tuple[str, str]]:
116
+ conf = doc / "areas.conf"
117
+ if not conf.is_file():
118
+ raise SnapDoczillaError("documentation/areas.conf is missing.")
119
+ areas = []
120
+ for line in conf.read_text(encoding="utf-8").splitlines():
121
+ s = line.strip()
122
+ if s and not s.startswith("#") and "=" in s:
123
+ name, path = s.split("=", 1)
124
+ areas.append((name.strip(), path.strip()))
125
+ return areas
126
+
127
+
128
+ def _area_path(doc: Path, area: str) -> str:
129
+ areas = dict(read_areas(doc))
130
+ if area not in areas:
131
+ raise SnapDoczillaError(f"Unknown area '{area}'. Areas in areas.conf: {', '.join(areas) or '(none)'}")
132
+ return areas[area]
133
+
134
+
135
+ def _last_sync(doc: Path, area: str) -> str | None:
136
+ marker = doc / f".last-sync-{area}"
137
+ if not marker.is_file():
138
+ return None
139
+ value = marker.read_text(encoding="utf-8").strip()
140
+ return value if HASH_RE.match(value) else None
141
+
142
+
143
+ # --------------------------------------------------------------------------- #
144
+ # Tools
145
+ # --------------------------------------------------------------------------- #
146
+
147
+ def status(repo_path: str) -> dict[str, Any]:
148
+ repo = resolve_repo(repo_path)
149
+ doc = _doc(repo)
150
+ installed = _installed(repo)
151
+ _, head = _git(repo, "rev-parse", "--short", "HEAD")
152
+ result: dict[str, Any] = {
153
+ "repo": str(repo),
154
+ "head": head.strip() or None,
155
+ "installed": installed,
156
+ "foreign_documentation_dir": doc.exists() and not installed,
157
+ }
158
+ if result["foreign_documentation_dir"]:
159
+ result["next_step"] = (
160
+ "documentation/ exists but was not created by SnapDoczilla. Stop and ask the user before touching it."
161
+ )
162
+ return result
163
+ if not installed:
164
+ result["next_step"] = "Call snapdoczilla_install (choose the areas from the repo layout), then write the base pages."
165
+ return result
166
+
167
+ areas = []
168
+ for name, path in read_areas(doc):
169
+ last = _last_sync(doc, name)
170
+ behind = None
171
+ if last:
172
+ rc, out = _git(repo, "rev-list", "--count", f"{last}..HEAD", "--", path, ":!documentation")
173
+ behind = int(out.strip()) if rc == 0 and out.strip().isdigit() else None
174
+ areas.append({
175
+ "name": name, "path": path, "last_sync": last, "commits_not_documented": behind,
176
+ "has_area_rules": (doc / f"AGENT-RULES-{name}.md").is_file(),
177
+ })
178
+ src = doc / "source"
179
+ pages = sorted(p.relative_to(src).as_posix() for p in src.rglob("*.md")) if src.is_dir() else []
180
+ adr_numbers = [int(m.group(1)) for p in pages if (m := re.match(r"adr/(\d{4})-", p))]
181
+ result.update({
182
+ "areas": areas,
183
+ "pages": pages,
184
+ "next_adr_number": f"{max(adr_numbers, default=0) + 1:04d}",
185
+ "html_built": (doc / "html" / "index.html").is_file(),
186
+ })
187
+ stale = [a["name"] for a in areas if a["commits_not_documented"]]
188
+ never = [a["name"] for a in areas if a["last_sync"] is None]
189
+ if never:
190
+ result["next_step"] = f"Areas never documented: {', '.join(never)}. Write their pages, build, then mark_synced."
191
+ elif stale:
192
+ result["next_step"] = f"Out of date: {', '.join(stale)}. Call snapdoczilla_changes_since_sync for each."
193
+ else:
194
+ result["next_step"] = "Everything is documented up to HEAD."
195
+ return result
196
+
197
+
198
+ def install(repo_path: str, project_name: str, language: str = "en",
199
+ areas: dict[str, str] | None = None, ui_areas: list[str] | None = None) -> dict[str, Any]:
200
+ repo = resolve_repo(repo_path)
201
+ name = (project_name or "").strip()
202
+ if not name or any(ord(c) < 32 for c in name) or '"' in name:
203
+ raise SnapDoczillaError('project_name must be non-empty, single-line, and contain no double quotes.')
204
+ if not LANG_RE.match(language or ""):
205
+ raise SnapDoczillaError("language must be a code such as 'en' or 'es'.")
206
+ if _installed(repo):
207
+ raise SnapDoczillaError("SnapDoczilla is already installed here. Nothing was changed.")
208
+ if _doc(repo).exists():
209
+ raise SnapDoczillaError("documentation/ already exists and was not created by SnapDoczilla. Ask the user first.")
210
+
211
+ clean_areas = _validate_areas(repo, areas) if areas else None
212
+ ui = list(ui_areas or [])
213
+ if clean_areas is not None:
214
+ unknown = [a for a in ui if a not in clean_areas]
215
+ if unknown:
216
+ raise SnapDoczillaError(f"ui_areas not in areas: {', '.join(unknown)}")
217
+ elif ui:
218
+ raise SnapDoczillaError("ui_areas requires areas.")
219
+
220
+ # install.sh substitutes the name through sed with '|' as delimiter: escape sed's specials.
221
+ sed_safe = name.replace("\\", "\\\\").replace("&", "\\&").replace("|", "\\|")
222
+ script = (skill_dir() / "scripts" / "install.sh").as_posix()
223
+ rc, out = _run([bash_exe(), script, repo.as_posix(), sed_safe, language], timeout=180)
224
+ if rc != 0:
225
+ raise SnapDoczillaError(f"install.sh failed (exit {rc}):\n{_tail(out)}")
226
+
227
+ doc = _doc(repo)
228
+ notes: list[str] = []
229
+ if "WARNING" in out:
230
+ notes.append(next((l for l in out.splitlines() if "WARNING" in l), "Mermaid could not be downloaded."))
231
+ if clean_areas is not None:
232
+ header = [l for l in (doc / "areas.conf").read_text(encoding="utf-8").splitlines() if l.strip().startswith("#")]
233
+ lines = header + [f"{n}={p}" for n, p in clean_areas.items()]
234
+ (doc / "areas.conf").write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
235
+ template = doc / "AGENT-RULES-COMPONENTS.md"
236
+ for a in ui:
237
+ shutil.copyfile(template, doc / f"AGENT-RULES-{a}.md")
238
+ if not ui and template.is_file():
239
+ template.unlink()
240
+ notes.append(f"areas.conf written with: {', '.join(clean_areas)}")
241
+ else:
242
+ notes.append("areas.conf still has the default area code=.; edit it if the repo has separate back/front parts.")
243
+ return {
244
+ "installed": True,
245
+ "documentation_dir": str(doc),
246
+ "notes": notes,
247
+ "next_step": "Call snapdoczilla_get_rules, write the base pages with snapdoczilla_write_page "
248
+ "(index, getting-started, architecture, api if any), then snapdoczilla_build_html and snapdoczilla_mark_synced.",
249
+ }
250
+
251
+
252
+ def _validate_areas(repo: Path, areas: dict[str, str]) -> dict[str, str]:
253
+ clean: dict[str, str] = {}
254
+ for name, path in areas.items():
255
+ if not AREA_RE.match(name):
256
+ raise SnapDoczillaError(f"Invalid area name '{name}': use letters, digits, '-' or '_'.")
257
+ p = path.strip().replace("\\", "/")
258
+ if not p or p.startswith("/") or re.match(r"^[A-Za-z]:", p) or ".." in p.split("/") or "=" in p or "\n" in p:
259
+ raise SnapDoczillaError(f"Invalid path for area '{name}': must be relative to the repo root.")
260
+ if not (repo / p).is_dir():
261
+ raise SnapDoczillaError(f"Path for area '{name}' is not a directory in the repo: {p}")
262
+ clean[name] = p
263
+ return clean
264
+
265
+
266
+ def get_rules(repo_path: str, area: str | None = None) -> dict[str, Any]:
267
+ repo = resolve_repo(repo_path)
268
+ doc = _require_installed(repo)
269
+ names = ["AGENT-RULES.md"]
270
+ if area:
271
+ _area_path(doc, area)
272
+ extra = f"AGENT-RULES-{area}.md"
273
+ if (doc / extra).is_file():
274
+ names.append(extra)
275
+ return {"rules": {n: (doc / n).read_text(encoding="utf-8") for n in names if (doc / n).is_file()}}
276
+
277
+
278
+ def changes_since_sync(repo_path: str, area: str) -> dict[str, Any]:
279
+ repo = resolve_repo(repo_path)
280
+ doc = _require_installed(repo)
281
+ path = _area_path(doc, area)
282
+ last = _last_sync(doc, area)
283
+ base: dict[str, Any] = {"area": area, "area_path": path, "last_sync": last}
284
+ if last is None:
285
+ return {**base, "first_generation": True,
286
+ "hint": f"No sync marker: analyze '{path}' fully, then mark_synced."}
287
+ rc, _ = _git(repo, "cat-file", "-e", f"{last}^{{commit}}")
288
+ if rc != 0:
289
+ return {**base, "first_generation": True,
290
+ "hint": "The recorded commit no longer exists (history rewritten). Treat as a full pass."}
291
+ spec = [f"{last}..HEAD", "--", path, ":!documentation"]
292
+ _, names = _git(repo, "diff", "--name-status", *spec)
293
+ _, stat = _git(repo, "diff", "--stat", *spec)
294
+ _, log = _git(repo, "log", "--oneline", "-n", "30", *spec)
295
+ files = names.strip().splitlines()
296
+ return {
297
+ **base, "first_generation": False,
298
+ "changed_files": files[:200], "changed_files_truncated": len(files) > 200,
299
+ "summary": _tail(stat, 1500), "commits": log.strip().splitlines(),
300
+ "hint": "Read only these files, update only the affected pages. Only committed changes are listed.",
301
+ }
302
+
303
+
304
+ def write_page(repo_path: str, page: str, content: str, nav_title: str | None = None) -> dict[str, Any]:
305
+ repo = resolve_repo(repo_path)
306
+ doc = _require_installed(repo)
307
+ parts = page.split("/")
308
+ if (not PAGE_RE.match(page) or any(p.startswith(".") for p in parts) or parts[0] == "assets"):
309
+ raise SnapDoczillaError("page must be a relative .md path such as 'architecture.md' or 'adr/0002-use-queue.md'.")
310
+ if "\x00" in content or len(content.encode("utf-8")) > MAX_PAGE_BYTES:
311
+ raise SnapDoczillaError("content must not contain NUL characters and must be under 1 MB.")
312
+ src = (doc / "source").resolve()
313
+ target = (src / page).resolve()
314
+ if not target.is_relative_to(src):
315
+ raise SnapDoczillaError("page escapes documentation/source/.")
316
+ created = not target.exists()
317
+ target.parent.mkdir(parents=True, exist_ok=True)
318
+ target.write_text(content, encoding="utf-8", newline="\n")
319
+ nav_added = False
320
+ if nav_title:
321
+ nav_added = _add_to_nav(doc / "mkdocs.yml", page, nav_title)
322
+ return {
323
+ "page": page, "created": created, "nav_added": nav_added,
324
+ "reminder": "Every page must be in nav: or the strict build fails." if created and not nav_added else None,
325
+ }
326
+
327
+
328
+ def _add_to_nav(mkdocs: Path, page: str, title: str) -> bool:
329
+ if "\n" in title or "\r" in title:
330
+ raise SnapDoczillaError("nav_title must be a single line.")
331
+ raw = mkdocs.read_bytes().decode("utf-8")
332
+ nl = "\r\n" if "\r\n" in raw else "\n"
333
+ lines = raw.split(nl)
334
+ start = next((i for i, l in enumerate(lines) if l.rstrip() == "nav:"), None)
335
+ if start is None:
336
+ raise SnapDoczillaError("No top-level 'nav:' in documentation/mkdocs.yml; add the entry by hand.")
337
+ last, j = start, start + 1
338
+ while j < len(lines):
339
+ l = lines[j]
340
+ if l.strip() == "":
341
+ j += 1
342
+ elif l[0] in " \t-":
343
+ if page in re.split(r"[\s:'\"]+", l):
344
+ return False
345
+ last, j = j, j + 1
346
+ else:
347
+ break
348
+ indent = re.match(r"\s*", lines[start + 1]).group(0) if start + 1 < len(lines) and lines[start + 1].strip() else " "
349
+ lines.insert(last + 1, f"{indent or ' '}- {json.dumps(title, ensure_ascii=False)}: {page}")
350
+ mkdocs.write_bytes(nl.join(lines).encode("utf-8"))
351
+ return True
352
+
353
+
354
+ def build_html(repo_path: str) -> dict[str, Any]:
355
+ repo = resolve_repo(repo_path)
356
+ doc = _require_installed(repo)
357
+ rc, out = _run([bash_exe(), (doc / "update.sh").as_posix(), "--html-only"], cwd=repo, timeout=900)
358
+ problems = [l.strip() for l in out.splitlines() if re.search(r"\b(WARNING|ERROR)\b|Aborted|could not be found", l)]
359
+ return {
360
+ "ok": rc == 0,
361
+ "index": str(doc / "html" / "index.html"),
362
+ "problems": problems[:40],
363
+ "output_tail": _tail(out),
364
+ "hint": None if rc == 0 else
365
+ "The previous html/ was kept. Fix the cause (nav, links, snippet paths); never loosen mkdocs.yml to get green.",
366
+ }
367
+
368
+
369
+ def mark_synced(repo_path: str, area: str) -> dict[str, Any]:
370
+ repo = resolve_repo(repo_path)
371
+ doc = _require_installed(repo)
372
+ _area_path(doc, area)
373
+ rc, out = _git(repo, "rev-parse", "HEAD")
374
+ if rc != 0 or not HASH_RE.match(out.strip()):
375
+ raise SnapDoczillaError("The repo has no commits yet; commit something first.")
376
+ (doc / f".last-sync-{area}").write_text(out.strip() + "\n", encoding="utf-8", newline="\n")
377
+ return {"area": area, "last_sync": out.strip()}
@@ -0,0 +1,130 @@
1
+ """SnapDoczilla MCP server (stdio). Run with ``snapdoczilla-mcp`` or ``uvx snapdoczilla-mcp``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import sys
7
+ from typing import Annotated, Any
8
+
9
+ from mcp.server.mcpserver import MCPServer
10
+ from mcp.server.mcpserver.exceptions import ToolError
11
+ from mcp.types import ToolAnnotations
12
+ from pydantic import Field
13
+
14
+ from . import __version__, core
15
+
16
+ INSTRUCTIONS = """\
17
+ SnapDoczilla keeps offline docs-as-code (MkDocs Material + local Mermaid) inside a git repo.
18
+ YOU write the documentation from the real code; these tools do the deterministic parts.
19
+ This server does not read source code: use your client's own file tools for that.
20
+
21
+ Workflow
22
+ 1. snapdoczilla_status(repo): installed? which areas are out of date?
23
+ 2. Not installed: snapdoczilla_install (choose areas from the repo layout), then write the base pages.
24
+ 3. Installed: snapdoczilla_changes_since_sync(area), read ONLY the changed code, update ONLY the affected pages.
25
+ 4. snapdoczilla_get_rules before writing, and follow it (language, never invent, embed real code).
26
+ 5. snapdoczilla_write_page writes inside documentation/source/ only and can add the page to nav.
27
+ 6. snapdoczilla_build_html is strict: fix every reported problem, never loosen the config.
28
+ 7. snapdoczilla_mark_synced once an area is documented up to HEAD.
29
+ Never commit or push. Anything you cannot verify in the code is flagged as "to be confirmed".
30
+ """
31
+
32
+ mcp = MCPServer("snapdoczilla", instructions=INSTRUCTIONS, version=__version__)
33
+
34
+ Repo = Annotated[str, Field(description="Absolute path of the git repository (any folder inside it works).")]
35
+ Area = Annotated[str, Field(description="Area name from documentation/areas.conf, e.g. 'back' or 'front'.")]
36
+
37
+ READ_ONLY = ToolAnnotations(readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False)
38
+ WRITES_DOCS = ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=True, openWorldHint=False)
39
+
40
+
41
+ async def _off_loop(fn, *args) -> dict[str, Any]:
42
+ """Blocking work (git, bash, mkdocs) runs in a thread so the protocol loop keeps answering."""
43
+ try:
44
+ return await asyncio.to_thread(fn, *args)
45
+ except core.SnapDoczillaError as exc:
46
+ raise ToolError(str(exc)) from None # expected problems: show the model the real message
47
+
48
+
49
+ @mcp.tool(name="snapdoczilla_status", annotations=READ_ONLY)
50
+ async def snapdoczilla_status(repo: Repo) -> dict[str, Any]:
51
+ """Report whether SnapDoczilla is installed in the repo, its areas, how many commits each area is
52
+ behind its docs, the existing pages, the next ADR number and the suggested next step. Call this first."""
53
+ return await _off_loop(core.status, repo)
54
+
55
+
56
+ @mcp.tool(name="snapdoczilla_install",
57
+ annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=True))
58
+ async def snapdoczilla_install(
59
+ repo: Repo,
60
+ project_name: Annotated[str, Field(description="Human name shown as the site title.")],
61
+ language: Annotated[str, Field(description="Docs language code: 'en', 'es', ...")] = "en",
62
+ areas: Annotated[dict[str, str] | None, Field(
63
+ description="Code areas as {name: path relative to the repo root}, e.g. {'back': 'backend/', 'front': 'frontend/'}. "
64
+ "Omit for a single area covering the whole repo.")] = None,
65
+ ui_areas: Annotated[list[str] | None, Field(
66
+ description="Names from `areas` that contain UI components; they get component-page rules.")] = None,
67
+ ) -> dict[str, Any]:
68
+ """Install the documentation scaffold into documentation/ (templates, update script, pre-push reminder)
69
+ and download Mermaid once (needs internet). Refuses to touch an existing documentation/ folder."""
70
+ return await _off_loop(core.install, repo, project_name, language, areas, ui_areas)
71
+
72
+
73
+ @mcp.tool(name="snapdoczilla_get_rules", annotations=READ_ONLY)
74
+ async def snapdoczilla_get_rules(repo: Repo, area: Annotated[str | None, Field(
75
+ description="Also return this area's AGENT-RULES-<area>.md when it exists.")] = None) -> dict[str, Any]:
76
+ """Return the rules the documentation must follow (language, tone, page layout, no invention)."""
77
+ return await _off_loop(core.get_rules, repo, area)
78
+
79
+
80
+ @mcp.tool(name="snapdoczilla_changes_since_sync", annotations=READ_ONLY)
81
+ async def snapdoczilla_changes_since_sync(repo: Repo, area: Area) -> dict[str, Any]:
82
+ """List the files, commits and diffstat of an area since its last documented commit, so only the
83
+ affected pages are updated. With no previous sync it says to analyze the whole area."""
84
+ return await _off_loop(core.changes_since_sync, repo, area)
85
+
86
+
87
+ @mcp.tool(name="snapdoczilla_write_page", annotations=WRITES_DOCS)
88
+ async def snapdoczilla_write_page(
89
+ repo: Repo,
90
+ page: Annotated[str, Field(description="Path inside documentation/source/, e.g. 'architecture.md' or 'adr/0002-use-queue.md'.")],
91
+ content: Annotated[str, Field(description="Full Markdown of the page. Embed real code with --8<-- \"path/from/repo/root\".")],
92
+ nav_title: Annotated[str | None, Field(
93
+ description="Menu title. Pass it when creating a page so it is added to nav (the strict build needs that).")] = None,
94
+ ) -> dict[str, Any]:
95
+ """Create or overwrite one Markdown page. Writes are confined to documentation/source/."""
96
+ return await _off_loop(core.write_page, repo, page, content, nav_title)
97
+
98
+
99
+ @mcp.tool(name="snapdoczilla_build_html", annotations=WRITES_DOCS)
100
+ async def snapdoczilla_build_html(repo: Repo) -> dict[str, Any]:
101
+ """Rebuild documentation/html with a strict MkDocs build (no AI agent is invoked). The first run creates a
102
+ local venv and installs MkDocs, which takes a minute or two and needs internet. A failed build keeps the old html."""
103
+ return await _off_loop(core.build_html, repo)
104
+
105
+
106
+ @mcp.tool(name="snapdoczilla_mark_synced", annotations=WRITES_DOCS)
107
+ async def snapdoczilla_mark_synced(repo: Repo, area: Area) -> dict[str, Any]:
108
+ """Record the current HEAD as the last documented commit of an area. Call it after the area's pages are updated."""
109
+ return await _off_loop(core.mark_synced, repo, area)
110
+
111
+
112
+ @mcp.prompt(name="document_project", title="Document this project with SnapDoczilla")
113
+ def document_project(language: str = "en") -> str:
114
+ return (
115
+ f"Document this repository with SnapDoczilla in {language}. Start with snapdoczilla_status. "
116
+ "Install if needed, write the base pages from the real code (read it with your file tools), "
117
+ "build the HTML, fix every reported problem, mark each area as synced, then summarize what "
118
+ "was created and what is flagged as to be confirmed. Do not commit or push."
119
+ )
120
+
121
+
122
+ def main() -> None:
123
+ if "--version" in sys.argv[1:]:
124
+ print(__version__)
125
+ return
126
+ mcp.run() # stdio; stdout is reserved for the protocol
127
+
128
+
129
+ if __name__ == "__main__":
130
+ main()
@@ -0,0 +1,104 @@
1
+ ---
2
+ name: snapdoczilla
3
+ description: SnapDoczilla - sets up and maintains technical documentation for any repository (any backend, any frontend) as a 100% offline HTML site that lives inside the repo - no Confluence, no servers, no CI. Readers double-click documentation/html/index.html; any dev refreshes it with one command. Built with MkDocs Material, local Mermaid diagrams and react.dev-style component pages that embed real source code and real usage. Use it whenever the user wants to document a project or codebase, set up "docs as code", generate or update architecture/API/ADR/onboarding docs, document frontend components, or asks to "document this project" / "documenta este proyecto" / "actualiza la documentación" - even if they don't name SnapDoczilla or MkDocs. Also use it when a repo already has documentation/update.sh and the user asks to update the docs.
4
+ ---
5
+
6
+ # SnapDoczilla
7
+
8
+ Leaves a documentation system in the repo that **anyone reads with a double click** (no install, no internet) and **any dev updates with one command**.
9
+
10
+ `SKILL_DIR` below means the folder that contains this `SKILL.md`.
11
+
12
+ ## Scope
13
+
14
+ **What it documents** — any stack, as long as the code is in the repo:
15
+
16
+ | Area | Pages produced | Source of truth |
17
+ |---|---|---|
18
+ | Any project | `index.md` (what it is, stack, versions), `getting-started.md` (requirements, run, test), `adr/0001-*.md` | build files, README, config |
19
+ | Backend | `architecture.md` (layers, integrations, main flows in Mermaid), `api.md` (endpoints grouped by resource), data model if there is one | routes/controllers, services, clients, migrations/schemas |
20
+ | Frontend | screen/route map, `src/` structure, state management, **component pages** (what it is, props/events/slots, real usage, real code) | router, views/pages, components, stores |
21
+
22
+ Where to look, by stack (not exhaustive — read whatever the repo actually uses):
23
+
24
+ | Stack | Endpoints / routes | Run & build |
25
+ |---|---|---|
26
+ | Spring / Java | `@RestController`, `@*Mapping`, `@HttpExchange`/Feign clients | `pom.xml`, `build.gradle`, `application.*` |
27
+ | Node (Express, Nest, Fastify) | `router.get(...)`, `@Controller`/`@Get` | `package.json` scripts |
28
+ | Python (FastAPI, Django, Flask) | `@app.get`, `urls.py`, `@bp.route` | `pyproject.toml`, `requirements*.txt`, `manage.py` |
29
+ | .NET | `[ApiController]`, `MapGet` | `*.csproj`, `appsettings*.json` |
30
+ | Go / PHP / Ruby | `http.HandleFunc`/router libs, `routes/*.php`, `config/routes.rb` | `go.mod`, `composer.json`, `Gemfile` |
31
+ | Vue / React / Angular / Svelte | `router/`, `app/` or `pages/` dirs, `*.routes.ts` | `package.json`, `vite.config.*`, `angular.json` |
32
+
33
+ **What it deliberately does not do:** screenshots or live demos (they force running the app and go stale on their own), hosting or publishing, replacing OpenAPI/Swagger (it links to it), inventing behavior the code doesn't show.
34
+
35
+ ## Why it's designed this way
36
+
37
+ - **Everything inside the repo.** Docs travel with the code and are reviewed in the same PRs.
38
+ - **The HTML is committed.** Non-technical readers and new devs open `documentation/html/index.html`. The cost is big diffs in `html/`; accepted in exchange for zero setup.
39
+ - **Source code is never pasted.** Pages embed the real file at build time (`--8<-- "path"`), so they can't drift; if a file moves, the build fails naming it.
40
+ - **The agent assists, humans approve.** It writes the base and incremental updates; people review `git diff`. Anything it cannot verify is flagged as "to be confirmed".
41
+ - **Areas are independent.** `back`, `front`, etc. each have their own sync marker and command, so a frontend dev never regenerates backend docs.
42
+
43
+ ## Pick a flow
44
+
45
+ | Situation | Flow |
46
+ |---|---|
47
+ | No `documentation/mkdocs.yml` yet and the user wants docs | **A. Install** |
48
+ | `documentation/` exists and they want it refreshed | **B. Update** |
49
+ | They want one component documented | **C. Component page** |
50
+
51
+ If the repo already has a `documentation/` folder that was **not** made by SnapDoczilla (no `mkdocs.yml` + `update.sh`), stop and ask before touching it.
52
+
53
+ ## A. Install
54
+
55
+ 1. **Check prerequisites** — report what's missing, never install anything global: `git` (must be a git repo), `python3`/`python`, `bash` (on Windows it ships with Git for Windows). An agent CLI (`claude`, `codex`, `opencode`…) is only needed for one-command updates later; `--html-only` works without it.
56
+ 2. **Detect areas.** Look for `backend/`, `frontend/`, `api/`, `web/`, `apps/*`, `packages/*`, `src/`. One block → a single area `code=.`. Clearly separate parts → one area each (`back=backend/`, `front=frontend/`). Ask only if the split is not evident.
57
+ 3. **Pick the docs language:** the language the user is writing in, unless they ask for another (`en`, `es`, …).
58
+ 4. **Run the installer** (copies templates, downloads Mermaid once, adds `.gitignore`/`.gitattributes` lines; refuses to overwrite an existing install):
59
+ ```bash
60
+ bash "$SKILL_DIR/scripts/install.sh" "<repo-root>" "<Project name>" <lang>
61
+ ```
62
+ 5. **Write `documentation/areas.conf`** with the detected areas (`name=path`).
63
+ 6. **UI areas:** copy `documentation/AGENT-RULES-COMPONENTS.md` to `documentation/AGENT-RULES-<area>.md` (e.g. `AGENT-RULES-front.md`); per-area rules are picked up automatically. No UI area → delete the template.
64
+ 7. **Generate the first content** in `documentation/source/`, following `AGENT-RULES.md` (and each area's rules) and the Scope table above. For UI areas, write 2–3 component pages for representative components (ask which, or pick the most reused). Read real code; don't invent. Add every page to `nav:` in `mkdocs.yml`.
65
+ 8. **Existing docs** (md, docx, pdf) are context for the *why*, never a substitute for reading code. Convert docx/pdf to Markdown before reading, and never copy secrets or tokens they may contain.
66
+ 9. **Mark sync:** for each area, `git rev-parse HEAD > documentation/.last-sync-<area>`.
67
+ 10. **Build and verify:**
68
+ ```bash
69
+ bash documentation/update.sh --html-only
70
+ ```
71
+ First build installs MkDocs into `documentation/.venv` (1–2 min, needs internet). `snippet ... could not be found` → fix that path. If Chrome/Edge is available, open `html/architecture.html` headless with `--allow-file-access-from-files --dump-dom` and check there are more `<svg` than on a page without diagrams (Material adds ~7 icons).
72
+ 11. **Hand over** a short summary: what was created, how to read it, how to update it (below), what is flagged as to be confirmed. **Do not commit or push** — that is the user's call.
73
+
74
+ ## B. Update
75
+
76
+ - **From a terminal:** `documentation/update.cmd` (Windows double-click) or `./documentation/update.sh [--<area>] [--html-only]`. It sends the diff since `.last-sync-<area>` to an agent CLI, which edits only `source/`, then rebuilds the HTML (atomically: a failed build never empties `html/`). Default CLI is Claude Code with tools restricted to `documentation/source/`; set `DOCS_LLM` to use another agent:
77
+
78
+ | Agent | `DOCS_LLM` |
79
+ |---|---|
80
+ | Claude Code | *(unset — default)* |
81
+ | Codex CLI | `codex exec --full-auto` |
82
+ | OpenCode | `opencode run` |
83
+ | Gemini CLI | `gemini --yolo -p` |
84
+ | Other | any non-interactive command that takes the prompt as its last argument |
85
+
86
+ Only the Claude default restricts writable paths; with other agents rely on their sandbox and review `git diff`.
87
+ - **Inside this session (any agent, incl. Antigravity):** read `AGENT-RULES*.md`, run `git diff <hash in .last-sync-<area>>..HEAD -- <area path>`, edit only the affected pages, run `bash documentation/update.sh --html-only`, then `git rev-parse HEAD > documentation/.last-sync-<area>`.
88
+ - Check line-range snippets (`path:START:END`) when their source file changed: they don't fail when they drift, they just show the wrong lines.
89
+
90
+ ## C. Component page
91
+
92
+ Follow `AGENT-RULES-COMPONENTS.md`: what it is → how it works → API → **real usage example** → **real source code** → things to know. Read the component *and* its real consumers; the usage example comes from an existing screen (if none exists, label it as illustrative). Incomplete component (empty files, `console.log`, commented-out code) → say so in a `!!! warning` block. Add the page to `nav:`.
93
+
94
+ ## Good to know
95
+
96
+ - Diagrams are ```` ```mermaid ```` blocks. The fence class is `diagram` on purpose: it stops Material from loading Mermaid from the internet; `assets/diagrams.js` renders them with the local copy.
97
+ - Builds are `--strict` with `validation` set to `warn`: a page missing from `nav:`, a broken link or a missing snippet fails the build (and the previous `html/` stays). Fix the cause; never loosen the config to get a green build.
98
+ - `install.sh` sets `repo_url`/`edit_uri` from the `origin` remote (GitHub/GitLab) so every page gets an "edit this page" button; other hosts are skipped silently.
99
+ - Readers get a light/dark toggle; diagrams redraw in the matching theme.
100
+ - The user wants it on the web too? `html/` is a static site: point them to the GitHub Pages workflow in the SnapDoczilla README instead of adding hosting yourself.
101
+ - `requirements.txt` pins `mkdocs<2` and `mkdocs-material<10` (MkDocs 2.0 breaks plugins).
102
+ - First run needs internet (pip + one Mermaid download); reading never does.
103
+ - Merge conflict in `html/`: resolve the `.md` files, rerun `--html-only`, commit the result. Never hand-merge HTML.
104
+ - Suggest naming **one docs owner** who reviews, while any dev can run the update.
@@ -0,0 +1,47 @@
1
+ # Rules for the agent: UI component pages
2
+
3
+ Template for UI areas (Vue, React, Svelte, Angular, …). The skill copies it as `AGENT-RULES-<area>.md`
4
+ for each UI area. These rules apply on top of `AGENT-RULES.md` (including its language).
5
+
6
+ ## Required structure of a component page
7
+
8
+ One page per component (`<area>/<name>.md`, added to `nav:`), with these sections in this order:
9
+
10
+ 1. **What it is and what it's for** (2–4 lines a non-technical reader understands) + where it appears in the app.
11
+ 2. **How it works** (Mermaid diagram or a short list; visible business rules).
12
+ 3. **Component API**: tables of *Props*, *Events* and *Slots* (or the framework's equivalents): name, type, default, description. Taken from the code, never guessed.
13
+ 4. **Usage example from this project** — required. REAL code from the repo.
14
+ 5. **Source code** — required. REAL code from the repo.
15
+ 6. **Things to know**: dependencies (hooks/composables, stores, constants, endpoints) and tech debt or non-obvious behavior.
16
+
17
+ ## Embedding real code (never copy-paste)
18
+
19
+ `pymdownx.snippets` inserts the current file at build time, so the page never drifts.
20
+
21
+ ````markdown
22
+ === "Usage"
23
+
24
+ ```tsx title="Screen.tsx"
25
+ --8<-- "src/screens/Screen.tsx"
26
+ ```
27
+
28
+ === "Code"
29
+
30
+ ```tsx title="Button.tsx"
31
+ --8<-- "src/components/Button.tsx"
32
+ ```
33
+ ````
34
+
35
+ - Paths are relative to the repo root. Line range: `--8<-- "path:START:END"`.
36
+ - Prefer whole files. Use ranges only to pull one usage line out of a big file, and **re-check the range** whenever that file changes.
37
+ - Files over ~150 lines: wrap them in `??? note "Full source"` (content indented 4 spaces).
38
+ - Build fails with "snippet ... could not be found" → the file moved or was renamed: fix the path.
39
+ - An example that doesn't exist in the project must be labeled as illustrative.
40
+
41
+ ## Maturity
42
+
43
+ If the component is incomplete (empty files, `console.log`, commented-out code), say so in a `!!! warning` block listing what's missing. Never document as working what doesn't work.
44
+
45
+ ## No screenshots or live demos
46
+
47
+ They force running the app and go stale on their own.
@@ -0,0 +1,17 @@
1
+ # Rules for the agent that updates this documentation
2
+
3
+ - **Documentation language: {{LANGUAGE}}.** Write every page, heading and marker in this language. Technical, direct tone.
4
+ - **Edit only `documentation/source/`** (plus the `nav:` in `documentation/mkdocs.yml` when adding a page). Never touch code, `html/` or other folders.
5
+ - Document only what exists in the code. Anything you cannot verify gets a `> ⚠️` "to be confirmed" note (written in the documentation language). Never invent endpoints, tables or credentials (use `{PLACEHOLDER}`). Never copy secrets, tokens or passwords.
6
+ - Diagrams: ```` ```mermaid ```` blocks inside the `.md` files (they render offline).
7
+ - Real code in the docs: never paste it; embed it with `--8<-- "path/from/repo/root"` (see `AGENT-RULES-COMPONENTS.md`).
8
+ - Base pages (keep the file names; every new page also goes into `nav:`):
9
+ - `index.md`: what the project is, stack and versions.
10
+ - `getting-started.md`: requirements, how to run and test, taken from real files (README, build, config). If an existing README contradicts the real build, point out the discrepancy.
11
+ - `architecture.md`: layers, integrations and main flows, with Mermaid.
12
+ - `api.md`: only if the project exposes an API; read the real routes/controllers.
13
+ - `adr/NNNN-title.md`: one decision per file (Context, Decision, Consequences). New ADRs only for real architecture decisions (new library, database change, new pattern).
14
+ - Incremental updates: change only the pages affected by the diff; don't rewrite what is still valid.
15
+ - Keep the last line of `index.md` as `Last sync: <short hash> (<date>)` (translated to the documentation language).
16
+ - Code areas and their paths live in `areas.conf`.
17
+ - The build runs with `--strict`: every page must be listed in `nav:`, and every link and snippet path must resolve. A failed build keeps the previous `html/`; fix the cause and rebuild.
@@ -0,0 +1,8 @@
1
+ # Project documentation (SnapDoczilla)
2
+
3
+ - **Read:** open `documentation/html/index.html` with a double click. No install, no internet.
4
+ - **Update:** `documentation/update.cmd` (Windows) or `./documentation/update.sh`. Options: `--<area>` (see `areas.conf`) or `--html-only` (no agent). Review the changes and commit.
5
+ - Editable source: `documentation/source/*.md`. Rules for the agent: `documentation/AGENT-RULES*.md`.
6
+ - To update you need Git (with bash), Python 3 and an agent CLI: Claude Code by default, or another one via `DOCS_LLM` (e.g. `DOCS_LLM="codex exec --full-auto"`). No agent: `--html-only`.
7
+
8
+ Generated with [SnapDoczilla](https://github.com/julvc/skills).
@@ -0,0 +1,9 @@
1
+ # Code areas documented and synced independently.
2
+ # Format: name=path (path relative to the repo root; "." = whole repo)
3
+ # Each area gets its own .last-sync-<name> marker and may have its own rules in
4
+ # AGENT-RULES-<name>.md. Update one area with: update.sh --<name>
5
+ #
6
+ # Examples:
7
+ # back=backend/
8
+ # front=frontend/
9
+ code=.
@@ -0,0 +1,60 @@
1
+ site_name: "{{PROJECT}}"
2
+ docs_dir: source
3
+ site_dir: html
4
+ use_directory_urls: false # lets html/index.html open with a double click (file://)
5
+
6
+ validation: # with update.sh --strict these fail the build instead of passing silently
7
+ omitted_files: warn # a page that is not in nav
8
+ unrecognized_links: warn
9
+ absolute_links: warn
10
+
11
+ theme:
12
+ name: material
13
+ language: {{LANG}}
14
+ features:
15
+ - content.code.copy # "copy" button on every code block
16
+ - content.action.edit # "edit this page" button (needs repo_url + edit_uri, set by install.sh)
17
+ - navigation.sections
18
+ palette: # light/dark toggle, starts with the reader's OS preference
19
+ - media: "(prefers-color-scheme: light)"
20
+ scheme: default
21
+ toggle:
22
+ icon: material/weather-night
23
+ name: Dark mode
24
+ - media: "(prefers-color-scheme: dark)"
25
+ scheme: slate
26
+ toggle:
27
+ icon: material/weather-sunny
28
+ name: Light mode
29
+
30
+ plugins:
31
+ - search
32
+ - offline # search works without a server
33
+
34
+ extra_javascript:
35
+ - assets/mermaid.min.js # local copy (downloaded by install.sh): diagrams without internet
36
+ - assets/diagrams.js
37
+
38
+ markdown_extensions:
39
+ - tables
40
+ - admonition
41
+ - attr_list
42
+ - pymdownx.details
43
+ - pymdownx.highlight:
44
+ anchor_linenums: true
45
+ - pymdownx.tabbed:
46
+ alternate_style: true
47
+ - pymdownx.snippets:
48
+ # Embeds REAL repo code on every build: --8<-- "path/from/repo/root"
49
+ # Line range: --8<-- "path:START:END". The build runs from documentation/.
50
+ base_path: [".."]
51
+ check_paths: true # a moved/deleted file fails the build, naming it
52
+ - pymdownx.superfences:
53
+ custom_fences:
54
+ # class "diagram" (not "mermaid") so Material never tries to load mermaid from the internet
55
+ - name: mermaid
56
+ class: diagram
57
+ format: !!python/name:pymdownx.superfences.fence_code_format
58
+
59
+ nav:
60
+ - Home: index.md
@@ -0,0 +1,2 @@
1
+ mkdocs>=1.6,<2
2
+ mkdocs-material>=9.7,<10
@@ -0,0 +1,31 @@
1
+ // Turns ```mermaid blocks (rendered as <pre class="diagram">) into SVG diagrams.
2
+ // Uses the local assets/mermaid.min.js: no internet needed. Follows the light/dark toggle.
3
+ (function () {
4
+ function theme() {
5
+ return document.body.getAttribute("data-md-color-scheme") === "slate" ? "dark" : "default";
6
+ }
7
+ function draw() {
8
+ if (!window.mermaid) return;
9
+ document.querySelectorAll("pre.diagram").forEach(function (pre) {
10
+ var div = document.createElement("div");
11
+ div.className = "mermaid";
12
+ div.dataset.source = pre.textContent;
13
+ pre.replaceWith(div);
14
+ });
15
+ var diagrams = document.querySelectorAll("div.mermaid");
16
+ if (!diagrams.length) return;
17
+ diagrams.forEach(function (div) {
18
+ div.removeAttribute("data-processed");
19
+ div.textContent = div.dataset.source;
20
+ });
21
+ window.mermaid.initialize({ startOnLoad: false, theme: theme() });
22
+ window.mermaid.run({ nodes: diagrams });
23
+ }
24
+ function start() {
25
+ draw();
26
+ // redraw with the matching theme when the reader toggles light/dark
27
+ new MutationObserver(draw).observe(document.body, { attributeFilter: ["data-md-color-scheme"] });
28
+ }
29
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start);
30
+ else start();
31
+ })();
@@ -0,0 +1,6 @@
1
+ # {{PROJECT}}
2
+
3
+ > Placeholder created by SnapDoczilla. The agent replaces it with real content.
4
+
5
+ ---
6
+ Last sync: pending
@@ -0,0 +1,4 @@
1
+ @echo off
2
+ rem Double-click on Windows. Needs Git for Windows (ships bash). Optional args: --<area> --html-only
3
+ bash "%~dp0update.sh" %*
4
+ pause
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env bash
2
+ # SnapDoczilla: (1) an agent CLI updates documentation/source from the code that changed,
3
+ # (2) the HTML site in documentation/html is rebuilt.
4
+ # ./documentation/update.sh -> every area in areas.conf: agent + HTML
5
+ # ./documentation/update.sh --<area> -> one area only (e.g. --front)
6
+ # ./documentation/update.sh --html-only -> HTML only (after editing .md by hand; no agent)
7
+ # Agent: Claude Code by default. Another one: DOCS_LLM="codex exec --full-auto" | "opencode run" | "gemini --yolo -p"
8
+ # (any non-interactive command that takes the prompt as its last argument).
9
+ set -euo pipefail
10
+
11
+ DOC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
12
+ ROOT="$(git -C "$DOC_DIR" rev-parse --show-toplevel)"
13
+ CONF="$DOC_DIR/areas.conf"
14
+ [ -f "$CONF" ] || { echo "ERROR: missing $CONF"; exit 1; }
15
+
16
+ # "name=path" lines, without comments or blanks
17
+ LINES="$(grep -v -E '^[[:space:]]*(#|$)' "$CONF")"
18
+ ALL="$(echo "$LINES" | cut -d= -f1)"
19
+ path_of() { echo "$LINES" | grep -E "^$1=" | head -1 | cut -d= -f2-; }
20
+
21
+ AREAS="$ALL"; HTML_ONLY=0
22
+ for a in "$@"; do
23
+ if [ "$a" = "--html-only" ]; then HTML_ONLY=1
24
+ elif echo "$ALL" | grep -qx -- "${a#--}"; then AREAS="${a#--}"
25
+ else echo "Unknown option: $a (areas: $(echo $ALL | tr '\n' ' '), --html-only)"; exit 1
26
+ fi
27
+ done
28
+
29
+ # --- 1. Agent: incremental update since the last documented commit of each area ---
30
+ if [ "$HTML_ONLY" -eq 0 ]; then
31
+ LLM="${DOCS_LLM:-claude}"
32
+ command -v "${LLM%% *}" >/dev/null || { echo "ERROR: '${LLM%% *}' not found. Install it, set DOCS_LLM or use --html-only."; exit 1; }
33
+ for AREA in $AREAS; do
34
+ AREA_PATH="$(path_of "$AREA")"
35
+ STATE="$DOC_DIR/.last-sync-$AREA"
36
+ RULES="documentation/AGENT-RULES.md"
37
+ [ -f "$DOC_DIR/AGENT-RULES-$AREA.md" ] && RULES="$RULES and documentation/AGENT-RULES-$AREA.md"
38
+ BASE="$(cat "$STATE" 2>/dev/null || echo "")"
39
+ if [ -n "$BASE" ]; then
40
+ SCOPE="Changes since commit $BASE: run 'git diff $BASE..HEAD --stat -- $AREA_PATH :!documentation' and read only the relevant files. Update only the affected pages."
41
+ else
42
+ SCOPE="First generation for this area: analyze '$AREA_PATH' fully."
43
+ fi
44
+ PROMPT="Follow the rules in $RULES and update documentation/source. Area: $AREA ($AREA_PATH). $SCOPE Edit only documentation/source/ and the nav of documentation/mkdocs.yml."
45
+ echo ">> Documenting area: $AREA ($AREA_PATH)"
46
+ if [ -z "${DOCS_LLM:-}" ]; then
47
+ # Claude Code: tools restricted to documentation/source/
48
+ (cd "$ROOT" && claude -p "$PROMPT" \
49
+ --allowedTools "Read,Grep,Glob,Bash(git diff:*),Bash(git log:*),Edit(documentation/source/**),Edit(documentation/mkdocs.yml),Write(documentation/source/**)")
50
+ else
51
+ # ponytail: word splitting on purpose (DOCS_LLM = command + flags); review git diff afterwards
52
+ (cd "$ROOT" && $DOCS_LLM "$PROMPT")
53
+ fi
54
+ git -C "$ROOT" rev-parse HEAD > "$STATE"
55
+ done
56
+ fi
57
+
58
+ # --- 2. HTML: MkDocs goes into a local venv the first time (system Python untouched) ---
59
+ PY="$(command -v python3 || command -v python || command -v py || true)"
60
+ [ -n "$PY" ] || { echo "ERROR: Python 3 not found."; exit 1; }
61
+ if [ ! -d "$DOC_DIR/.venv" ]; then "$PY" -m venv "$DOC_DIR/.venv"; fi
62
+ VPY="$DOC_DIR/.venv/Scripts/python"; [ -x "$VPY" ] || [ -f "$VPY.exe" ] || VPY="$DOC_DIR/.venv/bin/python"
63
+ "$VPY" -m pip install -q -r "$DOC_DIR/requirements.txt"
64
+ # Built from documentation/ so snippets (base_path "..") resolve from the repo root.
65
+ # Built into a temp dir and swapped in only on success: a failed build never empties html/.
66
+ # --strict (never with -q: -q hides the warnings strict counts): broken links, missing snippets or pages left out of nav fail the build.
67
+ rm -rf "$DOC_DIR/.html-new"
68
+ (cd "$DOC_DIR" && "$VPY" -m mkdocs build --strict -f mkdocs.yml -d .html-new)
69
+ rm -rf "$DOC_DIR/html" && mv "$DOC_DIR/.html-new" "$DOC_DIR/html"
70
+
71
+ # enable the pre-push reminder (idempotent)
72
+ [ -d "$ROOT/.githooks" ] && git -C "$ROOT" config core.hooksPath .githooks
73
+
74
+ echo "OK. Open documentation/html/index.html and review 'git status documentation/' before committing."
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env bash
2
+ # SnapDoczilla: only warns (never blocks the push) when code changed without docs.
3
+ conf=documentation/areas.conf
4
+ [ -f "$conf" ] || exit 0
5
+ grep -v -E '^[[:space:]]*(#|$)' "$conf" | while IFS== read -r area path; do
6
+ last="$(cat "documentation/.last-sync-$area" 2>/dev/null)" || continue
7
+ n="$(git rev-list --count "$last"..HEAD -- "$path" ':!documentation' 2>/dev/null || echo 0)"
8
+ if [ "$n" -gt 0 ]; then
9
+ echo "Docs reminder ($area): $n commit(s) not documented yet. Run: documentation/update.cmd --$area (or ./documentation/update.sh --$area)" >&2
10
+ fi
11
+ done
12
+ exit 0
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env bash
2
+ # SnapDoczilla installer: sets up the offline documentation system in a repo.
3
+ # Usage: install.sh <repo-root> "<Project name>" [language: en|es|...] (default: en)
4
+ # Never overwrites an existing documentation/mkdocs.yml or .githooks/pre-push.
5
+ set -euo pipefail
6
+
7
+ SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
8
+ REPO="${1:?Usage: install.sh <repo-root> \"<Project name>\" [en|es|...]}"
9
+ NAME="${2:?Missing project name}"
10
+ LANG_CODE="${3:-en}"
11
+ case "$LANG_CODE" in en) LANGUAGE="English" ;; es) LANGUAGE="Spanish (español)" ;; *) LANGUAGE="$LANG_CODE" ;; esac
12
+ DEST="$REPO/documentation"
13
+
14
+ git -C "$REPO" rev-parse --show-toplevel >/dev/null 2>&1 || { echo "ERROR: $REPO is not a git repository"; exit 1; }
15
+ [ -e "$DEST/mkdocs.yml" ] && { echo "ERROR: $DEST is already set up. Nothing was changed."; exit 1; }
16
+
17
+ mkdir -p "$DEST" "$REPO/.githooks"
18
+ cp -R "$SKILL_DIR/assets/documentation/." "$DEST/"
19
+ [ -e "$REPO/.githooks/pre-push" ] || cp "$SKILL_DIR/assets/githooks/pre-push" "$REPO/.githooks/pre-push"
20
+ chmod +x "$DEST/update.sh" "$REPO/.githooks/pre-push"
21
+
22
+ # project name and language into templates (sed without -i: portable across macOS/Linux/Git Bash)
23
+ for f in "$DEST/mkdocs.yml" "$DEST/source/index.md" "$DEST/AGENT-RULES.md"; do
24
+ sed -e "s|{{PROJECT}}|$NAME|g" -e "s|{{LANG}}|$LANG_CODE|g" -e "s|{{LANGUAGE}}|$LANGUAGE|g" "$f" > "$f.tmp" && mv "$f.tmp" "$f"
25
+ done
26
+
27
+ # "edit this page" button: derive the web URL from the git remote (GitHub/GitLab; others skipped)
28
+ REMOTE="$(git -C "$REPO" remote get-url origin 2>/dev/null || true)"
29
+ WEB="$(echo "$REMOTE" | sed -E -e 's#^git@([^:]+):#https://\1/#' -e 's#^(https?://)[^@/]+@#\1#' -e 's#\.git$##')"
30
+ BRANCH="$(git -C "$REPO" symbolic-ref --short HEAD 2>/dev/null || echo main)"
31
+ case "$WEB" in
32
+ https://github.com/*) EDIT="edit/$BRANCH/documentation/source/" ;;
33
+ https://gitlab.*|https://*gitlab*) EDIT="-/edit/$BRANCH/documentation/source/" ;;
34
+ *) EDIT="" ;;
35
+ esac
36
+ [ -n "$EDIT" ] && printf '\nrepo_url: %s\nedit_uri: %s\n' "$WEB" "$EDIT" >> "$DEST/mkdocs.yml"
37
+
38
+ # the local venv and temp build dir are not versioned; the generated HTML is (read with zero setup)
39
+ GI="$REPO/.gitignore"
40
+ grep -qs '^documentation/.venv/' "$GI" || printf '\n# SnapDoczilla: local venv and temp build dir\ndocumentation/.venv/\ndocumentation/.html-new/\n' >> "$GI"
41
+
42
+ # scripts must keep LF line endings (with CRLF, bash fails on Linux/macOS/Git Bash)
43
+ GA="$REPO/.gitattributes"
44
+ grep -qs 'SnapDoczilla' "$GA" || printf '\n# SnapDoczilla\ndocumentation/*.sh text eol=lf\n.githooks/* text eol=lf\ndocumentation/source/assets/mermaid.min.js -text\n' >> "$GA"
45
+
46
+ # local Mermaid copy: diagrams render without internet (downloaded once)
47
+ MERMAID="$DEST/source/assets/mermaid.min.js"
48
+ if ! curl -fsSL -o "$MERMAID" "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"; then
49
+ rm -f "$MERMAID"
50
+ echo "WARNING: could not download Mermaid. Save https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js as $MERMAID"
51
+ fi
52
+
53
+ echo "SnapDoczilla installed in $DEST"
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.5
2
+ Name: snapdoczilla-mcp
3
+ Version: 1.0.0
4
+ Summary: MCP server for SnapDoczilla: offline docs-as-code (MkDocs + Mermaid) for any backend and frontend, written by your AI agent.
5
+ Project-URL: Homepage, https://github.com/julvc/skills
6
+ Project-URL: Repository, https://github.com/julvc/skills
7
+ Project-URL: Issues, https://github.com/julvc/skills/issues
8
+ Author-email: Julio Varas Contreras <jvarascontreras@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: adr,ai-agents,docs-as-code,documentation,mcp,mermaid,mkdocs,model-context-protocol,offline
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Software Development :: Documentation
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: mcp<3,>=2.0
19
+ Description-Content-Type: text/markdown
20
+
21
+ # SnapDoczilla MCP server
22
+
23
+ <!-- mcp-name: io.github.julvc/snapdoczilla -->
24
+
25
+ Offline **docs-as-code** for any backend and any frontend, driven by your AI agent. SnapDoczilla keeps a
26
+ `documentation/` folder inside your repo: a static HTML site (MkDocs Material, local Mermaid diagrams, ADRs,
27
+ API map and component pages with **real code and real usage**) that anyone opens with a double click and any dev
28
+ refreshes with one command.
29
+
30
+ This MCP server gives your agent the deterministic half of that workflow. **The agent writes the docs from your
31
+ code; the server installs, tracks what changed since the last documented commit, writes pages safely, and builds the site.**
32
+
33
+ ## Install
34
+
35
+ Needs `uv` (for `uvx`), `git`, and `bash` (on Windows: [Git for Windows](https://git-scm.com)). Building the site
36
+ also needs Python 3 on `PATH`, and internet the first time (MkDocs and Mermaid are downloaded once).
37
+
38
+ **Claude Code**
39
+
40
+ ```bash
41
+ claude mcp add snapdoczilla -- uvx snapdoczilla-mcp
42
+ ```
43
+
44
+ **Claude Desktop, Cursor and other clients that use `mcpServers` JSON**
45
+
46
+ ```json
47
+ {
48
+ "mcpServers": {
49
+ "snapdoczilla": {
50
+ "command": "uvx",
51
+ "args": ["snapdoczilla-mcp"]
52
+ }
53
+ }
54
+ }
55
+ ```
56
+
57
+ **Codex CLI** (`~/.codex/config.toml`)
58
+
59
+ ```toml
60
+ [mcp_servers.snapdoczilla]
61
+ command = "uvx"
62
+ args = ["snapdoczilla-mcp"]
63
+ ```
64
+
65
+ This server does **not** read your source code. Your client does, with its own file tools (Claude Code, Cursor and
66
+ Codex have them; in Claude Desktop add a filesystem server).
67
+
68
+ ## Tools
69
+
70
+ | Tool | What it does |
71
+ |---|---|
72
+ | `snapdoczilla_status` | Installed? Areas, commits not yet documented per area, existing pages, next ADR number, suggested next step. Start here. |
73
+ | `snapdoczilla_install` | Scaffolds `documentation/` (templates, update script, pre-push reminder), writes `areas.conf`, downloads Mermaid once. Refuses to touch a `documentation/` it did not create. |
74
+ | `snapdoczilla_get_rules` | Returns the rules the pages must follow (language, never invent, embed real code). |
75
+ | `snapdoczilla_changes_since_sync` | Files, commits and diffstat of an area since its last documented commit, so only affected pages are updated. |
76
+ | `snapdoczilla_write_page` | Creates or overwrites one Markdown page, confined to `documentation/source/`, and adds it to `nav`. |
77
+ | `snapdoczilla_build_html` | Strict MkDocs build into `documentation/html/`. A failed build keeps the previous site. No AI agent is invoked. |
78
+ | `snapdoczilla_mark_synced` | Records HEAD as the last documented commit of an area. |
79
+
80
+ There is also a prompt, `document_project`, that starts the whole flow.
81
+
82
+ ## Typical use
83
+
84
+ Ask your agent: *"Document this project"* (or *"update the docs"*). It checks status, installs if needed, reads the
85
+ real code, writes the pages, builds, and marks the areas as synced. It never commits or pushes: you review `git diff`.
86
+
87
+ ## Also available as a plain Agent Skill
88
+
89
+ The same workflow ships as a [`SKILL.md`](https://github.com/julvc/skills/blob/main/skills/snapdoczilla/SKILL.md)
90
+ for Claude Code, Codex, OpenCode, Gemini CLI and more. See the
91
+ [repository](https://github.com/julvc/skills) for screenshots, the `examples/shop` result and the full documentation.
92
+
93
+ MIT License.
@@ -0,0 +1,21 @@
1
+ snapdoczilla_mcp/__init__.py,sha256=7xJnUoaw8KITZZYJAFOeE8vQv_6H_R7NsXsDu_33TvU,100
2
+ snapdoczilla_mcp/core.py,sha256=zSfQQPqGsN0aMLsxDC7OaEF2Xjrk8Jhqge4TVwzDRTE,16452
3
+ snapdoczilla_mcp/server.py,sha256=fbapdB7wAUcX-NBVLGbUQZ1jpspwCmIneIB7t16Pggg,6890
4
+ snapdoczilla_mcp/skill/SKILL.md,sha256=sR6NCfXPPg7sbbiFpZ_Zds_N2NFr-ANea3pRbmLwoI8,9438
5
+ snapdoczilla_mcp/skill/assets/documentation/AGENT-RULES-COMPONENTS.md,sha256=OaWmq3Ihtto_xPTVcw-kXGafOoqrGw4RkEouH9wp3dc,2098
6
+ snapdoczilla_mcp/skill/assets/documentation/AGENT-RULES.md,sha256=KxEeyuMqHaG5KwBbWl-15TluBkiiio2UcYnoX5ii50I,1901
7
+ snapdoczilla_mcp/skill/assets/documentation/README.md,sha256=u0pOGEgTr8iFg3Thhr3ZMXTuLrSVbu75It7O0-EhwZA,672
8
+ snapdoczilla_mcp/skill/assets/documentation/areas.conf,sha256=wQX9vly1f_Oh4GNUDUqVqEkZYf48m65attD1hX-wQZo,330
9
+ snapdoczilla_mcp/skill/assets/documentation/mkdocs.yml,sha256=eJ7KsssXCmzLBeftLJZ17bw2MwJ3y2Kcy6zXuUPa1RA,1937
10
+ snapdoczilla_mcp/skill/assets/documentation/requirements.txt,sha256=vHu_GuQRZln8BfaLeBl8kQbNPYIhnRTzgZKM3ujlq5I,40
11
+ snapdoczilla_mcp/skill/assets/documentation/update.cmd,sha256=tHqMbSJZq4Xch48Kzx4dswebw8ixiM_UhKlAjbsBylg,142
12
+ snapdoczilla_mcp/skill/assets/documentation/update.sh,sha256=rZH_qp4mM_WrY9ZhmeBEhyICgTyriw4z_-hFi_5YKIA,4080
13
+ snapdoczilla_mcp/skill/assets/documentation/source/index.md,sha256=j2vgL6cGFvBQWOVF0Y4H6sHMlJDhXoUvYp1pDONV-jU,119
14
+ snapdoczilla_mcp/skill/assets/documentation/source/assets/diagrams.js,sha256=6_Zbt-8PWk1mPkoa6Ea8RU0EQx3NZ8i3wsjdnUrzU9M,1272
15
+ snapdoczilla_mcp/skill/assets/githooks/pre-push,sha256=NionJvHfUMk7-4q_FSt1kVQ324i6xW1BNAMsAOwlY_4,593
16
+ snapdoczilla_mcp/skill/scripts/install.sh,sha256=bhFuSbJSoiH_U81vA0f3wZfIKy2oPdkXtur66Q89asY,3011
17
+ snapdoczilla_mcp-1.0.0.dist-info/METADATA,sha256=Xz_mgwsln4UUZRxPTnwAMsxcjQU6fQRYgwnRq97LGvw,4078
18
+ snapdoczilla_mcp-1.0.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
19
+ snapdoczilla_mcp-1.0.0.dist-info/entry_points.txt,sha256=yQaudz0Vh12GB2N1aXlnXLTFl-7R5ykFKr3814W--kY,66
20
+ snapdoczilla_mcp-1.0.0.dist-info/licenses/LICENSE,sha256=0Eqe_4w7RQ9PGOLFHwTprUB_0tKfjv9Oj0T0uwR5B0I,2014
21
+ snapdoczilla_mcp-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ snapdoczilla-mcp = snapdoczilla_mcp.server:main
@@ -0,0 +1,17 @@
1
+ Copyright (c) 2026 Julio Andrés Varas Contreras. All rights reserved.
2
+
3
+ This software, the source code, and its associated documentation are the exclusive property of the author.
4
+
5
+ Any copying, reproduction, modification, distribution, publication, sublicensing, and/or sale of this code or any part of it, whether in its original or modified form, for commercial or non-commercial purposes, is strictly prohibited without the prior, express, and written consent of the author.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8
+
9
+ ====================================================================================================================================
10
+
11
+ Copyright (c) 2026 Julio Andrés Varas Contreras. Todos los derechos reservados.
12
+
13
+ Este software, el código fuente y su documentación asociada son propiedad exclusiva del autor.
14
+
15
+ Queda estrictamente prohibida la copia, reproducción, modificación, distribución, publicación, sublicencia y/o venta de este código o cualquier parte del mismo, ya sea en formato original o modificado, con fines comerciales o no comerciales, sin el consentimiento previo, expreso y por escrito del autor.
16
+
17
+ EL SOFTWARE SE PROPORCIONA "TAL CUAL", SIN GARANTÍA DE NINGÚN TIPO, EXPRESA O IMPLÍCITA, INCLUYENDO PERO NO LIMITADO A GARANTÍAS DE COMERCIALIZACIÓN, IDONEIDAD PARA UN PROPÓSITO PARTICULAR Y NO INFRACCIÓN. EN NINGÚN CASO EL AUTOR SERÁ RESPONSABLE DE NINGUNA RECLAMACIÓN, DAÑO U OTRA RESPONSABILIDAD, YA SEA EN UNA ACCIÓN DE CONTRATO, AGRAVIO O DE OTRO TIPO, QUE SURJA DE O EN CONEXIÓN CON EL SOFTWARE O EL USO U OTROS TRATOS EN EL SOFTWARE.