flet-mcp 0.86.0.dev1__tar.gz → 0.86.0.dev2__tar.gz

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.
Files changed (28) hide show
  1. {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/PKG-INFO +13 -4
  2. {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/README.md +10 -0
  3. {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/pyproject.toml +3 -4
  4. flet_mcp-0.86.0.dev2/src/flet_mcp/api_store.py +611 -0
  5. flet_mcp-0.86.0.dev2/src/flet_mcp/build/icons.py +74 -0
  6. {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/src/flet_mcp/build/indexer.py +4 -0
  7. {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/src/flet_mcp/data/api.json +13 -7
  8. flet_mcp-0.86.0.dev2/src/flet_mcp/data/icons.json +84638 -0
  9. {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/src/flet_mcp/data/mcp.db +0 -0
  10. flet_mcp-0.86.0.dev2/src/flet_mcp/icons_store.py +141 -0
  11. {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/src/flet_mcp/server.py +145 -33
  12. {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/src/flet_mcp.egg-info/PKG-INFO +13 -4
  13. {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/src/flet_mcp.egg-info/SOURCES.txt +5 -2
  14. {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/src/flet_mcp.egg-info/requires.txt +2 -3
  15. flet_mcp-0.86.0.dev2/tests/test_api_store.py +413 -0
  16. flet_mcp-0.86.0.dev2/tests/test_icons_store.py +113 -0
  17. flet_mcp-0.86.0.dev1/src/flet_mcp/api_store.py +0 -159
  18. flet_mcp-0.86.0.dev1/src/flet_mcp/data/icons.yml +0 -2097
  19. flet_mcp-0.86.0.dev1/src/flet_mcp/icons_store.py +0 -138
  20. {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/setup.cfg +0 -0
  21. {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/src/flet_mcp/__init__.py +0 -0
  22. {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/src/flet_mcp/build/__init__.py +0 -0
  23. {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/src/flet_mcp/build/api.py +0 -0
  24. {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/src/flet_mcp/build/docs.py +0 -0
  25. {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/src/flet_mcp/build/examples.py +0 -0
  26. {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/src/flet_mcp/db.py +0 -0
  27. {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/src/flet_mcp.egg-info/dependency_links.txt +0 -0
  28. {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/src/flet_mcp.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flet-mcp
3
- Version: 0.86.0.dev1
3
+ Version: 0.86.0.dev2
4
4
  Summary: Flet MCP server for LLM agents
5
5
  Author-email: "Appveyor Systems Inc." <hello@flet.dev>
6
6
  License-Expression: Apache-2.0
@@ -10,12 +10,11 @@ Project-URL: Documentation, https://flet.dev/docs
10
10
  Requires-Python: >=3.10
11
11
  Description-Content-Type: text/markdown
12
12
  Requires-Dist: fastmcp>=2.0.0
13
- Requires-Dist: pyyaml>=6.0
14
13
  Provides-Extra: build
15
14
  Requires-Dist: markdownify>=0.14.1; extra == "build"
16
15
  Requires-Dist: griffe>=1.6.2; extra == "build"
17
- Requires-Dist: flet==0.86.0.dev1; extra == "build"
18
- Requires-Dist: flet-cli==0.86.0.dev1; extra == "build"
16
+ Requires-Dist: flet==0.86.0.dev2; extra == "build"
17
+ Requires-Dist: flet-cli==0.86.0.dev2; extra == "build"
19
18
 
20
19
  # flet-mcp
21
20
 
@@ -43,6 +42,16 @@ uv run flet mcp build # api.json only
43
42
  uv run flet mcp build --examples ./examples # add examples index
44
43
  ```
45
44
 
45
+ The icon search metadata (`data/icons.json`) is a *committed* file, not a
46
+ build product — it is generated from Google's fonts.google.com icon
47
+ metadata (names, synonym tags, popularity; Apache-2.0, the same data that
48
+ powers the fonts.google.com icon search) and only changes when Google
49
+ ships new icons. Refresh it with:
50
+
51
+ ```bash
52
+ uv run python -m flet_mcp.build.icons
53
+ ```
54
+
46
55
  > **Docs index is currently deferred.** The `--docs` flag still expects a mkdocs
47
56
  > `search_index.json`, which the site no longer produces after the migration to
48
57
  > Docusaurus + Algolia. The `DOCS` tool group stays off by default; rebuilding
@@ -24,6 +24,16 @@ uv run flet mcp build # api.json only
24
24
  uv run flet mcp build --examples ./examples # add examples index
25
25
  ```
26
26
 
27
+ The icon search metadata (`data/icons.json`) is a *committed* file, not a
28
+ build product — it is generated from Google's fonts.google.com icon
29
+ metadata (names, synonym tags, popularity; Apache-2.0, the same data that
30
+ powers the fonts.google.com icon search) and only changes when Google
31
+ ships new icons. Refresh it with:
32
+
33
+ ```bash
34
+ uv run python -m flet_mcp.build.icons
35
+ ```
36
+
27
37
  > **Docs index is currently deferred.** The `--docs` flag still expects a mkdocs
28
38
  > `search_index.json`, which the site no longer produces after the migration to
29
39
  > Docusaurus + Algolia. The `DOCS` tool group stays off by default; rebuilding
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flet-mcp"
3
- version = "0.86.0.dev1"
3
+ version = "0.86.0.dev2"
4
4
  description = "Flet MCP server for LLM agents"
5
5
  authors = [{ name = "Appveyor Systems Inc.", email = "hello@flet.dev" }]
6
6
  license = "Apache-2.0"
@@ -8,15 +8,14 @@ readme = "README.md"
8
8
  requires-python = ">=3.10"
9
9
  dependencies = [
10
10
  "fastmcp >=2.0.0",
11
- "pyyaml >=6.0",
12
11
  ]
13
12
 
14
13
  [project.optional-dependencies]
15
14
  build = [
16
15
  "markdownify >=0.14.1",
17
16
  "griffe >=1.6.2",
18
- "flet==0.86.0.dev1",
19
- "flet-cli==0.86.0.dev1",
17
+ "flet==0.86.0.dev2",
18
+ "flet-cli==0.86.0.dev2",
20
19
  ]
21
20
 
22
21
  [project.urls]
@@ -0,0 +1,611 @@
1
+ """Load and query the Griffe-generated API reference (api.json)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.resources
6
+ import json
7
+ import re
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ # Member docstrings are trimmed to their first sentence in listings (full
12
+ # docstrings often embed multi-paragraph examples — Page's methods alone
13
+ # carry ~9k chars of doc text). The full text stays reachable per member
14
+ # via `get(name, member=...)`.
15
+ _SUMMARY_MAX_CHARS = 160
16
+
17
+ # Controls use properties/events/methods; dataclass types and event
18
+ # classes keep their members under "fields" (same entry shape).
19
+ _MEMBER_SECTIONS = ("properties", "fields", "events", "methods")
20
+
21
+ _MEMBER_KIND = {
22
+ "properties": "property",
23
+ "fields": "field",
24
+ "events": "event",
25
+ "methods": "method",
26
+ }
27
+
28
+ # Buckets in preference order for ambiguous names, and the `kind` value
29
+ # stamped onto entries from buckets that don't carry their own.
30
+ _BUCKET_RANK = {"controls": 0, "types": 1, "events": 2, "enums": 3}
31
+ _BUCKET_KIND = {"types": "type", "events": "event"}
32
+
33
+ # Sphinx roles in docstrings (":attr:`style`", ":class:`~flet.Text`") are
34
+ # docs-site markup — render them as plain backticked names.
35
+ _RST_ROLE = re.compile(r":\w+(?::\w+)?:`~?([^`]+)`")
36
+
37
+ # Material icon style-variant suffixes: collapsed in member search so
38
+ # ADD / ADD_OUTLINED / ADD_ROUNDED / ADD_SHARP don't spend four result
39
+ # slots on one icon.
40
+ _STYLE_SUFFIXES = ("_OUTLINED", "_ROUNDED", "_SHARP")
41
+
42
+
43
+ def clean_rst(text: str) -> str:
44
+ return _RST_ROLE.sub(r"`\1`", text)
45
+
46
+
47
+ def first_sentence(text: str, limit: int = _SUMMARY_MAX_CHARS) -> str:
48
+ """First sentence (or line) of a docstring, capped at `limit` chars."""
49
+ if not text:
50
+ return text
51
+ text = clean_rst(text)
52
+ head = text.split("\n\n", 1)[0].split("\n", 1)[0]
53
+ dot = head.find(". ")
54
+ if dot != -1:
55
+ head = head[: dot + 1]
56
+ if len(head) > limit:
57
+ head = head[: limit - 1].rstrip() + "…"
58
+ return head
59
+
60
+
61
+ def _compact_member(entry: dict[str, Any]) -> dict[str, Any]:
62
+ doc = entry.get("docstring")
63
+ if not doc:
64
+ return entry
65
+ summary = first_sentence(doc)
66
+ if summary == doc:
67
+ return entry
68
+ return {**entry, "docstring": summary}
69
+
70
+
71
+ # ── Text rendering ──────────────────────────────────────────────────────
72
+ #
73
+ # The default `get_api` response is signature-style text, not JSON: one
74
+ # line per member. Same information, ~44% fewer chars and far fewer
75
+ # tokens than the JSON encoding (keys/quoting repeat per member).
76
+ # `format="json"` on the tool returns the dict shape instead.
77
+
78
+
79
+ def _optional_inner(type_str: str) -> str | None:
80
+ """`Optional[X]` -> `X`, else None."""
81
+ if type_str.startswith("Optional[") and type_str.endswith("]"):
82
+ return type_str[len("Optional[") : -1]
83
+ return None
84
+
85
+
86
+ def _event_payload(type_str: str) -> str | None:
87
+ """The event object type a handler receives, or None if the type
88
+ string doesn't match the known handler shapes (caller falls back to
89
+ the raw string — never guess):
90
+
91
+ * `Optional[ControlEventHandler[X]]` -> `ControlEvent`
92
+ * `Optional[EventHandler[XEvent]]` -> `XEvent`
93
+ * `Optional[EventHandler[XEvent[Subject]]]` -> `XEvent`
94
+ """
95
+ s = _optional_inner(type_str) or type_str
96
+ wrapper, bracket, rest = s.partition("[")
97
+ if not bracket or not rest.endswith("]"):
98
+ return None
99
+ wrapper = wrapper.removeprefix("ft.")
100
+ inner = rest[:-1]
101
+ if wrapper == "ControlEventHandler":
102
+ return "ControlEvent"
103
+ if wrapper == "EventHandler":
104
+ return inner.split("[", 1)[0]
105
+ return None
106
+
107
+
108
+ def _fmt_default(default: Any) -> str | None:
109
+ if default in (None, "None", ""):
110
+ return None
111
+ text = str(default)
112
+ # Unwrap dataclass `field(...)` declarations — the metadata is
113
+ # serialization plumbing, only the actual default value informs.
114
+ if text.startswith("field("):
115
+ if m := re.search(r"default_factory=(\w+)", text):
116
+ factory = m.group(1)
117
+ return {"list": "[]", "dict": "{}"}.get(factory, f"{factory}()")
118
+ if m := re.search(r"default=([^,)]+)", text):
119
+ return _fmt_default(m.group(1).strip())
120
+ return None
121
+ return text
122
+
123
+
124
+ def _member_line(section: str, entry: dict[str, Any]) -> str:
125
+ """One text line for a property/event/method entry (docstring already
126
+ compacted by `_compact_member`)."""
127
+ name = entry.get("name", "")
128
+ if section == "methods":
129
+ params = ", ".join(p.get("name", "") for p in entry.get("params") or [])
130
+ sig = f"{name}({params})"
131
+ ret = entry.get("return_type")
132
+ if ret:
133
+ sig += f" -> {ret}"
134
+ if entry.get("async"):
135
+ sig = "async " + sig
136
+ elif section == "events":
137
+ payload = _event_payload(entry.get("type", ""))
138
+ sig = f"{name}({payload})" if payload else f"{name}: {entry.get('type', '')}"
139
+ else:
140
+ type_str = entry.get("type", "")
141
+ inner = _optional_inner(type_str)
142
+ sig = f"{name}: {inner}?" if inner else f"{name}: {type_str}"
143
+ default = _fmt_default(entry.get("default"))
144
+ if default:
145
+ sig += f" = {default}"
146
+ dep = entry.get("deprecated")
147
+ if dep:
148
+ reason = dep.get("reason") if isinstance(dep, dict) else str(dep)
149
+ sig += f" [DEPRECATED: {reason}]"
150
+ doc = entry.get("docstring")
151
+ if doc:
152
+ sig += f" — {doc}"
153
+ return sig
154
+
155
+
156
+ def render_text(hit: dict[str, Any]) -> str:
157
+ """Render a `get()` result as signature-style text (see the `get_api`
158
+ tool docstring for the layout conventions the model relies on)."""
159
+ name = hit.get("name", "")
160
+
161
+ # Single-member drill-down (`get_api(name, member=...)`).
162
+ if "member" in hit and "member_kind" in hit:
163
+ entry = hit["member"]
164
+ section = next(
165
+ (s for s, k in _MEMBER_KIND.items() if k == hit["member_kind"]),
166
+ "properties",
167
+ )
168
+ lines = [f"{name}.{entry.get('name')} ({hit['member_kind']})"]
169
+ lines.append(_member_line(section, {**entry, "docstring": None}))
170
+ if entry.get("type"):
171
+ lines.append(f"type: {entry['type']}")
172
+ if entry.get("docstring"):
173
+ lines.append(clean_rst(entry["docstring"]))
174
+ return "\n".join(lines)
175
+
176
+ lines = []
177
+ header = f"{name} ({hit.get('kind', '')})"
178
+ summary = hit.get("summary") or first_sentence(hit.get("docstring") or "")
179
+ if summary:
180
+ header += f" — {clean_rst(summary)}"
181
+ lines.append(header)
182
+ dep = hit.get("deprecated")
183
+ if dep:
184
+ reason = dep.get("reason") if isinstance(dep, dict) else str(dep)
185
+ lines.append(f"DEPRECATED: {reason}")
186
+ pkg = hit.get("package")
187
+ if pkg and pkg != "flet":
188
+ lines.append(f"package: {pkg} (add this pip package to the project)")
189
+ if hit.get("bases"):
190
+ lines.append("bases: " + ", ".join(hit["bases"]))
191
+ if hit.get("note"):
192
+ lines.append(f"note: {hit['note']}")
193
+
194
+ if hit.get("kind") == "large_enum":
195
+ lines.append(f"total members: {hit.get('total_members')}")
196
+ samples = [
197
+ m.get("name") if isinstance(m, dict) else str(m)
198
+ for m in hit.get("sample_members") or []
199
+ ]
200
+ lines.append("sample members: " + ", ".join(samples))
201
+ elif hit.get("members") is not None:
202
+ lines.append("members:")
203
+ for m in hit.get("members") or []:
204
+ if isinstance(m, dict):
205
+ line = f" {m.get('name')} = {m.get('value')}"
206
+ if m.get("docstring"):
207
+ line += f" — {first_sentence(m['docstring'])}"
208
+ lines.append(line)
209
+ else:
210
+ lines.append(f" {m}")
211
+
212
+ for section in _MEMBER_SECTIONS:
213
+ entries = hit.get(section)
214
+ if not entries:
215
+ continue
216
+ lines.append(f"{section}:")
217
+ for entry in entries:
218
+ lines.append(" " + _member_line(section, entry))
219
+
220
+ # Inherited matches from a query= base-class walk.
221
+ for block in hit.get("inherited") or []:
222
+ lines.append(f"from {block.get('from')}:")
223
+ for section in _MEMBER_SECTIONS:
224
+ for entry in block.get(section) or []:
225
+ lines.append(" " + _member_line(section, entry))
226
+ return "\n".join(lines)
227
+
228
+
229
+ class ApiStore:
230
+ """Lazy-loading store for the bundled api.json data.
231
+
232
+ The api.json file uses lists for controls/events/types/enums,
233
+ each entry having a "name" key. This store builds name-keyed dicts
234
+ for fast lookup.
235
+ """
236
+
237
+ def __init__(self) -> None:
238
+ self._raw: dict[str, Any] | None = None
239
+ self._controls: dict[str, dict] | None = None
240
+ self._events: dict[str, dict] | None = None
241
+ self._types: dict[str, dict] | None = None
242
+ self._enums: dict[str, dict] | None = None
243
+ self._by_name: dict[str, list[tuple[str, dict]]] | None = None
244
+
245
+ def _load(self) -> dict[str, Any]:
246
+ if self._raw is None:
247
+ ref = importlib.resources.files("flet_mcp").joinpath("data/api.json")
248
+ self._raw = json.loads(Path(str(ref)).read_text(encoding="utf-8"))
249
+ # Build name-keyed dicts from lists
250
+ self._controls = {c["name"]: c for c in self._raw.get("controls", [])}
251
+ self._events = {e["name"]: e for e in self._raw.get("events", [])}
252
+ self._types = {t["name"]: t for t in self._raw.get("types", [])}
253
+ self._enums = {e["name"]: e for e in self._raw.get("enums", [])}
254
+ # Names are NOT unique across (or even within) buckets — e.g.
255
+ # the Text control vs the canvas Text shape. Keep every entry
256
+ # per name so `get` can rank candidates instead of letting the
257
+ # last dict insert silently win.
258
+ self._by_name = {}
259
+ for bucket in ("controls", "types", "events", "enums"):
260
+ for e in self._raw.get(bucket, []):
261
+ self._by_name.setdefault(e["name"], []).append((bucket, e))
262
+ return self._raw
263
+
264
+ def _candidates(self, name: str) -> list[tuple[str, dict[str, Any]]]:
265
+ """Entries matching `name`, best first. A dotted name
266
+ ("canvas.Text", "flet_map.Camera") filters candidates by module
267
+ segment, so shadowed symbols stay reachable. Preference for bare
268
+ names: non-canvas modules first (the canvas shapes namespace
269
+ shadows core controls like Text/Image), then bucket order."""
270
+ self._load()
271
+ if name in self._by_name:
272
+ cands = self._by_name[name]
273
+ elif "." in name:
274
+ prefix, cls = name.rsplit(".", 1)
275
+ cands = [
276
+ (b, e)
277
+ for (b, e) in self._by_name.get(cls, [])
278
+ if f".{prefix}." in f".{e.get('module') or ''}."
279
+ ]
280
+ else:
281
+ return []
282
+
283
+ def rank(item: tuple[str, dict[str, Any]]) -> tuple[bool, int]:
284
+ bucket, entry = item
285
+ in_canvas = ".canvas." in f".{entry.get('module') or ''}."
286
+ return (in_canvas, _BUCKET_RANK[bucket])
287
+
288
+ return sorted(cands, key=rank)
289
+
290
+ @staticmethod
291
+ def _qualified_hint(entry: dict[str, Any], name: str) -> str:
292
+ """A dotted name that selects `entry` unambiguously in `get`."""
293
+ parts = (entry.get("module") or "").split(".")
294
+ if "canvas" in parts:
295
+ return f"canvas.{name}"
296
+ return f"{parts[0]}.{name}" if parts and parts[0] else name
297
+
298
+ # ------------------------------------------------------------------
299
+ # Controls
300
+ # ------------------------------------------------------------------
301
+
302
+ def list_controls(
303
+ self,
304
+ category: str | None = None,
305
+ kind: str | None = None,
306
+ limit: int = 50,
307
+ ) -> list[dict[str, Any]]:
308
+ """List controls, optionally filtered by category or kind."""
309
+ self._load()
310
+ results: list[dict[str, Any]] = []
311
+ for name, ctrl in self._controls.items():
312
+ if kind and ctrl.get("kind") != kind:
313
+ continue
314
+ if category and category not in ctrl.get("categories", []):
315
+ continue
316
+ results.append(
317
+ {
318
+ "name": name,
319
+ "kind": ctrl.get("kind"),
320
+ "summary": ctrl.get("summary", ""),
321
+ "categories": ctrl.get("categories", []),
322
+ }
323
+ )
324
+ if len(results) >= limit:
325
+ break
326
+ return results
327
+
328
+ # ------------------------------------------------------------------
329
+ # Unified lookup (controls/services/types/events)
330
+ # ------------------------------------------------------------------
331
+
332
+ def get(
333
+ self,
334
+ name: str,
335
+ member: str | None = None,
336
+ query: str | None = None,
337
+ ) -> dict[str, Any] | None:
338
+ """Look up `name` across controls, services, types, events, and enums.
339
+
340
+ Every match carries a `kind` field — `"control"`, `"service"`, `"type"`,
341
+ `"event"`, or `"enum"` (`"large_enum"` for Icons/CupertinoIcons) — so the
342
+ caller can distinguish them without inspecting the response shape.
343
+ Controls and services already carry `kind`; the other buckets are
344
+ augmented here. Returns `None` only when the name matches nothing.
345
+
346
+ Member docstrings (properties/events/methods) are trimmed to their
347
+ first sentence. Pass `member` for one member's full entry, or `query`
348
+ for a case-insensitive substring filter over member names (`member`
349
+ wins if both are given). Enum responses use the same truncation as
350
+ `get_enum` for large enums; `member`/`query` don't apply to enums —
351
+ use search_enum_members / enum_has_member there.
352
+
353
+ Names are not globally unique (e.g. the Text control vs the canvas
354
+ Text shape): the best candidate wins and the response notes the
355
+ alternatives, each reachable via a dotted name ("canvas.Text").
356
+ """
357
+ cands = self._candidates(name)
358
+ if not cands:
359
+ return None
360
+ bucket, entry = cands[0]
361
+
362
+ if bucket == "enums":
363
+ if member is not None or query is not None:
364
+ return {
365
+ "error": (
366
+ f"'{name}' is an enum — use search_enum_members or "
367
+ "enum_has_member to look up its members"
368
+ )
369
+ }
370
+ enum = self.get_enum(entry["name"])
371
+ return {"kind": enum.get("kind", "enum"), **enum}
372
+
373
+ hit = entry if bucket == "controls" else {"kind": _BUCKET_KIND[bucket], **entry}
374
+ if member is not None:
375
+ return self._get_member(hit, name, member)
376
+ out = self._compact(hit)
377
+ if query is not None:
378
+ out = self._filter_members(out, name, query)
379
+ if "error" not in out and len(cands) > 1:
380
+ alternates = "; ".join(
381
+ f"{e.get('module')}.{e.get('name')} — get_api"
382
+ f"('{self._qualified_hint(e, e.get('name'))}')"
383
+ for _, e in cands[1:]
384
+ )
385
+ combined = f"Other symbols share this name: {alternates}."
386
+ if out.get("note"):
387
+ combined += " " + out["note"]
388
+ out = {**out, "note": combined}
389
+ # Keep the note leading so it survives downstream capping.
390
+ out = {"note": out.pop("note"), **out}
391
+ return out
392
+
393
+ def _filter_members(
394
+ self, compacted: dict[str, Any], name: str, query: str
395
+ ) -> dict[str, Any]:
396
+ """Keep only members whose name contains `query` (case-insensitive).
397
+
398
+ Base classes are searched too (transitively): api.json stores only
399
+ a class's *own* members, so e.g. TextField's border properties live
400
+ on FormFieldControl — without the walk, a query would return a
401
+ misleading "no members" for members the control actually inherits.
402
+ Inherited matches land under an `inherited` key, grouped by the
403
+ defining class.
404
+ """
405
+ q = query.lower()
406
+
407
+ def _matches(hit: dict[str, Any]) -> tuple[dict[str, list], int]:
408
+ found: dict[str, list] = {}
409
+ count = 0
410
+ for section in _MEMBER_SECTIONS:
411
+ kept = [
412
+ _compact_member(e)
413
+ for e in hit.get(section) or []
414
+ if q in e.get("name", "").lower()
415
+ ]
416
+ found[section] = kept
417
+ count += len(kept)
418
+ return found, count
419
+
420
+ out = dict(compacted)
421
+ own, matched = _matches(compacted)
422
+ out.update(own)
423
+
424
+ inherited: list[dict[str, Any]] = []
425
+ visited: set[str] = set()
426
+ queue = list(compacted.get("bases") or [])
427
+ while queue:
428
+ base_name = queue.pop(0)
429
+ if base_name in visited:
430
+ continue
431
+ visited.add(base_name)
432
+ base = self._controls.get(base_name) or self._types.get(base_name)
433
+ if base is None:
434
+ continue
435
+ base_found, base_count = _matches(base)
436
+ if base_count:
437
+ inherited.append({"from": base_name, **base_found})
438
+ matched += base_count
439
+ queue.extend(base.get("bases") or [])
440
+ if inherited:
441
+ out["inherited"] = inherited
442
+
443
+ if not matched:
444
+ bases_hint = (
445
+ f" (own and inherited via {', '.join(sorted(visited))})"
446
+ if visited
447
+ else ""
448
+ )
449
+ return {
450
+ "error": (f"'{name}' has no members matching '{query}'{bases_hint}"),
451
+ "available_members": [
452
+ e.get("name")
453
+ for section in _MEMBER_SECTIONS
454
+ for e in compacted.get(section) or []
455
+ ],
456
+ }
457
+ out["note"] = (
458
+ f"Showing only members matching '{query}' (including inherited). "
459
+ f"Call get_api({name!r}) for the full listing."
460
+ )
461
+ return out
462
+
463
+ def _compact(self, hit: dict[str, Any]) -> dict[str, Any]:
464
+ """Trim member docstrings to their first sentence, with a note
465
+ pointing at the per-member drill-down when anything was cut."""
466
+ out = dict(hit)
467
+ trimmed = False
468
+ for section in _MEMBER_SECTIONS:
469
+ entries = out.get(section)
470
+ if not entries:
471
+ continue
472
+ compacted = [_compact_member(e) for e in entries]
473
+ if any(c is not e for c, e in zip(compacted, entries)):
474
+ trimmed = True
475
+ out[section] = compacted
476
+ if trimmed:
477
+ # Lead with the note so it survives any downstream size capping
478
+ # (agent frameworks may truncate the serialized result tail).
479
+ note = (
480
+ "Member docstrings are trimmed to their first sentence. "
481
+ f"Call get_api({out.get('name')!r}, member=<name>) for one "
482
+ "member's full docs and examples, or query=<substring> to "
483
+ "list matching members only."
484
+ )
485
+ out = {"note": note, **out}
486
+ return out
487
+
488
+ def _get_member(
489
+ self, hit: dict[str, Any], name: str, member: str
490
+ ) -> dict[str, Any]:
491
+ for section in _MEMBER_SECTIONS:
492
+ for entry in hit.get(section) or []:
493
+ if entry.get("name") == member:
494
+ return {
495
+ "name": name,
496
+ "kind": hit.get("kind"),
497
+ "member_kind": _MEMBER_KIND[section],
498
+ "member": entry,
499
+ }
500
+ available = [
501
+ e.get("name")
502
+ for section in _MEMBER_SECTIONS
503
+ for e in hit.get(section) or []
504
+ ]
505
+ return {
506
+ "error": f"'{name}' has no member '{member}'",
507
+ "available_members": available,
508
+ }
509
+
510
+ # ------------------------------------------------------------------
511
+ # Enums
512
+ # ------------------------------------------------------------------
513
+
514
+ def get_enum(self, name: str) -> dict[str, Any] | None:
515
+ """Return enum data. Large enums are truncated with a hint to search."""
516
+ self._load()
517
+ enum = self._enums.get(name)
518
+ if enum is None:
519
+ return None
520
+
521
+ if enum.get("kind") == "large_enum":
522
+ members = enum.get("members", [])
523
+ return {
524
+ "name": name,
525
+ "kind": "large_enum",
526
+ "total_members": len(members),
527
+ "sample_members": members[:10],
528
+ "note": (
529
+ f"This enum has {len(members)} members. Use find_icon() "
530
+ "to search icons by concept, or search_enum_members() "
531
+ "for literal name lookup."
532
+ ),
533
+ }
534
+
535
+ return enum
536
+
537
+ def search_enum_members(self, name: str, query: str, limit: int = 10) -> list[str]:
538
+ """Search enum members by case-insensitive substring match.
539
+
540
+ Ranked: exact match, then prefix matches, then substring matches
541
+ (shortest/alphabetical within each tier) — so "remove" surfaces
542
+ REMOVE before BOOKMARK_REMOVE. Material style variants
543
+ (_OUTLINED/_ROUNDED/_SHARP) are collapsed into their base member
544
+ when the base also matches, so they don't exhaust the limit.
545
+ """
546
+ self._load()
547
+ enum = self._enums.get(name)
548
+ if enum is None:
549
+ return []
550
+
551
+ q = query.lower()
552
+ names = [
553
+ m["name"] if isinstance(m, dict) else str(m)
554
+ for m in enum.get("members", [])
555
+ ]
556
+ matched = [n for n in names if q in n.lower()]
557
+
558
+ def tier(n: str) -> int:
559
+ ln = n.lower()
560
+ if ln == q:
561
+ return 0
562
+ return 1 if ln.startswith(q) else 2
563
+
564
+ matched.sort(key=lambda n: (tier(n), len(n), n))
565
+ present = set(matched)
566
+ results: list[str] = []
567
+ for n in matched:
568
+ base = next((n[: -len(s)] for s in _STYLE_SUFFIXES if n.endswith(s)), None)
569
+ if base and base in present:
570
+ continue
571
+ results.append(n)
572
+ if len(results) >= limit:
573
+ break
574
+ return results
575
+
576
+ def enum_member_names(self, name: str) -> list[str]:
577
+ """All member names of an enum (untruncated — also for large
578
+ enums). Used to seed the icon search index from bundled data."""
579
+ self._load()
580
+ enum = self._enums.get(name)
581
+ if enum is None:
582
+ return []
583
+ return [
584
+ m["name"] if isinstance(m, dict) else str(m)
585
+ for m in enum.get("members", [])
586
+ ]
587
+
588
+ def enum_has_member(self, name: str, member: str) -> bool:
589
+ """Check whether an enum contains a specific member."""
590
+ self._load()
591
+ enum = self._enums.get(name)
592
+ if enum is None:
593
+ return False
594
+ members = enum.get("members", [])
595
+ member_lower = member.lower()
596
+ return any(
597
+ (m["name"] if isinstance(m, dict) else str(m)).lower() == member_lower
598
+ for m in members
599
+ )
600
+
601
+ # ------------------------------------------------------------------
602
+ # CLI
603
+ # ------------------------------------------------------------------
604
+
605
+ def get_cli_help(self, command: str | None = None) -> Any:
606
+ """Return CLI help. None -> list commands; otherwise command help text."""
607
+ self._load()
608
+ cli = self._raw.get("cli", {})
609
+ if command is None:
610
+ return list(cli.keys())
611
+ return cli.get(command)