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.
- rekipedia/__init__.py +2 -0
- rekipedia/__main__.py +3 -0
- rekipedia/cli/__init__.py +27 -0
- rekipedia/cli/ask.py +163 -0
- rekipedia/cli/embed.py +178 -0
- rekipedia/cli/export.py +200 -0
- rekipedia/cli/init.py +83 -0
- rekipedia/cli/scan.py +126 -0
- rekipedia/cli/serve.py +72 -0
- rekipedia/cli/update.py +102 -0
- rekipedia/exporters/__init__.py +1 -0
- rekipedia/exporters/json_export.py +98 -0
- rekipedia/exporters/markdown_export.py +41 -0
- rekipedia/extractors/__init__.py +8 -0
- rekipedia/extractors/base.py +22 -0
- rekipedia/extractors/config_extractor.py +147 -0
- rekipedia/extractors/python_extractor.py +141 -0
- rekipedia/extractors/typescript_extractor.py +113 -0
- rekipedia/llm/__init__.py +1 -0
- rekipedia/llm/client.py +116 -0
- rekipedia/models/__init__.py +1 -0
- rekipedia/models/contracts.py +90 -0
- rekipedia/orchestrator/__init__.py +1 -0
- rekipedia/orchestrator/run_ask.py +208 -0
- rekipedia/orchestrator/run_digest.py +339 -0
- rekipedia/orchestrator/run_update.py +187 -0
- rekipedia/orchestrator/sharding.py +77 -0
- rekipedia/orchestrator/snapshotter.py +108 -0
- rekipedia/prompts/ask_system.md +17 -0
- rekipedia/prompts/digest_system.md +70 -0
- rekipedia/rag/__init__.py +0 -0
- rekipedia/rag/embedder.py +399 -0
- rekipedia/rag/scan_meta.py +61 -0
- rekipedia/sandbox/__init__.py +1 -0
- rekipedia/sandbox/runner.py +143 -0
- rekipedia/sandbox/tasks/__init__.py +1 -0
- rekipedia/sandbox/tasks/analyze_shard.py +75 -0
- rekipedia/server/__init__.py +0 -0
- rekipedia/server/app.py +236 -0
- rekipedia/server/templates/ask.html +392 -0
- rekipedia/server/templates/base.html +153 -0
- rekipedia/server/templates/index.html +137 -0
- rekipedia/server/templates/wiki.html +4 -0
- rekipedia/storage/__init__.py +1 -0
- rekipedia/storage/migrations/001_initial.sql +156 -0
- rekipedia/storage/migrations/002_qa_history.sql +8 -0
- rekipedia/storage/sqlite_store.py +481 -0
- rekipedia/synthesis/__init__.py +1 -0
- rekipedia/synthesis/diagram_builder.py +161 -0
- rekipedia/synthesis/page_builder.py +404 -0
- rekipedia/synthesis/planner.py +427 -0
- rekipedia-0.9.3.dist-info/METADATA +351 -0
- rekipedia-0.9.3.dist-info/RECORD +55 -0
- rekipedia-0.9.3.dist-info/WHEEL +4 -0
- rekipedia-0.9.3.dist-info/entry_points.txt +3 -0
rekipedia/__init__.py
ADDED
rekipedia/__main__.py
ADDED
|
@@ -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)
|
rekipedia/cli/export.py
ADDED
|
@@ -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())
|