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/config.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import os
|
|
5
|
+
from importlib import resources
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
# Standalone tool checkout (…/Desktop/folio) — used only as a fallback for
|
|
12
|
+
# editable installs; the shared index lives under ~/.folio by default.
|
|
13
|
+
TOOL_ROOT = Path(__file__).resolve().parents[2]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def default_home() -> Path:
|
|
17
|
+
"""Shared index root (~/.folio). Computed at call time (never cache Path.home())."""
|
|
18
|
+
return Path.home() / ".folio"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def package_dir() -> Path:
|
|
22
|
+
return Path(__file__).resolve().parent
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def default_config_text() -> str:
|
|
26
|
+
"""Load bundled config.yaml (works for editable + installed wheels)."""
|
|
27
|
+
bundled = package_dir() / "assets" / "config.yaml"
|
|
28
|
+
if bundled.exists():
|
|
29
|
+
return bundled.read_text(encoding="utf-8")
|
|
30
|
+
legacy = TOOL_ROOT / "config.yaml"
|
|
31
|
+
if legacy.exists():
|
|
32
|
+
return legacy.read_text(encoding="utf-8")
|
|
33
|
+
try:
|
|
34
|
+
return resources.files("folio.assets").joinpath("config.yaml").read_text(
|
|
35
|
+
encoding="utf-8"
|
|
36
|
+
)
|
|
37
|
+
except Exception as e: # noqa: BLE001
|
|
38
|
+
raise FileNotFoundError("Bundled folio config.yaml not found") from e
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve_out_dir(explicit: Path | None = None) -> Path:
|
|
42
|
+
"""Resolve shared knowledge index root.
|
|
43
|
+
|
|
44
|
+
Priority:
|
|
45
|
+
1. explicit --out
|
|
46
|
+
2. FOLIO_HOME env
|
|
47
|
+
3. ~/.folio
|
|
48
|
+
"""
|
|
49
|
+
if explicit is not None:
|
|
50
|
+
return explicit.expanduser().resolve()
|
|
51
|
+
env = os.environ.get("FOLIO_HOME")
|
|
52
|
+
if env:
|
|
53
|
+
return Path(env).expanduser().resolve()
|
|
54
|
+
return default_home().resolve()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_config(
|
|
58
|
+
*,
|
|
59
|
+
out_dir: Path | None = None,
|
|
60
|
+
config_path: Path | None = None,
|
|
61
|
+
) -> dict[str, Any]:
|
|
62
|
+
if config_path is not None:
|
|
63
|
+
raw = config_path.read_text(encoding="utf-8")
|
|
64
|
+
else:
|
|
65
|
+
root = resolve_out_dir(out_dir)
|
|
66
|
+
local_cfg = root / "config.yaml"
|
|
67
|
+
raw = local_cfg.read_text(encoding="utf-8") if local_cfg.exists() else default_config_text()
|
|
68
|
+
|
|
69
|
+
cfg = yaml.safe_load(raw)
|
|
70
|
+
cfg = copy.deepcopy(cfg)
|
|
71
|
+
root = resolve_out_dir(out_dir)
|
|
72
|
+
paths = cfg.setdefault("paths", {})
|
|
73
|
+
paths["sources_dir"] = root / "sources"
|
|
74
|
+
paths["chroma_dir"] = root / "chroma"
|
|
75
|
+
paths["bm25_path"] = root / "bm25" / "index.pkl"
|
|
76
|
+
paths["manifest_path"] = root / "manifest.json"
|
|
77
|
+
paths["model_cache_dir"] = root / "model-cache"
|
|
78
|
+
paths["out_dir"] = root
|
|
79
|
+
cfg["_out_dir"] = root
|
|
80
|
+
return cfg
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def ensure_data_dirs(cfg: dict[str, Any]) -> None:
|
|
84
|
+
paths = cfg["paths"]
|
|
85
|
+
for key in ("sources_dir", "chroma_dir", "model_cache_dir"):
|
|
86
|
+
Path(paths[key]).mkdir(parents=True, exist_ok=True)
|
|
87
|
+
Path(paths["bm25_path"]).parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
Path(paths["manifest_path"]).parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def write_default_config(out_dir: Path | None = None) -> Path:
|
|
92
|
+
root = resolve_out_dir(out_dir)
|
|
93
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
dest = root / "config.yaml"
|
|
95
|
+
if not dest.exists():
|
|
96
|
+
dest.write_text(default_config_text(), encoding="utf-8")
|
|
97
|
+
return dest
|
folio/distribute.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
import tarfile
|
|
7
|
+
import tempfile
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.request
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .config import ensure_data_dirs, resolve_out_dir, write_default_config
|
|
14
|
+
from .ingest import index_version_now, ingest
|
|
15
|
+
from .sync import sync_sources
|
|
16
|
+
|
|
17
|
+
# Files/dirs included in a portable index pack (query-ready without sources).
|
|
18
|
+
_PACK_MEMBERS = ("chroma", "bm25", "manifest.json", "config.yaml")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _release_url(cfg: dict[str, Any]) -> str:
|
|
22
|
+
release = cfg.get("release") or {}
|
|
23
|
+
return (release.get("index_url") or "").strip()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def pack_index(cfg: dict[str, Any], *, dest: Path | None = None) -> Path:
|
|
27
|
+
"""Create a tarball of the shared index for GitHub Release upload."""
|
|
28
|
+
out = Path(cfg["paths"]["out_dir"])
|
|
29
|
+
manifest_path = Path(cfg["paths"]["manifest_path"])
|
|
30
|
+
if not manifest_path.exists():
|
|
31
|
+
raise FileNotFoundError(f"No index to pack. Run `folio build` first ({manifest_path})")
|
|
32
|
+
|
|
33
|
+
data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
34
|
+
version = data.get("index_version") or index_version_now()
|
|
35
|
+
if dest is None:
|
|
36
|
+
dest = Path.cwd() / "dist" / f"folio-index-{version}.tar.gz"
|
|
37
|
+
dest = dest.expanduser().resolve()
|
|
38
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
|
|
40
|
+
with tarfile.open(dest, "w:gz") as tar:
|
|
41
|
+
for name in _PACK_MEMBERS:
|
|
42
|
+
path = out / name
|
|
43
|
+
if not path.exists():
|
|
44
|
+
raise FileNotFoundError(f"Missing pack member: {path}")
|
|
45
|
+
tar.add(path, arcname=name)
|
|
46
|
+
|
|
47
|
+
print(f"[pack] wrote {dest} (index_version={version})")
|
|
48
|
+
return dest
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _extract_pack(archive: Path, dest: Path) -> None:
|
|
52
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
with tarfile.open(archive, "r:gz") as tar:
|
|
54
|
+
# Python 3.12+ supports filter=; keep compatible extract.
|
|
55
|
+
tar.extractall(dest) # noqa: S202 — local/trusted release asset
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def bootstrap_index(
|
|
59
|
+
cfg: dict[str, Any],
|
|
60
|
+
*,
|
|
61
|
+
url: str | None = None,
|
|
62
|
+
archive: Path | None = None,
|
|
63
|
+
force: bool = False,
|
|
64
|
+
) -> Path:
|
|
65
|
+
"""Install a prebuilt index into FOLIO_HOME (~/.folio)."""
|
|
66
|
+
out = Path(cfg["paths"]["out_dir"])
|
|
67
|
+
manifest = Path(cfg["paths"]["manifest_path"])
|
|
68
|
+
if manifest.exists() and not force:
|
|
69
|
+
print(f"[bootstrap] index already present at {out} (use --force to replace)")
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
ensure_data_dirs(cfg)
|
|
73
|
+
write_default_config(out)
|
|
74
|
+
|
|
75
|
+
if archive is not None:
|
|
76
|
+
src = archive.expanduser().resolve()
|
|
77
|
+
if not src.exists():
|
|
78
|
+
raise FileNotFoundError(src)
|
|
79
|
+
print(f"[bootstrap] extracting {src} → {out}")
|
|
80
|
+
_extract_into_out(src, out)
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
resolved = (url or _release_url(cfg)).strip()
|
|
84
|
+
if not resolved:
|
|
85
|
+
raise RuntimeError(
|
|
86
|
+
"No release URL configured. Set release.index_url in config, "
|
|
87
|
+
"pass --url, or run `folio build` / `folio update --from-source`."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
with tempfile.TemporaryDirectory(prefix="folio-bootstrap-") as tmp:
|
|
91
|
+
tmp_path = Path(tmp) / "folio-index.tar.gz"
|
|
92
|
+
print(f"[bootstrap] downloading {resolved}")
|
|
93
|
+
try:
|
|
94
|
+
urllib.request.urlretrieve(resolved, tmp_path) # noqa: S310
|
|
95
|
+
except urllib.error.URLError as e:
|
|
96
|
+
raise RuntimeError(f"Failed to download index: {e}") from e
|
|
97
|
+
_extract_into_out(tmp_path, out)
|
|
98
|
+
|
|
99
|
+
print(f"[bootstrap] ready at {out}")
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _extract_into_out(archive: Path, out: Path) -> None:
|
|
104
|
+
with tempfile.TemporaryDirectory(prefix="folio-extract-") as tmp:
|
|
105
|
+
staging = Path(tmp) / "staging"
|
|
106
|
+
staging.mkdir()
|
|
107
|
+
_extract_pack(archive, staging)
|
|
108
|
+
for name in _PACK_MEMBERS:
|
|
109
|
+
src = staging / name
|
|
110
|
+
if not src.exists():
|
|
111
|
+
raise RuntimeError(f"Invalid index pack: missing {name}")
|
|
112
|
+
dest = out / name
|
|
113
|
+
if dest.exists():
|
|
114
|
+
if dest.is_dir():
|
|
115
|
+
shutil.rmtree(dest)
|
|
116
|
+
else:
|
|
117
|
+
dest.unlink()
|
|
118
|
+
if src.is_dir():
|
|
119
|
+
shutil.copytree(src, dest)
|
|
120
|
+
else:
|
|
121
|
+
shutil.copy2(src, dest)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def update_index(
|
|
125
|
+
cfg: dict[str, Any],
|
|
126
|
+
*,
|
|
127
|
+
from_source: bool = False,
|
|
128
|
+
url: str | None = None,
|
|
129
|
+
force: bool = True,
|
|
130
|
+
) -> int:
|
|
131
|
+
"""Refresh the shared index: Release pack by default, or rebuild from sources."""
|
|
132
|
+
ensure_data_dirs(cfg)
|
|
133
|
+
write_default_config(cfg["paths"]["out_dir"])
|
|
134
|
+
|
|
135
|
+
if from_source:
|
|
136
|
+
print("[update] rebuilding from curated sources…")
|
|
137
|
+
sync_sources(cfg, force=False)
|
|
138
|
+
ingest(cfg, rebuild=True)
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
bootstrap_index(cfg, url=url, force=force)
|
|
143
|
+
return 0
|
|
144
|
+
except RuntimeError as e:
|
|
145
|
+
print(f"[update] bootstrap unavailable: {e}", file=sys.stderr)
|
|
146
|
+
print("[update] falling back to --from-source build…", file=sys.stderr)
|
|
147
|
+
sync_sources(cfg, force=False)
|
|
148
|
+
ingest(cfg, rebuild=True)
|
|
149
|
+
return 0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def migrate_legacy_index(legacy: Path | None = None) -> Path | None:
|
|
153
|
+
"""One-shot: move Desktop/aieng/aieng-out → ~/.folio if needed."""
|
|
154
|
+
dest = resolve_out_dir()
|
|
155
|
+
if (dest / "manifest.json").exists():
|
|
156
|
+
return dest
|
|
157
|
+
|
|
158
|
+
candidates = []
|
|
159
|
+
if legacy:
|
|
160
|
+
candidates.append(legacy.expanduser())
|
|
161
|
+
candidates.extend(
|
|
162
|
+
[
|
|
163
|
+
Path.home() / "Desktop" / "aieng" / "aieng-out",
|
|
164
|
+
Path.home() / "Desktop" / "folio" / "aieng-out",
|
|
165
|
+
Path.home() / "Desktop" / "folio" / "folio-out",
|
|
166
|
+
Path(__file__).resolve().parents[2] / "aieng-out",
|
|
167
|
+
]
|
|
168
|
+
)
|
|
169
|
+
for src in candidates:
|
|
170
|
+
if (src / "manifest.json").exists():
|
|
171
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
if dest.exists():
|
|
173
|
+
shutil.rmtree(dest)
|
|
174
|
+
shutil.move(str(src), str(dest))
|
|
175
|
+
print(f"[migrate] moved {src} → {dest}")
|
|
176
|
+
return dest
|
|
177
|
+
return None
|
folio/ingest.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from tqdm import tqdm
|
|
8
|
+
|
|
9
|
+
from . import __version__ as cli_version
|
|
10
|
+
from .chunk import Chunk, chunk_file
|
|
11
|
+
from .config import ensure_data_dirs
|
|
12
|
+
from .store import build_bm25, reset_collection, save_bm25, upsert_chunks
|
|
13
|
+
from .sync import iter_markdown
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def index_version_now() -> str:
|
|
17
|
+
return datetime.now(timezone.utc).strftime("%Y.%m.%d")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def ingest(cfg: dict[str, Any], *, rebuild: bool = True) -> dict[str, Any]:
|
|
21
|
+
ensure_data_dirs(cfg)
|
|
22
|
+
files = iter_markdown(cfg)
|
|
23
|
+
if not files:
|
|
24
|
+
raise RuntimeError("No markdown files found. Run sync first.")
|
|
25
|
+
|
|
26
|
+
chunk_cfg = cfg["chunking"]
|
|
27
|
+
all_chunks: list[Chunk] = []
|
|
28
|
+
sources_dir = cfg["paths"]["sources_dir"]
|
|
29
|
+
|
|
30
|
+
for source_id, path in tqdm(files, desc="chunking"):
|
|
31
|
+
chunks = chunk_file(
|
|
32
|
+
path,
|
|
33
|
+
source_id=source_id,
|
|
34
|
+
sources_root=sources_dir / source_id,
|
|
35
|
+
max_chars=int(chunk_cfg["max_chars"]),
|
|
36
|
+
overlap_chars=int(chunk_cfg["overlap_chars"]),
|
|
37
|
+
min_chars=int(chunk_cfg["min_chars"]),
|
|
38
|
+
)
|
|
39
|
+
all_chunks.extend(chunks)
|
|
40
|
+
|
|
41
|
+
collection = reset_collection(cfg) if rebuild else reset_collection(cfg)
|
|
42
|
+
n = upsert_chunks(collection, all_chunks, batch_size=int(cfg["embedding"]["batch_size"]))
|
|
43
|
+
bm25, ids, chunks = build_bm25(all_chunks)
|
|
44
|
+
save_bm25(cfg["paths"]["bm25_path"], bm25, ids, chunks)
|
|
45
|
+
|
|
46
|
+
index_version = index_version_now()
|
|
47
|
+
manifest = {
|
|
48
|
+
"index_version": index_version,
|
|
49
|
+
"cli_version": cli_version,
|
|
50
|
+
"ingested_at": datetime.now(timezone.utc).isoformat(),
|
|
51
|
+
"files": len(files),
|
|
52
|
+
"chunks": n,
|
|
53
|
+
"embedding_model": cfg["embedding"]["model"],
|
|
54
|
+
"collection": cfg["collection"]["name"],
|
|
55
|
+
"sources": [
|
|
56
|
+
{
|
|
57
|
+
"id": s["id"],
|
|
58
|
+
"repo": s["repo"],
|
|
59
|
+
"sparse_path": s["sparse_path"],
|
|
60
|
+
}
|
|
61
|
+
for s in cfg["sources"]
|
|
62
|
+
],
|
|
63
|
+
"file_list": [
|
|
64
|
+
{"source_id": sid, "path": str(p.relative_to(sources_dir))}
|
|
65
|
+
for sid, p in files
|
|
66
|
+
],
|
|
67
|
+
}
|
|
68
|
+
cfg["paths"]["manifest_path"].write_text(
|
|
69
|
+
json.dumps(manifest, indent=2, ensure_ascii=False),
|
|
70
|
+
encoding="utf-8",
|
|
71
|
+
)
|
|
72
|
+
print(
|
|
73
|
+
f"[ingest] index_version={index_version} files={len(files)} "
|
|
74
|
+
f"chunks={n} chroma={cfg['paths']['chroma_dir']}"
|
|
75
|
+
)
|
|
76
|
+
return manifest
|
folio/install.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .config import package_dir
|
|
8
|
+
|
|
9
|
+
TOOL = "folio"
|
|
10
|
+
_CURSOR_RULE_PATH = Path(".cursor") / "rules" / "folio.mdc"
|
|
11
|
+
_AGENTS_MD_MARKER = "# folio"
|
|
12
|
+
|
|
13
|
+
_CURSOR_RULE = """\
|
|
14
|
+
---
|
|
15
|
+
description: folio curated AI / MLOps / infra knowledge RAG
|
|
16
|
+
alwaysApply: true
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
This project uses the standalone **folio** tool (shared curricula RAG on the machine).
|
|
20
|
+
|
|
21
|
+
**MANDATORY: Before deciding or implementing AI engineering / MLOps / RAG / agent / GPU / AI infra / related AI architecture, you MUST run folio first:**
|
|
22
|
+
- `folio query "<question>"` — hybrid retrieve grounded citations
|
|
23
|
+
- `folio query "<question>" --json` — machine-readable hits for agents
|
|
24
|
+
- `folio status` — check whether the shared index exists (`~/.folio` or `$FOLIO_HOME`)
|
|
25
|
+
|
|
26
|
+
This applies to YOU and to every subagent you spawn for those topics. Do not skip folio because the topic “feels familiar” — the index surfaces curriculum trade-offs (hybrid RAG vs RAGless, CRAG, RRF, etc.) that training memory alone will miss.
|
|
27
|
+
|
|
28
|
+
**Token rule (strict):** Do **not** WebFetch, clone, or paste the curated curriculum GitHub repos into chat context. Those sources are already indexed. Retrieve with `folio query` only. If the index is missing, tell the user to run `folio bootstrap` or `folio build` / `folio update` — do not dump raw markdown into the conversation.
|
|
29
|
+
|
|
30
|
+
Only skip folio when:
|
|
31
|
+
1. The task is ordinary product/codebase work with no AI / MLOps / AI-infra architecture decision
|
|
32
|
+
2. The shared index is missing — then instruct `folio bootstrap` (or `folio build`) once
|
|
33
|
+
|
|
34
|
+
After `folio query`, ground choices in returned `source_id` + `rel_path` + `heading_path` citations.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
_CLAUDE_REGISTRATION = """\
|
|
38
|
+
# folio
|
|
39
|
+
|
|
40
|
+
Standalone tool: local hybrid RAG for AI engineering / MLOps / AI infra decisions.
|
|
41
|
+
Shared index: `~/.folio/` (override with `FOLIO_HOME`).
|
|
42
|
+
|
|
43
|
+
Before AI-eng / MLOps / RAG / agent / AI-infra work, run:
|
|
44
|
+
`folio query "<question>" --json`
|
|
45
|
+
|
|
46
|
+
Do **not** fetch the curriculum GitHub repos into chat — query the local index only.
|
|
47
|
+
|
|
48
|
+
Skill: `.claude/skills/folio/SKILL.md` / `.agents/skills/folio/SKILL.md`.
|
|
49
|
+
If index missing: `folio bootstrap` or `folio build`. Refresh: `folio update`.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
_PLATFORM_SKILL_DST = {
|
|
53
|
+
"claude": Path(".claude") / "skills" / "folio" / "SKILL.md",
|
|
54
|
+
"codex": Path(".codex") / "skills" / "folio" / "SKILL.md",
|
|
55
|
+
"agents": Path(".agents") / "skills" / "folio" / "SKILL.md",
|
|
56
|
+
"cursor": None,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _skill_src() -> Path:
|
|
61
|
+
src = package_dir() / "assets" / "SKILL.md"
|
|
62
|
+
if not src.exists():
|
|
63
|
+
raise FileNotFoundError(f"Missing packaged skill: {src}")
|
|
64
|
+
return src
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _write_skill(dst: Path) -> None:
|
|
68
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
text = _skill_src().read_text(encoding="utf-8")
|
|
70
|
+
if dst.exists() and dst.read_text(encoding="utf-8") == text:
|
|
71
|
+
print(f"folio skill already configured at {dst.resolve()} (no change)")
|
|
72
|
+
return
|
|
73
|
+
action = "updated" if dst.exists() else "written"
|
|
74
|
+
dst.write_text(text, encoding="utf-8")
|
|
75
|
+
print(f"folio skill {action} at {dst.resolve()}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _cursor_install(project_dir: Path) -> None:
|
|
79
|
+
rule_path = project_dir / _CURSOR_RULE_PATH
|
|
80
|
+
rule_path.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
if rule_path.exists() and rule_path.read_text(encoding="utf-8") == _CURSOR_RULE:
|
|
82
|
+
print(f"folio Cursor rule at {rule_path.resolve()} already configured (no change)")
|
|
83
|
+
return
|
|
84
|
+
action = "updated" if rule_path.exists() else "written"
|
|
85
|
+
rule_path.write_text(_CURSOR_RULE, encoding="utf-8")
|
|
86
|
+
print(f"folio Cursor rule {action} at {rule_path.resolve()}")
|
|
87
|
+
print("Cursor will retrieve-first via folio for AI / MLOps / infra tasks.")
|
|
88
|
+
print("Shared index: ~/.folio — run `folio bootstrap` or `folio build` if missing.")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _cursor_uninstall(project_dir: Path) -> None:
|
|
92
|
+
rule_path = project_dir / _CURSOR_RULE_PATH
|
|
93
|
+
# Also remove legacy aieng rule if present
|
|
94
|
+
legacy = project_dir / ".cursor" / "rules" / "aieng.mdc"
|
|
95
|
+
removed_any = False
|
|
96
|
+
for path in (rule_path, legacy):
|
|
97
|
+
if path.exists():
|
|
98
|
+
path.unlink()
|
|
99
|
+
print(f"removed {path.resolve()}")
|
|
100
|
+
removed_any = True
|
|
101
|
+
if not removed_any:
|
|
102
|
+
print("No folio Cursor rule found — nothing to do")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _replace_or_append_section(content: str, marker: str, section: str) -> str:
|
|
106
|
+
lines = content.splitlines()
|
|
107
|
+
start = None
|
|
108
|
+
for i, line in enumerate(lines):
|
|
109
|
+
if line.strip() == marker:
|
|
110
|
+
start = i
|
|
111
|
+
break
|
|
112
|
+
if start is None:
|
|
113
|
+
# Drop legacy aieng section if present
|
|
114
|
+
content = _strip_marker_section(content, "# aieng")
|
|
115
|
+
body = content.rstrip()
|
|
116
|
+
if body:
|
|
117
|
+
return body + "\n\n" + section.strip() + "\n"
|
|
118
|
+
return section.strip() + "\n"
|
|
119
|
+
|
|
120
|
+
end = len(lines)
|
|
121
|
+
for j in range(start + 1, len(lines)):
|
|
122
|
+
if lines[j].startswith("# "):
|
|
123
|
+
end = j
|
|
124
|
+
break
|
|
125
|
+
new_lines = lines[:start] + section.strip().splitlines() + ([""] if end < len(lines) else []) + lines[end:]
|
|
126
|
+
return "\n".join(new_lines).rstrip() + "\n"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _strip_marker_section(content: str, marker: str) -> str:
|
|
130
|
+
lines = content.splitlines()
|
|
131
|
+
start = None
|
|
132
|
+
for i, line in enumerate(lines):
|
|
133
|
+
if line.strip() == marker:
|
|
134
|
+
start = i
|
|
135
|
+
break
|
|
136
|
+
if start is None:
|
|
137
|
+
return content
|
|
138
|
+
end = len(lines)
|
|
139
|
+
for j in range(start + 1, len(lines)):
|
|
140
|
+
if lines[j].startswith("# "):
|
|
141
|
+
end = j
|
|
142
|
+
break
|
|
143
|
+
new_lines = lines[:start] + lines[end:]
|
|
144
|
+
return "\n".join(new_lines).rstrip() + ("\n" if new_lines else "")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _claude_md_register(project_dir: Path | None, user_global: bool) -> None:
|
|
148
|
+
if user_global:
|
|
149
|
+
target = Path.home() / ".claude" / "CLAUDE.md"
|
|
150
|
+
else:
|
|
151
|
+
assert project_dir is not None
|
|
152
|
+
target = project_dir / "CLAUDE.md"
|
|
153
|
+
try:
|
|
154
|
+
existed = target.exists()
|
|
155
|
+
content = target.read_text(encoding="utf-8") if existed else ""
|
|
156
|
+
content = _strip_marker_section(content, "# aieng")
|
|
157
|
+
new_content = _replace_or_append_section(content, _AGENTS_MD_MARKER, _CLAUDE_REGISTRATION)
|
|
158
|
+
if existed and new_content == content:
|
|
159
|
+
print(f"folio already registered in {target} (no change)")
|
|
160
|
+
return
|
|
161
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
target.write_text(new_content, encoding="utf-8")
|
|
163
|
+
print(f"folio registered in {target.resolve()}")
|
|
164
|
+
except OSError as exc:
|
|
165
|
+
print(f"skipped CLAUDE.md registration: {exc}", file=sys.stderr)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _agents_md_register(project_dir: Path) -> None:
|
|
169
|
+
target = project_dir / "AGENTS.md"
|
|
170
|
+
try:
|
|
171
|
+
existed = target.exists()
|
|
172
|
+
content = target.read_text(encoding="utf-8") if existed else ""
|
|
173
|
+
content = _strip_marker_section(content, "# aieng")
|
|
174
|
+
new_content = _replace_or_append_section(content, _AGENTS_MD_MARKER, _CLAUDE_REGISTRATION)
|
|
175
|
+
if existed and new_content == content:
|
|
176
|
+
print(f"folio already registered in {target} (no change)")
|
|
177
|
+
return
|
|
178
|
+
target.write_text(new_content, encoding="utf-8")
|
|
179
|
+
print(f"folio registered in {target.resolve()}")
|
|
180
|
+
except OSError as exc:
|
|
181
|
+
print(f"skipped AGENTS.md registration: {exc}", file=sys.stderr)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _skill_dst_for(platform: str, *, project: bool, project_dir: Path) -> Path | None:
|
|
185
|
+
rel = _PLATFORM_SKILL_DST.get(platform)
|
|
186
|
+
if rel is None:
|
|
187
|
+
return None
|
|
188
|
+
if project:
|
|
189
|
+
return project_dir / rel
|
|
190
|
+
return Path.home() / rel
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def install(
|
|
194
|
+
*,
|
|
195
|
+
platform: str = "cursor",
|
|
196
|
+
project: bool = False,
|
|
197
|
+
project_dir: Path | None = None,
|
|
198
|
+
) -> int:
|
|
199
|
+
project_dir = (project_dir or Path(".")).resolve()
|
|
200
|
+
platform = platform.lower().strip()
|
|
201
|
+
if platform == "skills":
|
|
202
|
+
platform = "agents"
|
|
203
|
+
|
|
204
|
+
known = {"cursor", "claude", "codex", "agents"}
|
|
205
|
+
if platform not in known:
|
|
206
|
+
print(
|
|
207
|
+
f"error: unknown platform '{platform}'. Choose from: {', '.join(sorted(known))}",
|
|
208
|
+
file=sys.stderr,
|
|
209
|
+
)
|
|
210
|
+
return 2
|
|
211
|
+
|
|
212
|
+
if platform == "cursor":
|
|
213
|
+
_cursor_install(project_dir)
|
|
214
|
+
if project:
|
|
215
|
+
_write_skill(project_dir / ".agents" / "skills" / "folio" / "SKILL.md")
|
|
216
|
+
# remove legacy skill dir
|
|
217
|
+
legacy_skill = project_dir / ".agents" / "skills" / "aieng"
|
|
218
|
+
if legacy_skill.exists():
|
|
219
|
+
shutil.rmtree(legacy_skill)
|
|
220
|
+
print(f"removed legacy {legacy_skill}")
|
|
221
|
+
_agents_md_register(project_dir)
|
|
222
|
+
return 0
|
|
223
|
+
|
|
224
|
+
dst = _skill_dst_for(platform, project=project, project_dir=project_dir)
|
|
225
|
+
assert dst is not None
|
|
226
|
+
_write_skill(dst)
|
|
227
|
+
|
|
228
|
+
if platform == "claude":
|
|
229
|
+
_claude_md_register(project_dir if project else None, user_global=not project)
|
|
230
|
+
if platform == "agents" and project:
|
|
231
|
+
_agents_md_register(project_dir)
|
|
232
|
+
|
|
233
|
+
print()
|
|
234
|
+
print("Next: `folio bootstrap` (or `folio build`), then `folio query \"...\"`.")
|
|
235
|
+
return 0
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def uninstall(
|
|
239
|
+
*,
|
|
240
|
+
platform: str | None = None,
|
|
241
|
+
project: bool = False,
|
|
242
|
+
project_dir: Path | None = None,
|
|
243
|
+
purge: bool = False,
|
|
244
|
+
) -> int:
|
|
245
|
+
project_dir = (project_dir or Path(".")).resolve()
|
|
246
|
+
platforms = (
|
|
247
|
+
[platform.lower()]
|
|
248
|
+
if platform
|
|
249
|
+
else ["cursor", "claude", "codex", "agents"]
|
|
250
|
+
)
|
|
251
|
+
for p in platforms:
|
|
252
|
+
if p == "skills":
|
|
253
|
+
p = "agents"
|
|
254
|
+
if p == "cursor":
|
|
255
|
+
_cursor_uninstall(project_dir)
|
|
256
|
+
for name in ("folio", "aieng"):
|
|
257
|
+
skill = project_dir / ".agents" / "skills" / name
|
|
258
|
+
if skill.exists():
|
|
259
|
+
shutil.rmtree(skill)
|
|
260
|
+
print(f"removed {skill}")
|
|
261
|
+
continue
|
|
262
|
+
dst = _skill_dst_for(p, project=project, project_dir=project_dir)
|
|
263
|
+
if dst and dst.exists():
|
|
264
|
+
skill_dir = dst.parent
|
|
265
|
+
shutil.rmtree(skill_dir)
|
|
266
|
+
print(f"removed {skill_dir}")
|
|
267
|
+
elif not project and p in _PLATFORM_SKILL_DST and _PLATFORM_SKILL_DST[p]:
|
|
268
|
+
user_dst = Path.home() / _PLATFORM_SKILL_DST[p] # type: ignore[operator]
|
|
269
|
+
if user_dst.exists():
|
|
270
|
+
shutil.rmtree(user_dst.parent)
|
|
271
|
+
print(f"removed {user_dst.parent}")
|
|
272
|
+
|
|
273
|
+
if purge:
|
|
274
|
+
for name in ("folio-out", "aieng-out"):
|
|
275
|
+
out = project_dir / name
|
|
276
|
+
if out.exists():
|
|
277
|
+
shutil.rmtree(out)
|
|
278
|
+
print(f"purged {out}")
|
|
279
|
+
return 0
|