folio-cli 0.3.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.
- folio/__init__.py +3 -0
- folio/__main__.py +4 -0
- folio/assets/SKILL.md +75 -0
- folio/assets/__init__.py +1 -0
- folio/assets/config.yaml +77 -0
- folio/chunk.py +164 -0
- folio/cli.py +308 -0
- folio/config.py +97 -0
- folio/distribute.py +177 -0
- folio/ingest.py +76 -0
- folio/install.py +279 -0
- folio/retrieve.py +185 -0
- folio/store.py +120 -0
- folio/sync.py +110 -0
- folio_cli-0.3.0.dist-info/METADATA +180 -0
- folio_cli-0.3.0.dist-info/RECORD +20 -0
- folio_cli-0.3.0.dist-info/WHEEL +5 -0
- folio_cli-0.3.0.dist-info/entry_points.txt +2 -0
- folio_cli-0.3.0.dist-info/licenses/LICENSE +176 -0
- folio_cli-0.3.0.dist-info/top_level.txt +1 -0
folio/__init__.py
ADDED
folio/__main__.py
ADDED
folio/assets/SKILL.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: folio
|
|
3
|
+
description: "Use for any AI engineering / MLOps / RAG / agent / GPU / AI infra / related AI architecture decision. Queries the local hybrid RAG over curated curricula (~/.folio). Treat those design questions as a folio query first."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# /folio
|
|
7
|
+
|
|
8
|
+
Local hybrid RAG over curated AI-engineer / MLOps / infra markdown curricula.
|
|
9
|
+
Persistent vector + BM25 index in `~/.folio/` (or `$FOLIO_HOME`). No API key required.
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
/folio # orient + ensure index
|
|
15
|
+
/folio bootstrap # download prebuilt index (preferred)
|
|
16
|
+
/folio build # sync curated sources + ingest
|
|
17
|
+
/folio update # refresh from maintainer Release
|
|
18
|
+
/folio update --from-source # rebuild from curated git sources
|
|
19
|
+
/folio query "<question>" # hybrid retrieve (BM25 + dense + RRF + CRAG)
|
|
20
|
+
/folio query "<question>" --json # machine-readable hits for agents
|
|
21
|
+
/folio query "<question>" -k 10 # more chunks
|
|
22
|
+
/folio query "<question>" --rerank # optional cross-encoder
|
|
23
|
+
/folio status # CLI + index health
|
|
24
|
+
/folio install --project --platform cursor # wire this repo's assistant
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## What folio is for
|
|
28
|
+
|
|
29
|
+
Ground AI / MLOps / AI-infra decisions in retrieved citations from curated curricula
|
|
30
|
+
(currently senior AI revision guide + AI infra lessons; more domains later).
|
|
31
|
+
|
|
32
|
+
**When to query:** RAG, embeddings, vector DBs, agents, training/GPU, model optimization,
|
|
33
|
+
feature stores, GitOps, SRE/SLO for ML, AI infra, or related senior AI trade-offs.
|
|
34
|
+
|
|
35
|
+
**When not to:** ordinary product code exploration — use graphify / Read / Grep for that.
|
|
36
|
+
|
|
37
|
+
## Token rule (strict)
|
|
38
|
+
|
|
39
|
+
Do **not** WebFetch, clone, or paste the curriculum GitHub repos into chat.
|
|
40
|
+
They are already indexed. Only `folio query`. If the index is missing, tell the user
|
|
41
|
+
to run `folio bootstrap` or `folio build` — never dump raw curriculum markdown into context.
|
|
42
|
+
|
|
43
|
+
## What You Must Do When Invoked
|
|
44
|
+
|
|
45
|
+
### Fast path — index exists
|
|
46
|
+
|
|
47
|
+
If `~/.folio/manifest.json` exists (or `$FOLIO_HOME/manifest.json`):
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
folio query "<question>" --json
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Ground your answer **only** in returned citations (`source_id`, `rel_path`, `heading_path`, `text`).
|
|
54
|
+
If scores are weak / empty, broaden terms and re-query once before inventing architecture.
|
|
55
|
+
|
|
56
|
+
### No index yet
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
folio bootstrap # or: folio build
|
|
60
|
+
folio query "<question>" --json
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Install into a project
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
folio install --project --platform cursor
|
|
67
|
+
folio bootstrap # once per machine
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Rules for answers
|
|
71
|
+
|
|
72
|
+
1. Prefer patterns that appear in retrieved chunks; cite `source_id` + file + heading.
|
|
73
|
+
2. When sources conflict (e.g. hybrid RAG vs RAGless), state the trade-off and pick for *this* constraint.
|
|
74
|
+
3. Do not dump entire markdown files when retrieval exists.
|
|
75
|
+
4. Never skip retrieval for “quick” AI architecture guesses when the index is present.
|
folio/assets/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Packaged static assets (config, skill templates)."""
|
folio/assets/config.yaml
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Hybrid RAG — curated AI eng / MLOps / AI infra curricula
|
|
2
|
+
# BM25 + dense → RRF → CRAG widen → optional cross-encoder rerank
|
|
3
|
+
|
|
4
|
+
sources:
|
|
5
|
+
# --- already trusted (user seeds) ---
|
|
6
|
+
- id: senior-ai-revision
|
|
7
|
+
repo: https://github.com/RavSinghChandan/02-senior-ai-engineer-study-path.git
|
|
8
|
+
sparse_path: revision-1
|
|
9
|
+
branch: main
|
|
10
|
+
- id: ai-infra-lessons
|
|
11
|
+
repo: https://github.com/ai-infra-curriculum/ai-infra-senior-engineer-learning.git
|
|
12
|
+
sparse_path: lessons
|
|
13
|
+
branch: main
|
|
14
|
+
|
|
15
|
+
# --- same curriculum factory (MD lessons, senior depth) ---
|
|
16
|
+
- id: agentic-ai-developer
|
|
17
|
+
repo: https://github.com/ai-engineering-curriculum/agentic-ai-developer-learning.git
|
|
18
|
+
sparse_path: lessons
|
|
19
|
+
branch: main
|
|
20
|
+
- id: agentic-ai-engineer
|
|
21
|
+
repo: https://github.com/ai-engineering-curriculum/agentic-ai-engineer-learning.git
|
|
22
|
+
sparse_path: lessons
|
|
23
|
+
branch: main
|
|
24
|
+
- id: senior-agentic-ai-engineer
|
|
25
|
+
repo: https://github.com/ai-engineering-curriculum/senior-agentic-ai-engineer-learning.git
|
|
26
|
+
sparse_path: lessons
|
|
27
|
+
branch: main
|
|
28
|
+
- id: applied-ai-engineer
|
|
29
|
+
repo: https://github.com/ai-engineering-curriculum/applied-ai-engineer-learning.git
|
|
30
|
+
sparse_path: lessons
|
|
31
|
+
branch: main
|
|
32
|
+
- id: ai-eval-engineer
|
|
33
|
+
repo: https://github.com/ai-engineering-curriculum/ai-eval-engineer-learning.git
|
|
34
|
+
sparse_path: lessons
|
|
35
|
+
branch: main
|
|
36
|
+
- id: agentic-systems-architect
|
|
37
|
+
repo: https://github.com/ai-engineering-curriculum/agentic-systems-architect-learning.git
|
|
38
|
+
sparse_path: lessons
|
|
39
|
+
branch: main
|
|
40
|
+
|
|
41
|
+
# --- classic MLOps course (markdown-heavy modules) ---
|
|
42
|
+
- id: mlops-zoomcamp
|
|
43
|
+
repo: https://github.com/DataTalksClub/mlops-zoomcamp.git
|
|
44
|
+
sparse_path: .
|
|
45
|
+
branch: main
|
|
46
|
+
|
|
47
|
+
release:
|
|
48
|
+
# Filled after first GitHub Release upload of folio-index.tar.gz
|
|
49
|
+
index_url: "https://github.com/Iman-Sharei/folio/releases/latest/download/folio-index.tar.gz"
|
|
50
|
+
|
|
51
|
+
paths:
|
|
52
|
+
sources_dir: data/sources
|
|
53
|
+
chroma_dir: data/chroma
|
|
54
|
+
bm25_path: data/bm25/index.pkl
|
|
55
|
+
manifest_path: data/manifest.json
|
|
56
|
+
|
|
57
|
+
chunking:
|
|
58
|
+
max_chars: 1200
|
|
59
|
+
overlap_chars: 150
|
|
60
|
+
min_chars: 80
|
|
61
|
+
|
|
62
|
+
embedding:
|
|
63
|
+
model: onnx-minilm-l6-v2
|
|
64
|
+
batch_size: 64
|
|
65
|
+
|
|
66
|
+
collection:
|
|
67
|
+
name: folio_knowledge
|
|
68
|
+
|
|
69
|
+
retrieval:
|
|
70
|
+
dense_k: 20
|
|
71
|
+
bm25_k: 20
|
|
72
|
+
final_k: 8
|
|
73
|
+
rrf_k: 60
|
|
74
|
+
crag_min_best_score: 0.012
|
|
75
|
+
crag_widen_factor: 2
|
|
76
|
+
reranker_model: cross-encoder/ms-marco-MiniLM-L-6-v2
|
|
77
|
+
confidence_floor: 0.008
|
folio/chunk.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$", re.MULTILINE)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Chunk:
|
|
13
|
+
chunk_id: str
|
|
14
|
+
text: str
|
|
15
|
+
source_id: str
|
|
16
|
+
rel_path: str
|
|
17
|
+
heading_path: str
|
|
18
|
+
chunk_index: int
|
|
19
|
+
content_hash: str
|
|
20
|
+
char_count: int
|
|
21
|
+
|
|
22
|
+
def to_metadata(self) -> dict:
|
|
23
|
+
meta = asdict(self)
|
|
24
|
+
# Chroma metadata values must be str/int/float/bool
|
|
25
|
+
meta.pop("text")
|
|
26
|
+
return meta
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _file_hash(text: str) -> str:
|
|
30
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _split_oversized(text: str, max_chars: int, overlap: int) -> list[str]:
|
|
34
|
+
if len(text) <= max_chars:
|
|
35
|
+
return [text]
|
|
36
|
+
parts: list[str] = []
|
|
37
|
+
paragraphs = re.split(r"\n\s*\n", text)
|
|
38
|
+
buf = ""
|
|
39
|
+
for para in paragraphs:
|
|
40
|
+
para = para.strip()
|
|
41
|
+
if not para:
|
|
42
|
+
continue
|
|
43
|
+
candidate = f"{buf}\n\n{para}".strip() if buf else para
|
|
44
|
+
if len(candidate) <= max_chars:
|
|
45
|
+
buf = candidate
|
|
46
|
+
continue
|
|
47
|
+
if buf:
|
|
48
|
+
parts.append(buf)
|
|
49
|
+
if len(para) <= max_chars:
|
|
50
|
+
buf = para
|
|
51
|
+
else:
|
|
52
|
+
start = 0
|
|
53
|
+
while start < len(para):
|
|
54
|
+
end = min(start + max_chars, len(para))
|
|
55
|
+
parts.append(para[start:end])
|
|
56
|
+
if end >= len(para):
|
|
57
|
+
break
|
|
58
|
+
start = max(0, end - overlap)
|
|
59
|
+
buf = ""
|
|
60
|
+
if buf:
|
|
61
|
+
parts.append(buf)
|
|
62
|
+
|
|
63
|
+
if overlap <= 0 or len(parts) <= 1:
|
|
64
|
+
return parts
|
|
65
|
+
|
|
66
|
+
# Soft overlap between adjacent oversized splits already handled for long paras;
|
|
67
|
+
# stitch short tails if needed by overlapping previous suffix.
|
|
68
|
+
overlapped: list[str] = [parts[0]]
|
|
69
|
+
for i in range(1, len(parts)):
|
|
70
|
+
prev = parts[i - 1]
|
|
71
|
+
prefix = prev[-overlap:] if len(prev) > overlap else prev
|
|
72
|
+
nxt = parts[i]
|
|
73
|
+
if not nxt.startswith(prefix[:40]):
|
|
74
|
+
overlapped.append(f"{prefix}\n{nxt}".strip())
|
|
75
|
+
else:
|
|
76
|
+
overlapped.append(nxt)
|
|
77
|
+
return overlapped
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def chunk_markdown(
|
|
81
|
+
text: str,
|
|
82
|
+
*,
|
|
83
|
+
source_id: str,
|
|
84
|
+
rel_path: str,
|
|
85
|
+
max_chars: int = 1200,
|
|
86
|
+
overlap_chars: int = 150,
|
|
87
|
+
min_chars: int = 80,
|
|
88
|
+
) -> list[Chunk]:
|
|
89
|
+
"""Structure-aware chunking: split on markdown headings, then size-guard."""
|
|
90
|
+
content_hash = _file_hash(text)
|
|
91
|
+
sections: list[tuple[str, str]] = []
|
|
92
|
+
matches = list(HEADING_RE.finditer(text))
|
|
93
|
+
|
|
94
|
+
if not matches:
|
|
95
|
+
sections.append(("", text.strip()))
|
|
96
|
+
else:
|
|
97
|
+
# Preface before first heading
|
|
98
|
+
preface = text[: matches[0].start()].strip()
|
|
99
|
+
if preface:
|
|
100
|
+
sections.append(("", preface))
|
|
101
|
+
|
|
102
|
+
stack: list[tuple[int, str]] = []
|
|
103
|
+
for i, m in enumerate(matches):
|
|
104
|
+
level = len(m.group(1))
|
|
105
|
+
title = m.group(2).strip()
|
|
106
|
+
while stack and stack[-1][0] >= level:
|
|
107
|
+
stack.pop()
|
|
108
|
+
stack.append((level, title))
|
|
109
|
+
heading_path = " > ".join(t for _, t in stack)
|
|
110
|
+
start = m.start()
|
|
111
|
+
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
|
112
|
+
body = text[start:end].strip()
|
|
113
|
+
sections.append((heading_path, body))
|
|
114
|
+
|
|
115
|
+
raw_chunks: list[tuple[str, str]] = []
|
|
116
|
+
for heading_path, body in sections:
|
|
117
|
+
for piece in _split_oversized(body, max_chars, overlap_chars):
|
|
118
|
+
piece = piece.strip()
|
|
119
|
+
if len(piece) < min_chars:
|
|
120
|
+
continue
|
|
121
|
+
raw_chunks.append((heading_path, piece))
|
|
122
|
+
|
|
123
|
+
chunks: list[Chunk] = []
|
|
124
|
+
for idx, (heading_path, piece) in enumerate(raw_chunks):
|
|
125
|
+
digest = hashlib.sha256(
|
|
126
|
+
f"{source_id}:{rel_path}:{idx}:{piece}".encode("utf-8")
|
|
127
|
+
).hexdigest()[:24]
|
|
128
|
+
chunks.append(
|
|
129
|
+
Chunk(
|
|
130
|
+
chunk_id=f"{source_id}:{digest}",
|
|
131
|
+
text=piece,
|
|
132
|
+
source_id=source_id,
|
|
133
|
+
rel_path=rel_path,
|
|
134
|
+
heading_path=heading_path or "(preamble)",
|
|
135
|
+
chunk_index=idx,
|
|
136
|
+
content_hash=content_hash,
|
|
137
|
+
char_count=len(piece),
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
return chunks
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def chunk_file(
|
|
144
|
+
path: Path,
|
|
145
|
+
*,
|
|
146
|
+
source_id: str,
|
|
147
|
+
sources_root: Path,
|
|
148
|
+
max_chars: int,
|
|
149
|
+
overlap_chars: int,
|
|
150
|
+
min_chars: int,
|
|
151
|
+
) -> list[Chunk]:
|
|
152
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
153
|
+
try:
|
|
154
|
+
rel = str(path.relative_to(sources_root))
|
|
155
|
+
except ValueError:
|
|
156
|
+
rel = path.name
|
|
157
|
+
return chunk_markdown(
|
|
158
|
+
text,
|
|
159
|
+
source_id=source_id,
|
|
160
|
+
rel_path=rel,
|
|
161
|
+
max_chars=max_chars,
|
|
162
|
+
overlap_chars=overlap_chars,
|
|
163
|
+
min_chars=min_chars,
|
|
164
|
+
)
|
folio/cli.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from importlib.metadata import PackageNotFoundError, version as pkg_version
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .config import ensure_data_dirs, load_config, resolve_out_dir, write_default_config
|
|
11
|
+
from .distribute import bootstrap_index, migrate_legacy_index, pack_index, update_index
|
|
12
|
+
from .ingest import ingest
|
|
13
|
+
from .install import install as install_skill
|
|
14
|
+
from .install import uninstall as uninstall_skill
|
|
15
|
+
from .retrieve import format_context, retrieve
|
|
16
|
+
from .sync import sync_sources
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _cli_version() -> str:
|
|
20
|
+
try:
|
|
21
|
+
return pkg_version("folio")
|
|
22
|
+
except PackageNotFoundError:
|
|
23
|
+
return __version__
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _cfg(args: argparse.Namespace) -> dict:
|
|
27
|
+
out = getattr(args, "out", None)
|
|
28
|
+
return load_config(out_dir=out)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _add_out_arg(p: argparse.ArgumentParser) -> None:
|
|
32
|
+
p.add_argument(
|
|
33
|
+
"--out",
|
|
34
|
+
type=Path,
|
|
35
|
+
default=None,
|
|
36
|
+
help="Index root (default: ~/.folio or $FOLIO_HOME)",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cmd_sync(args: argparse.Namespace) -> int:
|
|
41
|
+
cfg = _cfg(args)
|
|
42
|
+
ensure_data_dirs(cfg)
|
|
43
|
+
write_default_config(cfg["paths"]["out_dir"])
|
|
44
|
+
sync_sources(cfg, force=args.force)
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cmd_build(args: argparse.Namespace) -> int:
|
|
49
|
+
"""Sync curated sources + ingest into the shared index."""
|
|
50
|
+
cfg = _cfg(args)
|
|
51
|
+
ensure_data_dirs(cfg)
|
|
52
|
+
write_default_config(cfg["paths"]["out_dir"])
|
|
53
|
+
sync_sources(cfg, force=False)
|
|
54
|
+
ingest(cfg, rebuild=True)
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cmd_ingest(args: argparse.Namespace) -> int:
|
|
59
|
+
cfg = _cfg(args)
|
|
60
|
+
ensure_data_dirs(cfg)
|
|
61
|
+
if args.sync_first:
|
|
62
|
+
sync_sources(cfg, force=False)
|
|
63
|
+
ingest(cfg, rebuild=True)
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def cmd_bootstrap(args: argparse.Namespace) -> int:
|
|
68
|
+
migrate_legacy_index()
|
|
69
|
+
cfg = _cfg(args)
|
|
70
|
+
bootstrap_index(
|
|
71
|
+
cfg,
|
|
72
|
+
url=args.url,
|
|
73
|
+
archive=args.archive,
|
|
74
|
+
force=args.force,
|
|
75
|
+
)
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def cmd_update(args: argparse.Namespace) -> int:
|
|
80
|
+
"""Maintainer-published index refresh (Release), or rebuild from sources."""
|
|
81
|
+
cfg = _cfg(args)
|
|
82
|
+
return update_index(
|
|
83
|
+
cfg,
|
|
84
|
+
from_source=args.from_source,
|
|
85
|
+
url=args.url,
|
|
86
|
+
force=True,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def cmd_pack(args: argparse.Namespace) -> int:
|
|
91
|
+
cfg = _cfg(args)
|
|
92
|
+
pack_index(cfg, dest=args.dest)
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def cmd_query(args: argparse.Namespace) -> int:
|
|
97
|
+
migrate_legacy_index()
|
|
98
|
+
cfg = _cfg(args)
|
|
99
|
+
manifest = Path(cfg["paths"]["manifest_path"])
|
|
100
|
+
if not manifest.exists():
|
|
101
|
+
print(
|
|
102
|
+
"error: no index found. Run `folio bootstrap` or `folio build` first "
|
|
103
|
+
f"(expected {manifest})",
|
|
104
|
+
file=sys.stderr,
|
|
105
|
+
)
|
|
106
|
+
return 2
|
|
107
|
+
hits = retrieve(
|
|
108
|
+
cfg,
|
|
109
|
+
args.query,
|
|
110
|
+
final_k=args.k,
|
|
111
|
+
use_rerank=args.rerank,
|
|
112
|
+
source_filter=args.source,
|
|
113
|
+
)
|
|
114
|
+
if args.json:
|
|
115
|
+
payload = [
|
|
116
|
+
{
|
|
117
|
+
"chunk_id": h.chunk_id,
|
|
118
|
+
"score": h.score,
|
|
119
|
+
"source_id": h.source_id,
|
|
120
|
+
"rel_path": h.rel_path,
|
|
121
|
+
"heading_path": h.heading_path,
|
|
122
|
+
"dense_rank": h.dense_rank,
|
|
123
|
+
"bm25_rank": h.bm25_rank,
|
|
124
|
+
"text": h.text,
|
|
125
|
+
}
|
|
126
|
+
for h in hits
|
|
127
|
+
]
|
|
128
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
129
|
+
else:
|
|
130
|
+
if not hits:
|
|
131
|
+
print("No hits above confidence floor. Try a broader query or update the index.")
|
|
132
|
+
return 1
|
|
133
|
+
print(format_context(hits))
|
|
134
|
+
print("\n# decision hint: ground architecture choices in the citations above.")
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
139
|
+
migrate_legacy_index()
|
|
140
|
+
cfg = _cfg(args)
|
|
141
|
+
out = cfg["paths"]["out_dir"]
|
|
142
|
+
manifest = cfg["paths"]["manifest_path"]
|
|
143
|
+
print(f"cli: folio {_cli_version()}")
|
|
144
|
+
print(f"out: {out}")
|
|
145
|
+
print(f"chroma: {cfg['paths']['chroma_dir']} exists={Path(cfg['paths']['chroma_dir']).exists()}")
|
|
146
|
+
print(f"bm25: {cfg['paths']['bm25_path']} exists={Path(cfg['paths']['bm25_path']).exists()}")
|
|
147
|
+
print(f"manifest: {manifest} exists={manifest.exists()}")
|
|
148
|
+
if manifest.exists():
|
|
149
|
+
data = json.loads(Path(manifest).read_text(encoding="utf-8"))
|
|
150
|
+
keys = (
|
|
151
|
+
"index_version",
|
|
152
|
+
"ingested_at",
|
|
153
|
+
"files",
|
|
154
|
+
"chunks",
|
|
155
|
+
"embedding_model",
|
|
156
|
+
"cli_version",
|
|
157
|
+
)
|
|
158
|
+
print(json.dumps({k: data[k] for k in keys if k in data}, indent=2))
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def cmd_install(args: argparse.Namespace) -> int:
|
|
163
|
+
return install_skill(
|
|
164
|
+
platform=args.platform,
|
|
165
|
+
project=args.project,
|
|
166
|
+
project_dir=Path(args.dir).resolve() if args.dir else Path.cwd(),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def cmd_uninstall(args: argparse.Namespace) -> int:
|
|
171
|
+
return uninstall_skill(
|
|
172
|
+
platform=args.platform,
|
|
173
|
+
project=args.project,
|
|
174
|
+
project_dir=Path(args.dir).resolve() if args.dir else Path.cwd(),
|
|
175
|
+
purge=args.purge,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
180
|
+
parser = argparse.ArgumentParser(
|
|
181
|
+
prog="folio",
|
|
182
|
+
description=(
|
|
183
|
+
"Local hybrid RAG over curated AI / MLOps / infra curricula "
|
|
184
|
+
"(graphify-style CLI for any assistant)"
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
parser.add_argument("--version", action="version", version=f"folio {_cli_version()}")
|
|
188
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
189
|
+
|
|
190
|
+
p_sync = sub.add_parser("sync", help="Sparse-clone / update curated GitHub markdown sources")
|
|
191
|
+
_add_out_arg(p_sync)
|
|
192
|
+
p_sync.add_argument("--force", action="store_true")
|
|
193
|
+
p_sync.set_defaults(func=cmd_sync)
|
|
194
|
+
|
|
195
|
+
p_build = sub.add_parser("build", help="Sync + chunk + embed + index from curated sources")
|
|
196
|
+
_add_out_arg(p_build)
|
|
197
|
+
p_build.set_defaults(func=cmd_build)
|
|
198
|
+
|
|
199
|
+
p_ingest = sub.add_parser("ingest", help="Chunk + embed + index (no sync unless --sync-first)")
|
|
200
|
+
_add_out_arg(p_ingest)
|
|
201
|
+
p_ingest.add_argument("--sync-first", action="store_true")
|
|
202
|
+
p_ingest.set_defaults(func=cmd_ingest)
|
|
203
|
+
|
|
204
|
+
p_bootstrap = sub.add_parser(
|
|
205
|
+
"bootstrap",
|
|
206
|
+
help="Download/install a prebuilt index into ~/.folio (or $FOLIO_HOME)",
|
|
207
|
+
)
|
|
208
|
+
_add_out_arg(p_bootstrap)
|
|
209
|
+
p_bootstrap.add_argument("--url", default=None, help="Override release.index_url")
|
|
210
|
+
p_bootstrap.add_argument(
|
|
211
|
+
"--archive",
|
|
212
|
+
type=Path,
|
|
213
|
+
default=None,
|
|
214
|
+
help="Install from a local folio-index-*.tar.gz instead of URL",
|
|
215
|
+
)
|
|
216
|
+
p_bootstrap.add_argument("--force", action="store_true", help="Replace existing index")
|
|
217
|
+
p_bootstrap.set_defaults(func=cmd_bootstrap)
|
|
218
|
+
|
|
219
|
+
p_update = sub.add_parser(
|
|
220
|
+
"update",
|
|
221
|
+
help="Refresh index from maintainer Release (default) or rebuild with --from-source",
|
|
222
|
+
)
|
|
223
|
+
_add_out_arg(p_update)
|
|
224
|
+
p_update.add_argument("--url", default=None, help="Override release.index_url")
|
|
225
|
+
p_update.add_argument(
|
|
226
|
+
"--from-source",
|
|
227
|
+
action="store_true",
|
|
228
|
+
help="Rebuild by syncing curated repos + embedding (slow)",
|
|
229
|
+
)
|
|
230
|
+
p_update.set_defaults(func=cmd_update)
|
|
231
|
+
|
|
232
|
+
p_pack = sub.add_parser(
|
|
233
|
+
"pack",
|
|
234
|
+
help="Maintainer: pack ~/.folio index into dist/folio-index-*.tar.gz for a Release",
|
|
235
|
+
)
|
|
236
|
+
_add_out_arg(p_pack)
|
|
237
|
+
p_pack.add_argument(
|
|
238
|
+
"--dest",
|
|
239
|
+
type=Path,
|
|
240
|
+
default=None,
|
|
241
|
+
help="Output tarball path (default: dist/folio-index-<version>.tar.gz)",
|
|
242
|
+
)
|
|
243
|
+
p_pack.set_defaults(func=cmd_pack)
|
|
244
|
+
|
|
245
|
+
p_query = sub.add_parser(
|
|
246
|
+
"query",
|
|
247
|
+
help="Hybrid retrieve for AI-eng / MLOps / AI infra / related AI questions",
|
|
248
|
+
)
|
|
249
|
+
_add_out_arg(p_query)
|
|
250
|
+
p_query.add_argument("query", help="Natural language task or question")
|
|
251
|
+
p_query.add_argument("-k", type=int, default=None)
|
|
252
|
+
p_query.add_argument("--rerank", action="store_true")
|
|
253
|
+
p_query.add_argument("--source", default=None)
|
|
254
|
+
p_query.add_argument("--json", action="store_true")
|
|
255
|
+
p_query.set_defaults(func=cmd_query)
|
|
256
|
+
|
|
257
|
+
p_status = sub.add_parser("status", help="Show CLI + index status")
|
|
258
|
+
_add_out_arg(p_status)
|
|
259
|
+
p_status.set_defaults(func=cmd_status)
|
|
260
|
+
|
|
261
|
+
p_install = sub.add_parser(
|
|
262
|
+
"install",
|
|
263
|
+
help="Register skill/rules with an AI assistant (cursor|claude|codex|agents)",
|
|
264
|
+
)
|
|
265
|
+
p_install.add_argument(
|
|
266
|
+
"--platform",
|
|
267
|
+
default="cursor",
|
|
268
|
+
help="cursor|claude|codex|agents (default: cursor)",
|
|
269
|
+
)
|
|
270
|
+
p_install.add_argument(
|
|
271
|
+
"--project",
|
|
272
|
+
action="store_true",
|
|
273
|
+
help="Write into the current repo (commit-friendly), like graphify --project",
|
|
274
|
+
)
|
|
275
|
+
p_install.add_argument("--dir", default=".", help="Project directory (default: cwd)")
|
|
276
|
+
p_install.set_defaults(func=cmd_install)
|
|
277
|
+
|
|
278
|
+
p_uninstall = sub.add_parser("uninstall", help="Remove folio assistant registration")
|
|
279
|
+
p_uninstall.add_argument("--platform", default=None)
|
|
280
|
+
p_uninstall.add_argument("--project", action="store_true")
|
|
281
|
+
p_uninstall.add_argument("--dir", default=".")
|
|
282
|
+
p_uninstall.add_argument(
|
|
283
|
+
"--purge",
|
|
284
|
+
action="store_true",
|
|
285
|
+
help="Also delete a leftover ./folio-out/ in the project (index normally lives in ~/.folio)",
|
|
286
|
+
)
|
|
287
|
+
p_uninstall.set_defaults(func=cmd_uninstall)
|
|
288
|
+
|
|
289
|
+
return parser
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def main(argv: list[str] | None = None) -> int:
|
|
293
|
+
# Patch Chroma ONNX cache early (before collection open in subcommands).
|
|
294
|
+
out_hint = resolve_out_dir()
|
|
295
|
+
from .store import _ensure_local_chroma_cache
|
|
296
|
+
|
|
297
|
+
_ensure_local_chroma_cache(out_hint / "model-cache")
|
|
298
|
+
|
|
299
|
+
parser = build_parser()
|
|
300
|
+
args = parser.parse_args(argv)
|
|
301
|
+
try:
|
|
302
|
+
return int(args.func(args))
|
|
303
|
+
except FileNotFoundError as e:
|
|
304
|
+
print(f"error: {e}", file=sys.stderr)
|
|
305
|
+
return 2
|
|
306
|
+
except Exception as e:
|
|
307
|
+
print(f"error: {e}", file=sys.stderr)
|
|
308
|
+
return 1
|