agent-docs-kit 2.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.
- agent_docs_kit-2.1.0.dist-info/METADATA +299 -0
- agent_docs_kit-2.1.0.dist-info/RECORD +40 -0
- agent_docs_kit-2.1.0.dist-info/WHEEL +4 -0
- agent_docs_kit-2.1.0.dist-info/entry_points.txt +3 -0
- agent_docs_kit-2.1.0.dist-info/licenses/LICENSE +21 -0
- living_docs_cli/__init__.py +686 -0
- living_docs_cli/__main__.py +3 -0
- living_docs_cli/assets/fumadocs-starter/app/docs/[[...slug]]/page.tsx +44 -0
- living_docs_cli/assets/fumadocs-starter/app/docs/layout.tsx +12 -0
- living_docs_cli/assets/fumadocs-starter/app/global.css +450 -0
- living_docs_cli/assets/fumadocs-starter/app/layout.tsx +15 -0
- living_docs_cli/assets/fumadocs-starter/app/page.tsx +5 -0
- living_docs_cli/assets/fumadocs-starter/components/living-docs/index.tsx +185 -0
- living_docs_cli/assets/fumadocs-starter/components/mdx.tsx +27 -0
- living_docs_cli/assets/fumadocs-starter/content/docs/architecture.mdx +55 -0
- living_docs_cli/assets/fumadocs-starter/content/docs/components.mdx +132 -0
- living_docs_cli/assets/fumadocs-starter/content/docs/glossary.mdx +16 -0
- living_docs_cli/assets/fumadocs-starter/content/docs/index.mdx +72 -0
- living_docs_cli/assets/fumadocs-starter/content/docs/meta.json +4 -0
- living_docs_cli/assets/fumadocs-starter/lib/layout.shared.ts +7 -0
- living_docs_cli/assets/fumadocs-starter/lib/source.ts +7 -0
- living_docs_cli/assets/fumadocs-starter/next-env.d.ts +4 -0
- living_docs_cli/assets/fumadocs-starter/next.config.mjs +10 -0
- living_docs_cli/assets/fumadocs-starter/package.json +27 -0
- living_docs_cli/assets/fumadocs-starter/source.config.ts +34 -0
- living_docs_cli/assets/fumadocs-starter/tsconfig.json +23 -0
- living_docs_cli/assets/project/.living-docs/scripts/check.mjs +72 -0
- living_docs_cli/assets/project/.living-docs/scripts/create-doc.mjs +88 -0
- living_docs_cli/assets/project/.living-docs/scripts/glossary.mjs +107 -0
- living_docs_cli/assets/project/.living-docs/templates/architecture.mdx +51 -0
- living_docs_cli/assets/project/.living-docs/templates/change.mdx +40 -0
- living_docs_cli/assets/project/.living-docs/templates/glossary.mdx +15 -0
- living_docs_cli/assets/project/.living-docs/templates/plan.mdx +54 -0
- living_docs_cli/assets/styles/atlas.css +450 -0
- living_docs_cli/assets/workflow-skills/living-docs-architecture/SKILL.md +55 -0
- living_docs_cli/assets/workflow-skills/living-docs-change/SKILL.md +62 -0
- living_docs_cli/assets/workflow-skills/living-docs-check/SKILL.md +32 -0
- living_docs_cli/assets/workflow-skills/living-docs-glossary/SKILL.md +30 -0
- living_docs_cli/assets/workflow-skills/living-docs-plan/SKILL.md +55 -0
- living_docs_cli/assets/workflow-skills/living-docs-write/SKILL.md +46 -0
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import date
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
VERSION = "2.1.0"
|
|
14
|
+
PACKAGE_DIR = Path(__file__).resolve().parent
|
|
15
|
+
ASSETS_DIR = PACKAGE_DIR / "assets"
|
|
16
|
+
STYLE_NAMES = ("atlas",)
|
|
17
|
+
INTEGRATION_ORDER = ("codex", "claude", "copilot", "cursor", "gemini", "generic")
|
|
18
|
+
INTEGRATION_ALIASES = {
|
|
19
|
+
"claude-code": "claude",
|
|
20
|
+
"github": "copilot",
|
|
21
|
+
"github-copilot": "copilot",
|
|
22
|
+
"gemini-cli": "gemini",
|
|
23
|
+
}
|
|
24
|
+
TEXT_SUFFIXES = {
|
|
25
|
+
".css",
|
|
26
|
+
".json",
|
|
27
|
+
".js",
|
|
28
|
+
".jsx",
|
|
29
|
+
".md",
|
|
30
|
+
".mdx",
|
|
31
|
+
".mjs",
|
|
32
|
+
".ts",
|
|
33
|
+
".tsx",
|
|
34
|
+
".txt",
|
|
35
|
+
".yml",
|
|
36
|
+
".yaml",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Integration:
|
|
42
|
+
key: str
|
|
43
|
+
label: str
|
|
44
|
+
skills_dir: str
|
|
45
|
+
context_file: str
|
|
46
|
+
next_step: str
|
|
47
|
+
invocation_prefix: str | None = None
|
|
48
|
+
context_preamble: str = ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
INTEGRATIONS = {
|
|
52
|
+
"codex": Integration(
|
|
53
|
+
key="codex",
|
|
54
|
+
label="Codex",
|
|
55
|
+
skills_dir=".agents/skills",
|
|
56
|
+
context_file="AGENTS.md",
|
|
57
|
+
next_step="Start Codex in this project and invoke skills such as $living-docs-change.",
|
|
58
|
+
invocation_prefix="$",
|
|
59
|
+
),
|
|
60
|
+
"claude": Integration(
|
|
61
|
+
key="claude",
|
|
62
|
+
label="Claude Code",
|
|
63
|
+
skills_dir=".claude/skills",
|
|
64
|
+
context_file="CLAUDE.md",
|
|
65
|
+
next_step="Start Claude Code in this project and invoke skills such as /living-docs-change.",
|
|
66
|
+
invocation_prefix="/",
|
|
67
|
+
),
|
|
68
|
+
"copilot": Integration(
|
|
69
|
+
key="copilot",
|
|
70
|
+
label="GitHub Copilot",
|
|
71
|
+
skills_dir=".github/skills",
|
|
72
|
+
context_file=".github/copilot-instructions.md",
|
|
73
|
+
next_step="Use the installed GitHub Copilot skills under .github/skills.",
|
|
74
|
+
),
|
|
75
|
+
"generic": Integration(
|
|
76
|
+
key="generic",
|
|
77
|
+
label="Generic",
|
|
78
|
+
skills_dir=".living-docs/skills",
|
|
79
|
+
context_file=".living-docs/AGENT_CONTEXT.md",
|
|
80
|
+
next_step="Read .living-docs/skills for portable skill instructions.",
|
|
81
|
+
),
|
|
82
|
+
"cursor": Integration(
|
|
83
|
+
key="cursor",
|
|
84
|
+
label="Cursor",
|
|
85
|
+
skills_dir=".living-docs/skills",
|
|
86
|
+
context_file=".cursor/rules/living-docs.mdc",
|
|
87
|
+
next_step="Open the project in Cursor; the project rule points to .living-docs/skills.",
|
|
88
|
+
context_preamble=(
|
|
89
|
+
"---\n"
|
|
90
|
+
"description: Use living-docs workflows when creating or maintaining project documentation\n"
|
|
91
|
+
"alwaysApply: false\n"
|
|
92
|
+
"---\n\n"
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
"gemini": Integration(
|
|
96
|
+
key="gemini",
|
|
97
|
+
label="Gemini CLI",
|
|
98
|
+
skills_dir=".living-docs/skills",
|
|
99
|
+
context_file="GEMINI.md",
|
|
100
|
+
next_step="Start Gemini CLI in this project; GEMINI.md points to .living-docs/skills.",
|
|
101
|
+
),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
CONTEXT_START = "<!-- LIVING-DOCS START -->"
|
|
105
|
+
CONTEXT_END = "<!-- LIVING-DOCS END -->"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class CliError(RuntimeError):
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def project_name(path: Path) -> str:
|
|
113
|
+
return path.resolve().name or "Docs"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def render_text(content: str, variables: dict[str, str]) -> str:
|
|
117
|
+
for key, value in variables.items():
|
|
118
|
+
content = content.replace(f"__{key}__", value)
|
|
119
|
+
return content
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def is_text_file(path: Path) -> bool:
|
|
123
|
+
return path.suffix in TEXT_SUFFIXES or path.name in {".gitignore"}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def iter_files(root: Path) -> list[Path]:
|
|
127
|
+
return sorted(path for path in root.rglob("*") if path.is_file())
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def copy_asset_tree(
|
|
131
|
+
source: Path,
|
|
132
|
+
target: Path,
|
|
133
|
+
*,
|
|
134
|
+
variables: dict[str, str],
|
|
135
|
+
force: bool,
|
|
136
|
+
) -> list[Path]:
|
|
137
|
+
if not source.is_dir():
|
|
138
|
+
raise CliError(f"asset directory not found: {source}")
|
|
139
|
+
|
|
140
|
+
collisions: list[Path] = []
|
|
141
|
+
for src in iter_files(source):
|
|
142
|
+
rel = src.relative_to(source)
|
|
143
|
+
dst = target / rel
|
|
144
|
+
if dst.exists() and not force:
|
|
145
|
+
collisions.append(dst)
|
|
146
|
+
if collisions:
|
|
147
|
+
shown = "\n".join(f" - {p}" for p in collisions[:12])
|
|
148
|
+
extra = "" if len(collisions) <= 12 else f"\n ... {len(collisions) - 12} more"
|
|
149
|
+
raise CliError(
|
|
150
|
+
"refusing to overwrite existing files without --force:\n"
|
|
151
|
+
f"{shown}{extra}"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
written: list[Path] = []
|
|
155
|
+
for src in iter_files(source):
|
|
156
|
+
rel = src.relative_to(source)
|
|
157
|
+
dst = target / rel
|
|
158
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
if is_text_file(src):
|
|
160
|
+
dst.write_text(render_text(src.read_text(), variables))
|
|
161
|
+
else:
|
|
162
|
+
shutil.copy2(src, dst)
|
|
163
|
+
written.append(dst)
|
|
164
|
+
return written
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def write_json(path: Path, payload: dict, *, force: bool) -> None:
|
|
168
|
+
if path.exists() and not force:
|
|
169
|
+
raise CliError(f"refusing to overwrite existing file without --force: {path}")
|
|
170
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def parse_integration_selection(raw: str, *, default: list[str]) -> list[str]:
|
|
175
|
+
if not raw.strip():
|
|
176
|
+
return default
|
|
177
|
+
|
|
178
|
+
selected: list[str] = []
|
|
179
|
+
tokens = [token for token in re.split(r"[\s,]+", raw.strip().lower()) if token]
|
|
180
|
+
if any(token in {"all", "*"} for token in tokens):
|
|
181
|
+
return list(INTEGRATION_ORDER)
|
|
182
|
+
|
|
183
|
+
for token in tokens:
|
|
184
|
+
if token.isdigit():
|
|
185
|
+
index = int(token) - 1
|
|
186
|
+
if index < 0 or index >= len(INTEGRATION_ORDER):
|
|
187
|
+
raise CliError(f"unknown integration number: {token}")
|
|
188
|
+
key = INTEGRATION_ORDER[index]
|
|
189
|
+
else:
|
|
190
|
+
key = INTEGRATION_ALIASES.get(token, token)
|
|
191
|
+
if key not in INTEGRATIONS:
|
|
192
|
+
raise CliError(f"unknown integration: {token}")
|
|
193
|
+
|
|
194
|
+
if key not in selected:
|
|
195
|
+
selected.append(key)
|
|
196
|
+
|
|
197
|
+
return selected
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def prompt_text(label: str, default: str) -> str:
|
|
201
|
+
suffix = f" [{default}]" if default else ""
|
|
202
|
+
value = input(f"? {label}{suffix}: ").strip()
|
|
203
|
+
return value or default
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def prompt_bool(label: str, default: bool = False) -> bool:
|
|
207
|
+
default_label = "Y/n" if default else "y/N"
|
|
208
|
+
while True:
|
|
209
|
+
value = input(f"? {label} [{default_label}]: ").strip().lower()
|
|
210
|
+
if not value:
|
|
211
|
+
return default
|
|
212
|
+
if value in {"y", "yes"}:
|
|
213
|
+
return True
|
|
214
|
+
if value in {"n", "no"}:
|
|
215
|
+
return False
|
|
216
|
+
print(" Enter y or n.")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def prompt_integrations(default: list[str]) -> list[str]:
|
|
220
|
+
print("? Agent integrations")
|
|
221
|
+
for index, key in enumerate(INTEGRATION_ORDER, start=1):
|
|
222
|
+
integration = INTEGRATIONS[key]
|
|
223
|
+
marker = " (default)" if key in default else ""
|
|
224
|
+
print(f" {index}. {key} - {integration.label}{marker}")
|
|
225
|
+
print(" Enter names or numbers separated by commas, or 'all'.")
|
|
226
|
+
|
|
227
|
+
while True:
|
|
228
|
+
raw = input(f" Selection [{', '.join(default)}]: ")
|
|
229
|
+
try:
|
|
230
|
+
return parse_integration_selection(raw, default=default)
|
|
231
|
+
except CliError as exc:
|
|
232
|
+
print(f" {exc}")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def should_prompt_init(args: argparse.Namespace) -> bool:
|
|
236
|
+
if args.yes:
|
|
237
|
+
return False
|
|
238
|
+
if args.interactive:
|
|
239
|
+
return True
|
|
240
|
+
return (
|
|
241
|
+
sys.stdin.isatty()
|
|
242
|
+
and args.target is None
|
|
243
|
+
and args.integration is None
|
|
244
|
+
and args.docs_dir == "docs"
|
|
245
|
+
and args.style == "atlas"
|
|
246
|
+
and not args.force
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def collect_interactive_init_args(args: argparse.Namespace) -> None:
|
|
251
|
+
if not sys.stdin.isatty():
|
|
252
|
+
raise CliError("--interactive requires an interactive terminal")
|
|
253
|
+
|
|
254
|
+
print("living-docs init")
|
|
255
|
+
args.target = prompt_text("Target project", args.target or ".")
|
|
256
|
+
args.docs_dir = prompt_text("Docs directory", args.docs_dir)
|
|
257
|
+
args.integration = prompt_integrations(args.integration or ["codex"])
|
|
258
|
+
args.style = prompt_text("Style", args.style)
|
|
259
|
+
if args.style not in STYLE_NAMES:
|
|
260
|
+
raise CliError(
|
|
261
|
+
"unknown style: " + args.style + f". Available: {', '.join(STYLE_NAMES)}"
|
|
262
|
+
)
|
|
263
|
+
args.force = prompt_bool("Overwrite existing managed files if needed?", args.force)
|
|
264
|
+
print()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def skill_lines(integration: Integration) -> str:
|
|
268
|
+
names = [
|
|
269
|
+
("living-docs-write", "route general documentation requests"),
|
|
270
|
+
("living-docs-architecture", "document current architecture"),
|
|
271
|
+
("living-docs-change", "record shipped changes"),
|
|
272
|
+
("living-docs-plan", "draft future plans"),
|
|
273
|
+
("living-docs-glossary", "regenerate glossary"),
|
|
274
|
+
("living-docs-check", "validate documentation health"),
|
|
275
|
+
]
|
|
276
|
+
lines: list[str] = []
|
|
277
|
+
for name, purpose in names:
|
|
278
|
+
if integration.invocation_prefix:
|
|
279
|
+
lines.append(f"- `{integration.invocation_prefix}{name}` to {purpose}")
|
|
280
|
+
else:
|
|
281
|
+
lines.append(f"- `{integration.skills_dir}/{name}/SKILL.md` to {purpose}")
|
|
282
|
+
return "\n".join(lines)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def context_block(*, docs_dir: str, integration: Integration) -> str:
|
|
286
|
+
return f"""{CONTEXT_START}
|
|
287
|
+
This project uses living-docs for its documentation system.
|
|
288
|
+
|
|
289
|
+
Documentation system:
|
|
290
|
+
- Framework: Fumadocs + MDX
|
|
291
|
+
- Docs app: `{docs_dir}/`
|
|
292
|
+
- Content source: `{docs_dir}/content/docs/`
|
|
293
|
+
- Project config: `.living-docs/config.json`
|
|
294
|
+
- Managed scripts: `.living-docs/scripts/`
|
|
295
|
+
- Workflow skill files: `{integration.skills_dir}/living-docs-*/SKILL.md`
|
|
296
|
+
|
|
297
|
+
Use living-docs skills when the user asks to create or update project documentation:
|
|
298
|
+
{skill_lines(integration)}
|
|
299
|
+
|
|
300
|
+
Keep MDX as the source of truth. Do not hand-edit generated HTML output.
|
|
301
|
+
Run `node .living-docs/scripts/check.mjs` or `living-docs check` before treating docs work as complete.
|
|
302
|
+
{CONTEXT_END}
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def upsert_context_file(project_root: Path, rel_path: str, block: str, *, preamble: str = "") -> Path:
|
|
307
|
+
path = project_root / rel_path
|
|
308
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
309
|
+
if path.exists():
|
|
310
|
+
original = path.read_text()
|
|
311
|
+
else:
|
|
312
|
+
original = preamble
|
|
313
|
+
|
|
314
|
+
pattern = re.compile(
|
|
315
|
+
re.escape(CONTEXT_START) + r".*?" + re.escape(CONTEXT_END) + r"\n?",
|
|
316
|
+
re.S,
|
|
317
|
+
)
|
|
318
|
+
if pattern.search(original):
|
|
319
|
+
updated = pattern.sub(block, original)
|
|
320
|
+
else:
|
|
321
|
+
prefix = "" if not original.strip() else original.rstrip() + "\n\n"
|
|
322
|
+
updated = prefix + block
|
|
323
|
+
path.write_text(updated)
|
|
324
|
+
return path
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def init_project(args: argparse.Namespace) -> int:
|
|
328
|
+
if should_prompt_init(args):
|
|
329
|
+
collect_interactive_init_args(args)
|
|
330
|
+
|
|
331
|
+
if args.framework != "fumadocs":
|
|
332
|
+
raise CliError("only --framework fumadocs is currently supported")
|
|
333
|
+
|
|
334
|
+
target_arg = args.target or "."
|
|
335
|
+
target = Path(target_arg).expanduser()
|
|
336
|
+
if target_arg == ".":
|
|
337
|
+
target = Path.cwd()
|
|
338
|
+
target = target.resolve()
|
|
339
|
+
if target.exists() and not target.is_dir():
|
|
340
|
+
raise CliError(f"target exists but is not a directory: {target}")
|
|
341
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
342
|
+
|
|
343
|
+
integrations = args.integration or ["codex"]
|
|
344
|
+
unknown = [name for name in integrations if name not in INTEGRATIONS]
|
|
345
|
+
if unknown:
|
|
346
|
+
raise CliError(
|
|
347
|
+
"unknown integration(s): "
|
|
348
|
+
+ ", ".join(unknown)
|
|
349
|
+
+ f". Available: {', '.join(INTEGRATION_ORDER)}"
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
docs_dir = args.docs_dir.strip().strip("/")
|
|
353
|
+
if not docs_dir or docs_dir.startswith("..") or "/.." in docs_dir:
|
|
354
|
+
raise CliError("--docs-dir must be a project-relative directory")
|
|
355
|
+
|
|
356
|
+
variables = {
|
|
357
|
+
"PROJECT_NAME": project_name(target),
|
|
358
|
+
"DOCS_DIR": docs_dir,
|
|
359
|
+
"TODAY": date.today().isoformat(),
|
|
360
|
+
"LIVING_DOCS_VERSION": VERSION,
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
starter_src = ASSETS_DIR / "fumadocs-starter"
|
|
364
|
+
project_src = ASSETS_DIR / "project"
|
|
365
|
+
style_src = ASSETS_DIR / "styles" / f"{args.style}.css"
|
|
366
|
+
if not style_src.is_file():
|
|
367
|
+
raise CliError(f"style preset not found: {args.style}")
|
|
368
|
+
|
|
369
|
+
written: list[Path] = []
|
|
370
|
+
written += copy_asset_tree(
|
|
371
|
+
starter_src,
|
|
372
|
+
target / docs_dir,
|
|
373
|
+
variables=variables,
|
|
374
|
+
force=args.force,
|
|
375
|
+
)
|
|
376
|
+
written += copy_asset_tree(
|
|
377
|
+
project_src,
|
|
378
|
+
target,
|
|
379
|
+
variables=variables,
|
|
380
|
+
force=args.force,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
style_dst = target / docs_dir / "app" / "global.css"
|
|
384
|
+
style_dst.write_text(render_text(style_src.read_text(), variables))
|
|
385
|
+
written.append(style_dst)
|
|
386
|
+
|
|
387
|
+
config = {
|
|
388
|
+
"version": VERSION,
|
|
389
|
+
"framework": "fumadocs",
|
|
390
|
+
"style": args.style,
|
|
391
|
+
"docsRoot": docs_dir,
|
|
392
|
+
"contentDir": f"{docs_dir}/content/docs",
|
|
393
|
+
"defaultLanguage": "match-user",
|
|
394
|
+
"integrations": integrations,
|
|
395
|
+
"createdAt": date.today().isoformat(),
|
|
396
|
+
}
|
|
397
|
+
write_json(target / ".living-docs" / "config.json", config, force=args.force)
|
|
398
|
+
|
|
399
|
+
skill_src = ASSETS_DIR / "workflow-skills"
|
|
400
|
+
installed_skill_dirs: set[str] = set()
|
|
401
|
+
for integration_name in integrations:
|
|
402
|
+
integration = INTEGRATIONS[integration_name]
|
|
403
|
+
if integration.skills_dir not in installed_skill_dirs:
|
|
404
|
+
written += copy_asset_tree(
|
|
405
|
+
skill_src,
|
|
406
|
+
target / integration.skills_dir,
|
|
407
|
+
variables=variables,
|
|
408
|
+
force=args.force,
|
|
409
|
+
)
|
|
410
|
+
installed_skill_dirs.add(integration.skills_dir)
|
|
411
|
+
written.append(
|
|
412
|
+
upsert_context_file(
|
|
413
|
+
target,
|
|
414
|
+
integration.context_file,
|
|
415
|
+
context_block(docs_dir=docs_dir, integration=integration),
|
|
416
|
+
preamble=integration.context_preamble,
|
|
417
|
+
)
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
rel_written = [str(path.relative_to(target)) for path in written]
|
|
421
|
+
print(f"living-docs {VERSION}")
|
|
422
|
+
print(f"Initialized Fumadocs documentation system in {target}")
|
|
423
|
+
print(f"Docs app: {docs_dir}/")
|
|
424
|
+
print(f"Style preset: {args.style}")
|
|
425
|
+
print(f"Managed config: .living-docs/config.json")
|
|
426
|
+
print(f"Installed integrations: {', '.join(integrations)}")
|
|
427
|
+
print(f"Files written: {len(rel_written) + 1}")
|
|
428
|
+
print()
|
|
429
|
+
print("Next steps:")
|
|
430
|
+
print(f" 1. cd {target}")
|
|
431
|
+
print(f" 2. cd {docs_dir} && npm install && npm run dev -- --port 3333")
|
|
432
|
+
print(" 3. living-docs skills")
|
|
433
|
+
for integration_name in integrations:
|
|
434
|
+
print(f" - {INTEGRATIONS[integration_name].next_step}")
|
|
435
|
+
print(" - Run `living-docs check` or `node .living-docs/scripts/check.mjs` before committing docs changes.")
|
|
436
|
+
return 0
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def find_project_root(start: Path) -> Path:
|
|
440
|
+
current = start.resolve()
|
|
441
|
+
for candidate in [current, *current.parents]:
|
|
442
|
+
if (candidate / ".living-docs" / "config.json").is_file():
|
|
443
|
+
return candidate
|
|
444
|
+
return current
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def load_config(root: Path) -> dict:
|
|
448
|
+
path = root / ".living-docs" / "config.json"
|
|
449
|
+
if not path.is_file():
|
|
450
|
+
raise CliError("not a living-docs project: missing .living-docs/config.json")
|
|
451
|
+
return json.loads(path.read_text())
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def extract_frontmatter(text: str) -> dict[str, str]:
|
|
455
|
+
if not text.startswith("---\n"):
|
|
456
|
+
return {}
|
|
457
|
+
end = text.find("\n---", 4)
|
|
458
|
+
if end == -1:
|
|
459
|
+
return {}
|
|
460
|
+
data: dict[str, str] = {}
|
|
461
|
+
for line in text[4:end].splitlines():
|
|
462
|
+
if not line.strip() or line.startswith(" ") or line.startswith("-"):
|
|
463
|
+
continue
|
|
464
|
+
if ":" not in line:
|
|
465
|
+
continue
|
|
466
|
+
key, value = line.split(":", 1)
|
|
467
|
+
data[key.strip()] = value.strip().strip('"').strip("'")
|
|
468
|
+
return data
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def check_project(args: argparse.Namespace) -> int:
|
|
472
|
+
root = find_project_root(Path.cwd())
|
|
473
|
+
config = load_config(root)
|
|
474
|
+
content_dir = root / config.get("contentDir", "docs/content/docs")
|
|
475
|
+
docs_root = root / config.get("docsRoot", "docs")
|
|
476
|
+
errors: list[str] = []
|
|
477
|
+
|
|
478
|
+
if not docs_root.is_dir():
|
|
479
|
+
errors.append(f"docs root missing: {docs_root.relative_to(root)}")
|
|
480
|
+
if not content_dir.is_dir():
|
|
481
|
+
errors.append(f"content directory missing: {content_dir.relative_to(root)}")
|
|
482
|
+
|
|
483
|
+
mdx_files = sorted(content_dir.rglob("*.mdx")) if content_dir.is_dir() else []
|
|
484
|
+
if not mdx_files:
|
|
485
|
+
errors.append("no MDX files found")
|
|
486
|
+
|
|
487
|
+
for file in mdx_files:
|
|
488
|
+
rel = file.relative_to(root)
|
|
489
|
+
text = file.read_text()
|
|
490
|
+
frontmatter = extract_frontmatter(text)
|
|
491
|
+
if not frontmatter.get("title"):
|
|
492
|
+
errors.append(f"{rel}: missing frontmatter title")
|
|
493
|
+
|
|
494
|
+
doc_type = frontmatter.get("type")
|
|
495
|
+
if "/changes/" in rel.as_posix() and doc_type != "change":
|
|
496
|
+
errors.append(f"{rel}: changes pages must set type: change")
|
|
497
|
+
if "/plans/" in rel.as_posix() and doc_type != "plan":
|
|
498
|
+
errors.append(f"{rel}: plan pages must set type: plan")
|
|
499
|
+
if file.name == "architecture.mdx" and doc_type not in {"architecture", ""}:
|
|
500
|
+
errors.append(f"{rel}: architecture pages should set type: architecture")
|
|
501
|
+
|
|
502
|
+
if doc_type in {"architecture", "change"}:
|
|
503
|
+
has_component_diagram = any(
|
|
504
|
+
marker in text
|
|
505
|
+
for marker in ("<ArchMap", "<FlowSteps", "<StateFlow", "```mermaid")
|
|
506
|
+
)
|
|
507
|
+
if not has_component_diagram:
|
|
508
|
+
errors.append(
|
|
509
|
+
f"{rel}: architecture/change docs need <ArchMap />, <FlowSteps />, "
|
|
510
|
+
"<StateFlow />, or a mermaid block"
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
if doc_type in {"architecture", "change", "plan"}:
|
|
514
|
+
if "terms:" not in text and "<TermGrid" not in text:
|
|
515
|
+
errors.append(f"{rel}: managed docs should define terms or render <TermGrid />")
|
|
516
|
+
|
|
517
|
+
glossary = content_dir / "glossary.mdx"
|
|
518
|
+
if not glossary.is_file():
|
|
519
|
+
errors.append("missing generated glossary: run node .living-docs/scripts/glossary.mjs")
|
|
520
|
+
|
|
521
|
+
if errors:
|
|
522
|
+
print(f"living-docs check failed ({len(errors)} issue(s))")
|
|
523
|
+
for error in errors:
|
|
524
|
+
print(f" - {error}")
|
|
525
|
+
return 1
|
|
526
|
+
|
|
527
|
+
print(f"living-docs check passed ({len(mdx_files)} MDX file(s))")
|
|
528
|
+
return 0
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def version_command(_: argparse.Namespace) -> int:
|
|
532
|
+
print(f"living-docs {VERSION}")
|
|
533
|
+
return 0
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def skills_command(_: argparse.Namespace) -> int:
|
|
537
|
+
root = find_project_root(Path.cwd())
|
|
538
|
+
is_project = (root / ".living-docs" / "config.json").is_file()
|
|
539
|
+
config: dict = {}
|
|
540
|
+
if is_project:
|
|
541
|
+
config = load_config(root)
|
|
542
|
+
|
|
543
|
+
docs_root = config.get("docsRoot", "docs")
|
|
544
|
+
style = config.get("style", "atlas")
|
|
545
|
+
integrations = config.get("integrations", sorted(INTEGRATIONS))
|
|
546
|
+
|
|
547
|
+
print("living-docs skills")
|
|
548
|
+
print()
|
|
549
|
+
print("CLI:")
|
|
550
|
+
print(" living-docs init")
|
|
551
|
+
print(" living-docs init [target] --integration codex --integration claude")
|
|
552
|
+
print(" living-docs init . --integration codex --integration cursor --integration gemini")
|
|
553
|
+
print(" living-docs init . --docs-dir docs --style atlas --yes")
|
|
554
|
+
print(" living-docs init . --style atlas --interactive")
|
|
555
|
+
print(" living-docs check")
|
|
556
|
+
print(" living-docs skills")
|
|
557
|
+
print(" living-docs styles")
|
|
558
|
+
print(" living-docs version")
|
|
559
|
+
print(" living-docs self check")
|
|
560
|
+
print(f" current style: {style}")
|
|
561
|
+
print(" available integrations: " + ", ".join(sorted(INTEGRATIONS)))
|
|
562
|
+
print()
|
|
563
|
+
print("Docs app:")
|
|
564
|
+
print(f" cd {docs_root}")
|
|
565
|
+
print(" npm install")
|
|
566
|
+
print(" npm run dev -- --port 3333")
|
|
567
|
+
print(" npm run typecheck")
|
|
568
|
+
print(" npm run build")
|
|
569
|
+
print()
|
|
570
|
+
print("Project scripts:")
|
|
571
|
+
print(' node .living-docs/scripts/create-doc.mjs architecture <domain> <slug> "<Title>"')
|
|
572
|
+
print(' node .living-docs/scripts/create-doc.mjs change <domain> <slug> "<Title>"')
|
|
573
|
+
print(' node .living-docs/scripts/create-doc.mjs plan <domain> <slug> "<Title>"')
|
|
574
|
+
print(" node .living-docs/scripts/glossary.mjs")
|
|
575
|
+
print(" node .living-docs/scripts/check.mjs")
|
|
576
|
+
print()
|
|
577
|
+
print("Agent integrations:")
|
|
578
|
+
for integration_name in integrations:
|
|
579
|
+
integration = INTEGRATIONS.get(integration_name)
|
|
580
|
+
if not integration:
|
|
581
|
+
continue
|
|
582
|
+
print(f" {integration.label} ({integration.skills_dir}):")
|
|
583
|
+
for name in [
|
|
584
|
+
"living-docs-write",
|
|
585
|
+
"living-docs-architecture",
|
|
586
|
+
"living-docs-change",
|
|
587
|
+
"living-docs-plan",
|
|
588
|
+
"living-docs-glossary",
|
|
589
|
+
"living-docs-check",
|
|
590
|
+
]:
|
|
591
|
+
if integration.invocation_prefix:
|
|
592
|
+
print(f" {integration.invocation_prefix}{name}")
|
|
593
|
+
else:
|
|
594
|
+
print(f" {integration.skills_dir}/{name}/SKILL.md")
|
|
595
|
+
return 0
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def styles_command(_: argparse.Namespace) -> int:
|
|
599
|
+
print("living-docs styles")
|
|
600
|
+
print()
|
|
601
|
+
print(" atlas product documentation with clear hierarchy")
|
|
602
|
+
print()
|
|
603
|
+
print("Use:")
|
|
604
|
+
print(" living-docs init")
|
|
605
|
+
print(" living-docs init . --style atlas --interactive")
|
|
606
|
+
print(" living-docs init . --style atlas --yes")
|
|
607
|
+
return 0
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def self_check(_: argparse.Namespace) -> int:
|
|
611
|
+
print(f"living-docs {VERSION}")
|
|
612
|
+
print(f"assets: {ASSETS_DIR}")
|
|
613
|
+
missing = [
|
|
614
|
+
path
|
|
615
|
+
for path in [
|
|
616
|
+
ASSETS_DIR / "fumadocs-starter",
|
|
617
|
+
ASSETS_DIR / "project",
|
|
618
|
+
ASSETS_DIR / "styles",
|
|
619
|
+
ASSETS_DIR / "workflow-skills",
|
|
620
|
+
]
|
|
621
|
+
if not path.exists()
|
|
622
|
+
]
|
|
623
|
+
if missing:
|
|
624
|
+
print("missing bundled assets:")
|
|
625
|
+
for path in missing:
|
|
626
|
+
print(f" - {path}")
|
|
627
|
+
return 1
|
|
628
|
+
print("self check passed")
|
|
629
|
+
return 0
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
633
|
+
parser = argparse.ArgumentParser(
|
|
634
|
+
prog="living-docs",
|
|
635
|
+
description="Bootstrap and maintain a Fumadocs MDX documentation system.",
|
|
636
|
+
)
|
|
637
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
638
|
+
|
|
639
|
+
init = sub.add_parser("init", help="initialize a Fumadocs documentation system")
|
|
640
|
+
init.add_argument("target", nargs="?", default=None, help="project directory or '.'")
|
|
641
|
+
init.add_argument("--framework", default="fumadocs", choices=["fumadocs"])
|
|
642
|
+
init.add_argument(
|
|
643
|
+
"--integration",
|
|
644
|
+
action="append",
|
|
645
|
+
choices=sorted(INTEGRATIONS),
|
|
646
|
+
help="agent integration to install; repeat for multiple integrations",
|
|
647
|
+
)
|
|
648
|
+
init.add_argument("--docs-dir", default="docs", help="project-relative docs app directory")
|
|
649
|
+
init.add_argument("--style", default="atlas", choices=STYLE_NAMES, help="visual style preset")
|
|
650
|
+
init.add_argument("--force", action="store_true", help="overwrite managed files")
|
|
651
|
+
init.add_argument("--interactive", action="store_true", help="prompt for init options")
|
|
652
|
+
init.add_argument("-y", "--yes", action="store_true", help="accept defaults and skip prompts")
|
|
653
|
+
init.set_defaults(func=init_project)
|
|
654
|
+
|
|
655
|
+
check = sub.add_parser("check", help="validate a living-docs project")
|
|
656
|
+
check.set_defaults(func=check_project)
|
|
657
|
+
|
|
658
|
+
skills = sub.add_parser("skills", help="print installed skill names and supporting CLI/script actions")
|
|
659
|
+
skills.set_defaults(func=skills_command)
|
|
660
|
+
|
|
661
|
+
styles = sub.add_parser("styles", help="list visual style presets")
|
|
662
|
+
styles.set_defaults(func=styles_command)
|
|
663
|
+
|
|
664
|
+
version = sub.add_parser("version", help="print version")
|
|
665
|
+
version.set_defaults(func=version_command)
|
|
666
|
+
|
|
667
|
+
self_parser = sub.add_parser("self", help="manage the living-docs CLI")
|
|
668
|
+
self_sub = self_parser.add_subparsers(dest="self_command", required=True)
|
|
669
|
+
self_check_parser = self_sub.add_parser("check", help="validate bundled CLI assets")
|
|
670
|
+
self_check_parser.set_defaults(func=self_check)
|
|
671
|
+
|
|
672
|
+
return parser
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def main(argv: list[str] | None = None) -> int:
|
|
676
|
+
parser = build_parser()
|
|
677
|
+
args = parser.parse_args(argv)
|
|
678
|
+
try:
|
|
679
|
+
return args.func(args)
|
|
680
|
+
except CliError as exc:
|
|
681
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
682
|
+
return 1
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
if __name__ == "__main__":
|
|
686
|
+
raise SystemExit(main())
|