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/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