rekipedia 0.9.3__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.
Files changed (55) hide show
  1. rekipedia/__init__.py +2 -0
  2. rekipedia/__main__.py +3 -0
  3. rekipedia/cli/__init__.py +27 -0
  4. rekipedia/cli/ask.py +163 -0
  5. rekipedia/cli/embed.py +178 -0
  6. rekipedia/cli/export.py +200 -0
  7. rekipedia/cli/init.py +83 -0
  8. rekipedia/cli/scan.py +126 -0
  9. rekipedia/cli/serve.py +72 -0
  10. rekipedia/cli/update.py +102 -0
  11. rekipedia/exporters/__init__.py +1 -0
  12. rekipedia/exporters/json_export.py +98 -0
  13. rekipedia/exporters/markdown_export.py +41 -0
  14. rekipedia/extractors/__init__.py +8 -0
  15. rekipedia/extractors/base.py +22 -0
  16. rekipedia/extractors/config_extractor.py +147 -0
  17. rekipedia/extractors/python_extractor.py +141 -0
  18. rekipedia/extractors/typescript_extractor.py +113 -0
  19. rekipedia/llm/__init__.py +1 -0
  20. rekipedia/llm/client.py +116 -0
  21. rekipedia/models/__init__.py +1 -0
  22. rekipedia/models/contracts.py +90 -0
  23. rekipedia/orchestrator/__init__.py +1 -0
  24. rekipedia/orchestrator/run_ask.py +208 -0
  25. rekipedia/orchestrator/run_digest.py +339 -0
  26. rekipedia/orchestrator/run_update.py +187 -0
  27. rekipedia/orchestrator/sharding.py +77 -0
  28. rekipedia/orchestrator/snapshotter.py +108 -0
  29. rekipedia/prompts/ask_system.md +17 -0
  30. rekipedia/prompts/digest_system.md +70 -0
  31. rekipedia/rag/__init__.py +0 -0
  32. rekipedia/rag/embedder.py +399 -0
  33. rekipedia/rag/scan_meta.py +61 -0
  34. rekipedia/sandbox/__init__.py +1 -0
  35. rekipedia/sandbox/runner.py +143 -0
  36. rekipedia/sandbox/tasks/__init__.py +1 -0
  37. rekipedia/sandbox/tasks/analyze_shard.py +75 -0
  38. rekipedia/server/__init__.py +0 -0
  39. rekipedia/server/app.py +236 -0
  40. rekipedia/server/templates/ask.html +392 -0
  41. rekipedia/server/templates/base.html +153 -0
  42. rekipedia/server/templates/index.html +137 -0
  43. rekipedia/server/templates/wiki.html +4 -0
  44. rekipedia/storage/__init__.py +1 -0
  45. rekipedia/storage/migrations/001_initial.sql +156 -0
  46. rekipedia/storage/migrations/002_qa_history.sql +8 -0
  47. rekipedia/storage/sqlite_store.py +481 -0
  48. rekipedia/synthesis/__init__.py +1 -0
  49. rekipedia/synthesis/diagram_builder.py +161 -0
  50. rekipedia/synthesis/page_builder.py +404 -0
  51. rekipedia/synthesis/planner.py +427 -0
  52. rekipedia-0.9.3.dist-info/METADATA +351 -0
  53. rekipedia-0.9.3.dist-info/RECORD +55 -0
  54. rekipedia-0.9.3.dist-info/WHEEL +4 -0
  55. rekipedia-0.9.3.dist-info/entry_points.txt +3 -0
rekipedia/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """rekipedia — agentic repo-to-wiki knowledge store."""
2
+ __version__ = "0.1.0"
rekipedia/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from rekipedia.cli import main
2
+
3
+ main()
@@ -0,0 +1,27 @@
1
+ """rekipedia CLI entry point."""
2
+ from __future__ import annotations
3
+
4
+ import click
5
+
6
+ from rekipedia.cli.ask import ask_cmd
7
+ from rekipedia.cli.embed import embed_cmd
8
+ from rekipedia.cli.export import export_cmd
9
+ from rekipedia.cli.init import init_cmd
10
+ from rekipedia.cli.scan import scan_cmd
11
+ from rekipedia.cli.serve import serve_cmd
12
+ from rekipedia.cli.update import update_cmd
13
+
14
+
15
+ @click.group()
16
+ @click.version_option(package_name="rekipedia")
17
+ def main() -> None:
18
+ """rekipedia — agentic repo-to-wiki knowledge store."""
19
+
20
+
21
+ main.add_command(init_cmd)
22
+ main.add_command(scan_cmd)
23
+ main.add_command(update_cmd)
24
+ main.add_command(ask_cmd)
25
+ main.add_command(embed_cmd)
26
+ main.add_command(export_cmd)
27
+ main.add_command(serve_cmd)
rekipedia/cli/ask.py ADDED
@@ -0,0 +1,163 @@
1
+ """`rekipedia ask` — interactive grounded Q&A REPL."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import sys
6
+ import threading
7
+ from pathlib import Path
8
+
9
+ import click
10
+ import pyfiglet
11
+ import yaml
12
+ from rich.console import Console
13
+ from rich.live import Live
14
+ from rich.panel import Panel
15
+ from rich.rule import Rule
16
+ from rich.spinner import Spinner
17
+ from rich.text import Text
18
+
19
+ from rekipedia.models.contracts import LLMConfig
20
+
21
+ console = Console()
22
+
23
+
24
+ def _print_banner() -> None:
25
+ """Print the REKIPEDIA ASCII art banner (two-line ansi_shadow layout)."""
26
+ try:
27
+ line1 = pyfiglet.figlet_format("REKI", font="ansi_shadow").rstrip("\n")
28
+ line2 = pyfiglet.figlet_format("PEDIA", font="ansi_shadow").rstrip("\n")
29
+ except pyfiglet.FontNotFound:
30
+ line1 = pyfiglet.figlet_format("REKI", font="standard").rstrip("\n")
31
+ line2 = pyfiglet.figlet_format("PEDIA", font="standard").rstrip("\n")
32
+ console.print(Text(line1, style="bold cyan"))
33
+ console.print(Text(line2, style="bold bright_cyan"))
34
+ console.print(" 📖 [bold cyan]Repository → Wiki[/bold cyan] · [dim]powered by LLM[/dim]\n")
35
+
36
+
37
+ def _load_config(repo: Path) -> dict:
38
+ cfg_path = repo / ".rekipedia" / "config.yml"
39
+ if cfg_path.exists():
40
+ return yaml.safe_load(cfg_path.read_text()) or {}
41
+ return {}
42
+
43
+
44
+ def _build_llm_config(repo: Path, model: str | None) -> LLMConfig:
45
+ cfg = _load_config(repo)
46
+ llm_cfg_raw = cfg.get("llm", {})
47
+ return LLMConfig(
48
+ model=os.environ.get("REKIPEDIA_MODEL") or model or llm_cfg_raw.get("model", "ollama/llama4"),
49
+ api_key=os.environ.get("REKIPEDIA_API_KEY") or llm_cfg_raw.get("api_key", ""),
50
+ base_url=os.environ.get("REKIPEDIA_BASE_URL") or llm_cfg_raw.get("base_url", ""),
51
+ temperature=llm_cfg_raw.get("temperature", 0.2),
52
+ )
53
+
54
+
55
+ def _answer_streaming(question: str, repo: Path, output_dir: Path, llm_config: LLMConfig) -> None:
56
+ """Run one Q&A turn: spinner while waiting, then stream tokens."""
57
+ from rekipedia.orchestrator.run_ask import stream_ask # noqa: PLC0415
58
+
59
+ # Print question header
60
+ console.print(Rule(style="dim"))
61
+ console.print(f"[bold bright_yellow]❯[/bold bright_yellow] {question}\n")
62
+
63
+ # Phase 1: spinner until first token
64
+ chunks_iter = None
65
+
66
+ try:
67
+ chunks_iter = stream_ask(
68
+ question=question,
69
+ repo_root=repo,
70
+ output_dir=output_dir,
71
+ llm_config=llm_config,
72
+ )
73
+ except (RuntimeError, Exception) as exc:
74
+ console.print(f"[bold red]Error:[/bold red] {exc}")
75
+ return
76
+
77
+ # Show spinner while waiting for first chunk
78
+ spinner_text = Spinner("dots", text=Text(" Searching wiki & reasoning…", style="dim"))
79
+ with Live(spinner_text, console=console, refresh_per_second=12, transient=True):
80
+ try:
81
+ first_chunk = next(chunks_iter) # type: ignore[arg-type]
82
+ except StopIteration:
83
+ first_chunk = ""
84
+ except Exception as exc:
85
+ console.print(f"[bold red]LLM error:[/bold red] {exc}")
86
+ return
87
+
88
+ # Phase 2: stream remaining tokens to stdout
89
+ console.print(Rule("[bold bright_green]◆ Answer[/bold bright_green]", style="bright_green"))
90
+ sys.stdout.write(first_chunk)
91
+ sys.stdout.flush()
92
+ try:
93
+ for chunk in chunks_iter: # type: ignore[union-attr]
94
+ sys.stdout.write(chunk)
95
+ sys.stdout.flush()
96
+ except Exception as exc:
97
+ console.print(f"\n[bold red]Stream error:[/bold red] {exc}")
98
+ finally:
99
+ sys.stdout.write("\n")
100
+ sys.stdout.flush()
101
+
102
+ console.rule(style="dim")
103
+
104
+
105
+ @click.command("ask")
106
+ @click.option("--question", "-q", default=None, help="Single question (non-interactive mode).")
107
+ @click.option(
108
+ "--repo",
109
+ default=".",
110
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
111
+ help="Repository root (default: current directory).",
112
+ )
113
+ @click.option("--model", default=None, envvar="REKIPEDIA_MODEL", help="LLM model override.")
114
+ @click.option("--output-dir", default=None, type=click.Path(path_type=Path), help="Output directory.")
115
+ def ask_cmd(question: str | None, repo: Path, model: str | None, output_dir: Path | None) -> None:
116
+ """Interactive grounded Q&A about the scanned repository.
117
+
118
+ Starts a REPL loop — ask questions until you press Ctrl+C.
119
+ Answers are streamed in real-time from the LLM.
120
+
121
+ \b
122
+ Examples:
123
+ rekipedia ask
124
+ rekipedia ask --repo ./my-project
125
+ rekipedia ask -q "What are the entry points?" # single-shot
126
+ """
127
+ repo = repo.resolve()
128
+ output_dir = (output_dir or repo / ".rekipedia").resolve()
129
+ llm_config = _build_llm_config(repo, model)
130
+
131
+ if question:
132
+ # Single-shot mode
133
+ _print_banner()
134
+ _answer_streaming(question, repo, output_dir, llm_config)
135
+ return
136
+
137
+ # Interactive REPL
138
+ _print_banner()
139
+
140
+ wiki_dir = output_dir / "wiki"
141
+ panel_content = (
142
+ f"[bold]Model[/bold] [cyan]{llm_config.model}[/cyan]\n"
143
+ f"[bold]Repo[/bold] [cyan]{repo}[/cyan]\n"
144
+ f"[bold]Wiki[/bold] [cyan]{wiki_dir}/[/cyan]\n\n"
145
+ "[dim]Ask anything about the codebase. Type 'exit' or Ctrl+C to quit.[/dim]"
146
+ )
147
+ console.print(Panel(panel_content, title=" rekipedia ask ", border_style="cyan"))
148
+ console.print()
149
+
150
+ while True:
151
+ try:
152
+ user_input = console.input("\n[bold bright_yellow]❯ [/bold bright_yellow]").strip()
153
+ except (KeyboardInterrupt, EOFError):
154
+ console.print("\n[dim]── session ended ──[/dim]")
155
+ break
156
+
157
+ if not user_input:
158
+ continue
159
+ if user_input.lower() in ("exit", "quit", "q"):
160
+ console.print("\n[dim]── session ended ──[/dim]")
161
+ break
162
+
163
+ _answer_streaming(user_input, repo, output_dir, llm_config)
rekipedia/cli/embed.py ADDED
@@ -0,0 +1,178 @@
1
+ """rekipedia embed — build / refresh the RAG FAISS index for a scanned repo.
2
+
3
+ Usage:
4
+ rekipedia embed [REPO_PATH] [--output-dir DIR] [--model MODEL] [--top-k N]
5
+
6
+ Embeds all source files in REPO_PATH into a FAISS index stored under
7
+ .rekipedia/rag/. Requires a prior `rekipedia scan`.
8
+ Set REKIPEDIA_EMBED_MODEL to override the embedding model (default:
9
+ text-embedding-3-small).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ import click
18
+ from rich.console import Console
19
+
20
+ console = Console()
21
+
22
+ def _check_rag_deps() -> None:
23
+ """Raise a friendly error if faiss-cpu / numpy are not installed."""
24
+ missing = []
25
+ try:
26
+ import faiss # noqa: F401
27
+ except ImportError:
28
+ missing.append("faiss-cpu")
29
+ try:
30
+ import numpy # noqa: F401
31
+ except ImportError:
32
+ missing.append("numpy")
33
+ if missing:
34
+ console.print(
35
+ f"[bold red]Missing RAG dependencies:[/] {', '.join(missing)}\n"
36
+ "Install them with:\n\n"
37
+ " [bold]pip install rekipedia[rag][/]\n"
38
+ " or: uv add 'rekipedia[rag]'\n",
39
+ highlight=False,
40
+ )
41
+ raise SystemExit(1)
42
+
43
+
44
+ @click.command("embed")
45
+ @click.argument("repo_path", default=".", type=click.Path(exists=True, file_okay=False))
46
+ @click.option(
47
+ "--output-dir",
48
+ default=None,
49
+ help="Path to .rekipedia/ directory (default: REPO_PATH/.rekipedia/)",
50
+ )
51
+ @click.option(
52
+ "--model",
53
+ default=None,
54
+ envvar="REKIPEDIA_EMBED_MODEL",
55
+ help="Embedding model name (e.g. text-embedding-3-small). "
56
+ "Can also be set via REKIPEDIA_EMBED_MODEL env var.",
57
+ )
58
+ @click.option(
59
+ "--provider",
60
+ default=None,
61
+ envvar="REKIPEDIA_EMBED_PROVIDER",
62
+ help="Embedding provider (e.g. openai, ollama, azure). "
63
+ "Combined with --model as 'provider/model' for litellm routing.",
64
+ )
65
+ @click.option(
66
+ "--api-key",
67
+ default=None,
68
+ envvar="OPENAI_API_KEY",
69
+ help="API key for the embedding provider.",
70
+ )
71
+ @click.option(
72
+ "--base-url",
73
+ default=None,
74
+ envvar="REKIPEDIA_BASE_URL",
75
+ help="Custom base URL for the embedding provider.",
76
+ )
77
+ @click.option(
78
+ "--top-k",
79
+ default=8,
80
+ show_default=True,
81
+ help="Number of chunks returned per query (informational only; stored in meta).",
82
+ )
83
+ @click.option("--verbose", "-v", is_flag=True, help="Show debug output.")
84
+ def embed_cmd(
85
+ repo_path: str,
86
+ output_dir: str | None,
87
+ model: str | None,
88
+ provider: str | None,
89
+ api_key: str | None,
90
+ base_url: str | None,
91
+ top_k: int,
92
+ verbose: bool,
93
+ ) -> None:
94
+ """Build or refresh the RAG embed index for REPO_PATH."""
95
+ _check_rag_deps()
96
+ import logging # noqa: PLC0415
97
+
98
+ if verbose:
99
+ logging.basicConfig(level=logging.DEBUG)
100
+ else:
101
+ logging.basicConfig(level=logging.WARNING)
102
+
103
+ repo = Path(repo_path).resolve()
104
+ out = Path(output_dir).resolve() if output_dir else repo / ".rekipedia"
105
+
106
+ if not out.exists():
107
+ console.print(
108
+ f"[red]No .rekipedia directory found at {out}.[/red]\n"
109
+ "Run [bold]rekipedia scan[/bold] first."
110
+ )
111
+ sys.exit(1)
112
+
113
+ # Load config.yml first, then CLI flags / env vars override
114
+ from rekipedia.cli.scan import _load_config # noqa: PLC0415
115
+ from rekipedia.models.contracts import LLMConfig # noqa: PLC0415
116
+ from rekipedia.rag.embedder import EmbedPipeline # noqa: PLC0415
117
+ from rekipedia.rag.scan_meta import patch_scan_meta # noqa: PLC0415
118
+
119
+ cfg = _load_config(repo)
120
+ llm_cfg = cfg.get("llm", {})
121
+
122
+ embed_model = model or llm_cfg.get("embed_model") or "text-embedding-3-small"
123
+ embed_provider = provider or llm_cfg.get("embed_provider", "")
124
+ resolved_api_key = api_key or llm_cfg.get("embed_api_key") or llm_cfg.get("api_key", "")
125
+ resolved_base_url = base_url or llm_cfg.get("embed_base_url") or llm_cfg.get("base_url", "")
126
+
127
+ console.print(f"[bold cyan]rekipedia embed[/bold cyan]")
128
+ console.print(f" repo : {repo}")
129
+ console.print(f" output-dir : {out}")
130
+ if embed_provider:
131
+ console.print(f" model : {embed_provider}/{embed_model}")
132
+ else:
133
+ console.print(f" model : {embed_model}")
134
+ console.print(f" base_url : {resolved_base_url or '(default: api.openai.com)'}")
135
+ console.print(f" api_key : {'(set)' if resolved_api_key else '(not set)'}")
136
+ console.print()
137
+
138
+ llm_config = LLMConfig(
139
+ api_key=resolved_api_key,
140
+ base_url=resolved_base_url,
141
+ embed_model=embed_model,
142
+ embed_provider=embed_provider,
143
+ embed_api_key=resolved_api_key,
144
+ embed_base_url=resolved_base_url,
145
+ )
146
+
147
+ pipe = EmbedPipeline(out, llm_config)
148
+
149
+ from tqdm import tqdm # noqa: PLC0415
150
+
151
+ bar = tqdm(bar_format=" {desc}", dynamic_ncols=True, leave=False)
152
+
153
+ def _progress(msg: str) -> None:
154
+ bar.set_description_str(msg)
155
+ bar.refresh()
156
+
157
+ try:
158
+ n = pipe.build(repo, progress_cb=_progress)
159
+ bar.set_description_str(f"✅ Done — {n} chunks indexed")
160
+ bar.refresh()
161
+ bar.close()
162
+
163
+ patch_scan_meta(out, embedded=True, embed_model=embed_model)
164
+ meta = pipe.meta()
165
+
166
+ console.print(f"\n[green]✅ FAISS index built successfully[/green]")
167
+ if meta:
168
+ console.print(f" chunks : {meta.get('n_chunks', n)}")
169
+ console.print(f" dim : {meta.get('dim', '?')}")
170
+ console.print(f" model : {meta.get('model', embed_model)}")
171
+ console.print(f"\nIndex saved to [bold]{out}/rag/[/bold]")
172
+ except Exception as exc:
173
+ bar.close()
174
+ console.print(f"\n[red]Embed failed:[/red] {exc}")
175
+ if verbose:
176
+ import traceback # noqa: PLC0415
177
+ traceback.print_exc()
178
+ sys.exit(1)
@@ -0,0 +1,200 @@
1
+ """rekipedia export — bundle wiki pages into a portable file.
2
+
3
+ Usage:
4
+ rekipedia export [REPO_PATH] [--format md|zip|json] [--output PATH]
5
+
6
+ Formats:
7
+ md (default) — single combined Markdown file (nav_order preserved)
8
+ zip — zip of wiki/*.md + diagrams/*.md + exports/manifest.json
9
+ json — manifest.json (already exists; this re-exports with latest metadata)
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import sys
15
+ import zipfile
16
+ from pathlib import Path
17
+
18
+ import click
19
+ from rich.console import Console
20
+
21
+ console = Console()
22
+
23
+ _FORMAT_CHOICES = click.Choice(["md", "zip", "json"], case_sensitive=False)
24
+
25
+
26
+ @click.command("export")
27
+ @click.argument("repo_path", default=".", type=click.Path(exists=True, file_okay=False))
28
+ @click.option(
29
+ "--output-dir",
30
+ default=None,
31
+ help="Path to .rekipedia/ directory (default: REPO_PATH/.rekipedia/)",
32
+ )
33
+ @click.option(
34
+ "--format", "fmt",
35
+ default="md",
36
+ type=_FORMAT_CHOICES,
37
+ show_default=True,
38
+ help="Output format: md (single file), zip (bundle), json (manifest only).",
39
+ )
40
+ @click.option(
41
+ "--output", "-o",
42
+ default=None,
43
+ help="Destination file path. Defaults to .rekipedia/export.<ext>.",
44
+ )
45
+ @click.option(
46
+ "--title",
47
+ default=None,
48
+ help="Title for the combined Markdown document (default: repo directory name).",
49
+ )
50
+ def export_cmd(
51
+ repo_path: str,
52
+ output_dir: str | None,
53
+ fmt: str,
54
+ output: str | None,
55
+ title: str | None,
56
+ ) -> None:
57
+ """Export the wiki to a portable file.
58
+
59
+ \b
60
+ Examples:
61
+ rekipedia export .
62
+ rekipedia export . --format zip -o wiki.zip
63
+ rekipedia export . --format md -o WIKI.md
64
+ """
65
+ repo = Path(repo_path).resolve()
66
+ out_dir = Path(output_dir).resolve() if output_dir else repo / ".rekipedia"
67
+
68
+ wiki_dir = out_dir / "wiki"
69
+ diagrams_dir = out_dir / "diagrams"
70
+ manifest_path = out_dir / "exports" / "manifest.json"
71
+
72
+ if not wiki_dir.exists():
73
+ console.print(
74
+ f"[red]No wiki/ directory found at {wiki_dir}.[/red]\n"
75
+ "Run [bold]rekipedia scan[/bold] first."
76
+ )
77
+ sys.exit(1)
78
+
79
+ # Load manifest for nav_order + page metadata
80
+ nav_order: list[str] = []
81
+ pages_meta: dict[str, dict] = {}
82
+ if manifest_path.exists():
83
+ try:
84
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
85
+ nav_order = manifest.get("nav_order", [])
86
+ for p in manifest.get("pages", []):
87
+ pages_meta[p["slug"]] = p
88
+ except Exception:
89
+ pass
90
+
91
+ # Collect all wiki pages in nav order
92
+ all_pages = sorted(wiki_dir.glob("*.md"))
93
+ if nav_order:
94
+ # Sort by nav_order, append any not in nav_order alphabetically
95
+ ordered = []
96
+ slug_map = {p.stem: p for p in all_pages}
97
+ for slug in nav_order:
98
+ if slug in slug_map:
99
+ ordered.append(slug_map[slug])
100
+ remaining = [p for p in all_pages if p.stem not in set(nav_order)]
101
+ all_pages = ordered + remaining
102
+
103
+ doc_title = title or repo.name
104
+ fmt = fmt.lower()
105
+ ext = {"md": "md", "zip": "zip", "json": "json"}[fmt]
106
+
107
+ dest = Path(output) if output else out_dir / f"export.{ext}"
108
+ dest.parent.mkdir(parents=True, exist_ok=True)
109
+
110
+ if fmt == "md":
111
+ _export_md(all_pages, dest, doc_title, pages_meta)
112
+ elif fmt == "zip":
113
+ _export_zip(all_pages, diagrams_dir, manifest_path, dest, doc_title)
114
+ elif fmt == "json":
115
+ if not manifest_path.exists():
116
+ console.print("[red]No manifest.json found. Run rekipedia scan first.[/red]")
117
+ sys.exit(1)
118
+ import shutil # noqa: PLC0415
119
+ shutil.copy2(manifest_path, dest)
120
+
121
+ size_kb = dest.stat().st_size / 1024
122
+ console.print(f"[green]✅ Exported {fmt.upper()}[/green] → [bold]{dest}[/bold] ({size_kb:.1f} KB)")
123
+ if fmt == "md":
124
+ console.print(f" pages : {len(all_pages)}")
125
+ elif fmt == "zip":
126
+ with zipfile.ZipFile(dest) as z:
127
+ console.print(f" files : {len(z.namelist())}")
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Format implementations
132
+ # ---------------------------------------------------------------------------
133
+
134
+ def _export_md(
135
+ pages: list[Path],
136
+ dest: Path,
137
+ title: str,
138
+ pages_meta: dict[str, dict],
139
+ ) -> None:
140
+ """Write a single combined Markdown file with a TOC."""
141
+ lines: list[str] = [f"# {title}\n", "\n## Table of Contents\n"]
142
+
143
+ # TOC
144
+ for p in pages:
145
+ slug = p.stem
146
+ meta = pages_meta.get(slug, {})
147
+ display_title = meta.get("title", slug.replace("-", " ").title())
148
+ importance = meta.get("importance", "")
149
+ imp_badge = f" *(importance: {importance})*" if importance else ""
150
+ anchor = slug.replace(" ", "-").lower()
151
+ lines.append(f"- [{display_title}](#{anchor}){imp_badge}\n")
152
+
153
+ lines.append("\n---\n\n")
154
+
155
+ # Pages
156
+ for p in pages:
157
+ slug = p.stem
158
+ meta = pages_meta.get(slug, {})
159
+ display_title = meta.get("title", slug.replace("-", " ").title())
160
+ content = p.read_text(encoding="utf-8").strip()
161
+
162
+ # Add section header with anchor
163
+ lines.append(f'<a id="{slug}"></a>\n\n')
164
+ lines.append(f"## {display_title}\n\n")
165
+
166
+ # Importance + tags badge line
167
+ badges: list[str] = []
168
+ if "importance" in meta:
169
+ badges.append(f"importance: **{meta['importance']}**")
170
+ if "section" in meta:
171
+ badges.append(f"section: `{meta['section']}`")
172
+ if "tags" in meta and meta["tags"]:
173
+ badges.append("tags: " + ", ".join(f"`{t}`" for t in meta["tags"]))
174
+ if badges:
175
+ lines.append("> " + " · ".join(badges) + "\n\n")
176
+
177
+ lines.append(content + "\n\n---\n\n")
178
+
179
+ dest.write_text("".join(lines), encoding="utf-8")
180
+
181
+
182
+ def _export_zip(
183
+ pages: list[Path],
184
+ diagrams_dir: Path,
185
+ manifest_path: Path,
186
+ dest: Path,
187
+ title: str,
188
+ ) -> None:
189
+ """Bundle wiki pages + diagrams + manifest into a zip file."""
190
+ with zipfile.ZipFile(dest, "w", compression=zipfile.ZIP_DEFLATED) as zf:
191
+ for p in pages:
192
+ zf.write(p, arcname=f"wiki/{p.name}")
193
+ if diagrams_dir.exists():
194
+ for d in sorted(diagrams_dir.glob("*.md")):
195
+ zf.write(d, arcname=f"diagrams/{d.name}")
196
+ if manifest_path.exists():
197
+ zf.write(manifest_path, arcname="manifest.json")
198
+ # Include a README pointing to the index page
199
+ readme = f"# {title}\n\nGenerated by rekipedia.\n\nOpen `wiki/index.md` to start reading.\n"
200
+ zf.writestr("README.md", readme)
rekipedia/cli/init.py ADDED
@@ -0,0 +1,83 @@
1
+ """`rekipedia init` command — scaffold the .rekipedia/ bundle in a repo."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ import click
7
+ import yaml
8
+ from rich.console import Console
9
+
10
+ console = Console()
11
+
12
+ _DEFAULT_CONFIG: dict = {
13
+ "version": 1,
14
+ "ignore": [
15
+ ".git",
16
+ "node_modules",
17
+ "__pycache__",
18
+ ".rekipedia",
19
+ ],
20
+ "languages": ["python", "typescript"],
21
+ "llm": {
22
+ "model": "ollama/llama4",
23
+ "api_key": "",
24
+ "base_url": "",
25
+ "temperature": 0.2,
26
+ },
27
+ }
28
+
29
+ _GITIGNORE_ENTRY = ".rekipedia/store.db\n"
30
+
31
+
32
+ def run_init(repo_path: Path) -> None:
33
+ wiki_dir = repo_path / ".rekipedia"
34
+ config_path = wiki_dir / "config.yml"
35
+ gitignore_path = repo_path / ".gitignore"
36
+
37
+ # Create .rekipedia/ if missing
38
+ wiki_dir.mkdir(parents=True, exist_ok=True)
39
+
40
+ # Write config.yml — idempotent (skip if already present)
41
+ if config_path.exists():
42
+ console.print(
43
+ f"[yellow]⚠[/yellow] [bold]{config_path}[/bold] already exists — skipping."
44
+ )
45
+ else:
46
+ config_path.write_text(
47
+ yaml.dump(_DEFAULT_CONFIG, default_flow_style=False, sort_keys=False),
48
+ encoding="utf-8",
49
+ )
50
+ console.print(f"[green]✔[/green] Created [bold]{config_path}[/bold]")
51
+
52
+ # Append .gitignore entry — idempotent
53
+ if gitignore_path.exists():
54
+ existing = gitignore_path.read_text(encoding="utf-8")
55
+ if ".rekipedia/store.db" in existing:
56
+ console.print(
57
+ "[yellow]⚠[/yellow] .gitignore already contains .rekipedia/store.db — skipping."
58
+ )
59
+ else:
60
+ with gitignore_path.open("a", encoding="utf-8") as fh:
61
+ fh.write(_GITIGNORE_ENTRY)
62
+ console.print("[green]✔[/green] Added .rekipedia/store.db to .gitignore")
63
+ else:
64
+ gitignore_path.write_text(_GITIGNORE_ENTRY, encoding="utf-8")
65
+ console.print("[green]✔[/green] Created .gitignore with .rekipedia/store.db")
66
+
67
+ console.print()
68
+ console.print("[bold green]rekipedia initialised.[/bold green]")
69
+ console.print(
70
+ f" Edit [cyan]{config_path}[/cyan] to choose your LLM provider/model, then run:"
71
+ )
72
+ console.print(" [bold]rekipedia scan .[/bold]")
73
+
74
+
75
+ @click.command("init")
76
+ @click.argument(
77
+ "repo",
78
+ default=".",
79
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
80
+ )
81
+ def init_cmd(repo: Path) -> None:
82
+ """Initialise rekipedia in REPO (default: current directory)."""
83
+ run_init(repo.resolve())