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 ADDED
@@ -0,0 +1,3 @@
1
+ """folio — local hybrid RAG over curated AI / MLOps / data curricula (graphify-style)."""
2
+
3
+ __version__ = "0.3.0"
folio/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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.
@@ -0,0 +1 @@
1
+ """Packaged static assets (config, skill templates)."""
@@ -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