@paradigma-inc/flywheel 0.1.26 → 0.1.35

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 (31) hide show
  1. package/package.json +1 -1
  2. package/skills/flywheel/example-workflows/organizing-exploring-and-iterating-on-a-research-topic.md +11 -11
  3. package/skills/flywheel/example-workflows/reproducing-papers-on-a-budget.md +8 -8
  4. package/skills/flywheel/references/ARTIFACTS.md +48 -0
  5. package/skills/flywheel/references/INTERFACES.md +206 -0
  6. package/skills/flywheel/references/experiment-design-protocol.md +5 -5
  7. package/skills/flywheel/references/flywheel-mcp-tool-map.md +72 -60
  8. package/skills/flywheel-auto/SKILL.md +4 -4
  9. package/skills/flywheel-auto/references/ARTIFACTS.md +1 -1
  10. package/skills/flywheel-auto/references/INTERFACES.md +59 -59
  11. package/skills/flywheel-auto/references/experiment-design-protocol.md +5 -5
  12. package/skills/flywheel-auto/references/flywheel-mcp-tool-map.md +72 -60
  13. package/skills/flywheel-lookahead/references/ARTIFACTS.md +1 -1
  14. package/skills/flywheel-lookahead/references/INTERFACES.md +59 -59
  15. package/skills/flywheel-lookahead/references/flywheel-mcp-tool-map.md +72 -60
  16. package/skills/flywheel-prove/SKILL.md +8 -0
  17. package/skills/flywheel-reproduce/SKILL.md +4 -4
  18. package/skills/flywheel-reproduce/references/ARTIFACTS.md +1 -1
  19. package/skills/flywheel-reproduce/references/INTERFACES.md +59 -59
  20. package/skills/flywheel-reproduce/references/experiment-design-protocol.md +5 -5
  21. package/skills/flywheel-reproduce/references/flywheel-mcp-tool-map.md +72 -60
  22. package/skills/flywheel-to-graph/SKILL.md +4 -4
  23. package/skills/flywheel-to-graph/references/ARTIFACTS.md +1 -1
  24. package/skills/flywheel-to-graph/references/INTERFACES.md +59 -59
  25. package/skills/flywheel-to-graph/references/flywheel-mcp-tool-map.md +72 -60
  26. package/skills/flywheel-tree/SKILL.md +61 -0
  27. package/skills/flywheel-tree/agents/interface.yaml +4 -0
  28. package/skills/flywheel-tree/assets/ansi_palette.json +29 -0
  29. package/skills/flywheel-tree/references/workflow.md +108 -0
  30. package/skills/flywheel-tree/scripts/render_tree.py +407 -0
  31. package/skills/flywheel-tree/scripts/render_tree_via_mcp.py +694 -0
@@ -0,0 +1,694 @@
1
+ #!/usr/bin/env python3
2
+ """Fetch Flywheel tree via MCP tools and render terminal tree text.
3
+
4
+ This wrapper avoids model-side JSON marshalling by keeping fetch+render inside
5
+ one process:
6
+ 1) resolve selector via `flywheel_resolve_node_slug` when needed
7
+ 2) fetch topology via `flywheel_get_node_tree`
8
+ 3) render tree text via local deterministic renderer
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import asyncio
15
+ import builtins
16
+ import json
17
+ import os
18
+ import re
19
+ import sys
20
+ import tomllib
21
+ from pathlib import Path
22
+ from typing import Any, Iterable
23
+
24
+ from mcp import ClientSession
25
+ from mcp.client.streamable_http import streamablehttp_client
26
+
27
+ from render_tree import render_tree_text
28
+
29
+ UUID_PATTERN = re.compile(
30
+ r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
31
+ )
32
+ DEFAULT_CODEX_MCP_SERVER = "flywheel"
33
+ CODEX_CONFIG_FILENAME = "config.toml"
34
+ CODEX_CONFIG_DIRNAME = ".codex"
35
+ CLAUDE_PROJECT_CONFIG_FILENAME = ".mcp.json"
36
+ CLAUDE_GLOBAL_CONFIG_FILENAME = ".claude.json"
37
+ EXCEPTION_GROUP_TYPE = getattr(builtins, "BaseExceptionGroup", None)
38
+
39
+
40
+ def _normalize_auth_header_value(token_or_header: str) -> str:
41
+ token = token_or_header.strip()
42
+ if token.lower().startswith("bearer "):
43
+ return token
44
+ return f"Bearer {token}"
45
+
46
+
47
+ def _candidate_mcp_config_paths() -> list[Path]:
48
+ candidates: list[Path] = []
49
+ seen: set[Path] = set()
50
+
51
+ def add(candidate: Path | None) -> None:
52
+ if candidate is None:
53
+ return
54
+ resolved = candidate.expanduser().resolve()
55
+ if resolved in seen:
56
+ return
57
+ seen.add(resolved)
58
+ candidates.append(resolved)
59
+
60
+ env_codex_config = os.environ.get("CODEX_CONFIG", "").strip()
61
+ if env_codex_config:
62
+ add(Path(env_codex_config))
63
+
64
+ env_codex_home = os.environ.get("CODEX_HOME", "").strip()
65
+ if env_codex_home:
66
+ add(Path(env_codex_home) / CODEX_CONFIG_FILENAME)
67
+
68
+ # Project-scoped Codex config in current working tree (nearest ancestor first).
69
+ cwd = Path.cwd().resolve()
70
+ for current in (cwd, *cwd.parents):
71
+ add(current / CODEX_CONFIG_DIRNAME / CODEX_CONFIG_FILENAME)
72
+
73
+ add(Path.home() / CODEX_CONFIG_DIRNAME / CODEX_CONFIG_FILENAME)
74
+
75
+ user_profile = os.environ.get("USERPROFILE", "").strip()
76
+ if user_profile:
77
+ add(Path(user_profile) / CODEX_CONFIG_DIRNAME / CODEX_CONFIG_FILENAME)
78
+
79
+ # Claude project-scoped config in current working tree (nearest ancestor first).
80
+ for current in (cwd, *cwd.parents):
81
+ add(current / CLAUDE_PROJECT_CONFIG_FILENAME)
82
+
83
+ # Claude global config.
84
+ add(Path.home() / CLAUDE_GLOBAL_CONFIG_FILENAME)
85
+ if user_profile:
86
+ add(Path(user_profile) / CLAUDE_GLOBAL_CONFIG_FILENAME)
87
+
88
+ return candidates
89
+
90
+
91
+ def _resolve_mcp_config_path(explicit_path: str | None) -> Path:
92
+ if isinstance(explicit_path, str) and explicit_path.strip():
93
+ config_path = Path(explicit_path).expanduser().resolve()
94
+ if not config_path.exists():
95
+ raise RuntimeError(
96
+ f"MCP config not found at '{config_path}'. "
97
+ "Pass a valid --codex-config/--mcp-config path."
98
+ )
99
+ return config_path
100
+
101
+ candidates = _candidate_mcp_config_paths()
102
+ for candidate in candidates:
103
+ if candidate.exists():
104
+ return candidate
105
+
106
+ searched = "\n".join(f"- {path}" for path in candidates)
107
+ raise RuntimeError(
108
+ "No supported MCP config file was found in auto-discovery paths.\n"
109
+ f"Searched:\n{searched}\n"
110
+ "Pass --codex-config/--mcp-config explicitly."
111
+ )
112
+
113
+
114
+ def _load_target_from_discovered_configs(
115
+ *,
116
+ server_name: str,
117
+ ) -> tuple[str, dict[str, str], Path]:
118
+ candidates = _candidate_mcp_config_paths()
119
+ errors: list[str] = []
120
+
121
+ for candidate in candidates:
122
+ if not candidate.exists():
123
+ continue
124
+ try:
125
+ url, headers = _load_codex_http_mcp_target(
126
+ codex_config_path=candidate,
127
+ server_name=server_name,
128
+ )
129
+ return url, headers, candidate
130
+ except Exception as exc: # noqa: BLE001
131
+ errors.append(f"{candidate}: {type(exc).__name__}: {exc}")
132
+
133
+ searched = "\n".join(f"- {path}" for path in candidates)
134
+ details = (
135
+ "\n".join(f"- {error}" for error in errors)
136
+ if errors
137
+ else "- no readable config files found"
138
+ )
139
+ raise RuntimeError(
140
+ "Unable to resolve MCP target from auto-discovered configs.\n"
141
+ f"Searched:\n{searched}\n"
142
+ f"Failures:\n{details}\n"
143
+ "Pass --codex-config/--mcp-config or --mcp-url explicitly."
144
+ )
145
+
146
+
147
+ def _load_mcp_servers_from_toml(config_path: Path) -> dict[str, Any]:
148
+ with config_path.open("rb") as handle:
149
+ config = tomllib.load(handle)
150
+ mcp_servers = config.get("mcp_servers")
151
+ if not isinstance(mcp_servers, dict):
152
+ raise RuntimeError(
153
+ f"MCP config '{config_path}' is TOML but has no [mcp_servers] section."
154
+ )
155
+ return mcp_servers
156
+
157
+
158
+ def _load_mcp_servers_from_json(config_path: Path) -> dict[str, Any]:
159
+ with config_path.open("r", encoding="utf-8-sig") as handle:
160
+ config = json.load(handle)
161
+ mcp_servers = config.get("mcpServers")
162
+ if not isinstance(mcp_servers, dict):
163
+ raise RuntimeError(
164
+ f"MCP config '{config_path}' is JSON but has no mcpServers object."
165
+ )
166
+ return mcp_servers
167
+
168
+
169
+ def _load_mcp_servers_from_config(config_path: Path) -> dict[str, Any]:
170
+ suffix = config_path.suffix.lower()
171
+ if suffix == ".toml":
172
+ return _load_mcp_servers_from_toml(config_path)
173
+ if suffix == ".json":
174
+ return _load_mcp_servers_from_json(config_path)
175
+
176
+ # Fallback for unusual file extensions.
177
+ try:
178
+ return _load_mcp_servers_from_toml(config_path)
179
+ except Exception:
180
+ return _load_mcp_servers_from_json(config_path)
181
+
182
+
183
+ def _valid_http_mcp_aliases(mcp_servers: dict[str, Any]) -> list[str]:
184
+ aliases: list[str] = []
185
+ for key, value in mcp_servers.items():
186
+ if not isinstance(key, str) or not isinstance(value, dict):
187
+ continue
188
+ url = value.get("url")
189
+ if isinstance(url, str) and url.strip():
190
+ aliases.append(key)
191
+ return aliases
192
+
193
+
194
+ def _alias_rank(alias: str, server_url: str) -> tuple[int, str]:
195
+ normalized_alias = alias.lower()
196
+ normalized_url = server_url.lower()
197
+ score = 0
198
+
199
+ if normalized_alias == "flywheel-prod":
200
+ score += 100
201
+ if normalized_alias == "flywheel-production":
202
+ score += 90
203
+ if normalized_alias.endswith("-prod") or "prod" in normalized_alias:
204
+ score += 75
205
+ if "://flywheel." in normalized_url and "/mcp-server" in normalized_url:
206
+ score += 65
207
+ if normalized_alias.startswith("flywheel-"):
208
+ score += 25
209
+
210
+ return score, alias
211
+
212
+
213
+ def _resolve_server_alias(
214
+ *,
215
+ mcp_servers: dict[str, Any],
216
+ requested_server_name: str,
217
+ ) -> str:
218
+ if requested_server_name in mcp_servers:
219
+ return requested_server_name
220
+
221
+ if requested_server_name != DEFAULT_CODEX_MCP_SERVER:
222
+ available = ", ".join(sorted(str(key) for key in mcp_servers.keys()))
223
+ raise RuntimeError(
224
+ f"MCP server '{requested_server_name}' not found in Codex config. "
225
+ f"Available: {available or 'none'}"
226
+ )
227
+
228
+ env_server = os.environ.get("FLYWHEEL_MCP_SERVER", "").strip()
229
+ if env_server and env_server in mcp_servers:
230
+ return env_server
231
+
232
+ compatible_aliases = [
233
+ alias
234
+ for alias in _valid_http_mcp_aliases(mcp_servers)
235
+ if alias.startswith(DEFAULT_CODEX_MCP_SERVER)
236
+ ]
237
+ if not compatible_aliases:
238
+ available = ", ".join(sorted(str(key) for key in mcp_servers.keys()))
239
+ raise RuntimeError(
240
+ f"MCP server '{requested_server_name}' not found in Codex config. "
241
+ f"Available: {available or 'none'}"
242
+ )
243
+
244
+ if len(compatible_aliases) == 1:
245
+ return compatible_aliases[0]
246
+
247
+ ranked: list[tuple[int, str]] = []
248
+ for alias in compatible_aliases:
249
+ server = mcp_servers.get(alias)
250
+ if not isinstance(server, dict):
251
+ continue
252
+ server_url = server.get("url")
253
+ if not isinstance(server_url, str):
254
+ continue
255
+ ranked.append(_alias_rank(alias, server_url))
256
+
257
+ if ranked:
258
+ ranked.sort(reverse=True)
259
+ best_score = ranked[0][0]
260
+ best_aliases = [alias for score, alias in ranked if score == best_score]
261
+ if len(best_aliases) == 1:
262
+ return best_aliases[0]
263
+
264
+ available = ", ".join(sorted(compatible_aliases))
265
+ raise RuntimeError(
266
+ "Multiple flywheel-prefixed MCP aliases were found and no deterministic "
267
+ "default could be selected. "
268
+ f"Provide --codex-mcp-server explicitly. Candidates: {available}"
269
+ )
270
+
271
+
272
+ def _load_codex_http_mcp_target(
273
+ *,
274
+ codex_config_path: Path,
275
+ server_name: str,
276
+ ) -> tuple[str, dict[str, str]]:
277
+ if not codex_config_path.exists():
278
+ raise RuntimeError(
279
+ f"Codex config not found at '{codex_config_path}'. "
280
+ "Pass --mcp-url/--access-token explicitly or provide --codex-config."
281
+ )
282
+
283
+ mcp_servers = _load_mcp_servers_from_config(codex_config_path)
284
+
285
+ resolved_server_name = _resolve_server_alias(
286
+ mcp_servers=mcp_servers,
287
+ requested_server_name=server_name,
288
+ )
289
+ server_config = mcp_servers.get(resolved_server_name)
290
+ if not isinstance(server_config, dict):
291
+ raise RuntimeError(
292
+ f"MCP server '{resolved_server_name}' is not a valid MCP object in Codex config."
293
+ )
294
+
295
+ server_url = server_config.get("url")
296
+ if not isinstance(server_url, str) or not server_url.strip():
297
+ raise RuntimeError(
298
+ f"MCP server '{resolved_server_name}' has no HTTP url in Codex config."
299
+ )
300
+ server_url = server_url.strip()
301
+
302
+ headers: dict[str, str] = {}
303
+
304
+ # Generic headers key used by JSON host configs (for example Claude).
305
+ raw_headers = server_config.get("headers")
306
+ if isinstance(raw_headers, dict):
307
+ for key, value in raw_headers.items():
308
+ if isinstance(key, str) and isinstance(value, str) and key and value:
309
+ headers[key] = value
310
+
311
+ # Inline headers from TOML host configs (for example Codex).
312
+ raw_http_headers = server_config.get("http_headers")
313
+ if isinstance(raw_http_headers, dict):
314
+ for key, value in raw_http_headers.items():
315
+ if isinstance(key, str) and isinstance(value, str) and key and value:
316
+ headers[key] = value
317
+
318
+ # Environment-backed header mapping in Codex config.
319
+ raw_env_http_headers = server_config.get("env_http_headers")
320
+ if isinstance(raw_env_http_headers, dict):
321
+ for header_name, env_var in raw_env_http_headers.items():
322
+ if not (isinstance(header_name, str) and isinstance(env_var, str)):
323
+ continue
324
+ env_value = os.environ.get(env_var, "").strip()
325
+ if env_value:
326
+ headers[header_name] = env_value
327
+
328
+ # Optional bearer token env var field used by Codex config.
329
+ bearer_token_env_var = server_config.get("bearer_token_env_var")
330
+ if isinstance(bearer_token_env_var, str) and bearer_token_env_var.strip():
331
+ env_value = os.environ.get(bearer_token_env_var.strip(), "").strip()
332
+ if env_value:
333
+ headers["Authorization"] = _normalize_auth_header_value(env_value)
334
+
335
+ return server_url, headers
336
+
337
+
338
+ def _iter_text_content(content_items: Iterable[Any]) -> Iterable[str]:
339
+ for item in content_items:
340
+ text = getattr(item, "text", None)
341
+ if isinstance(text, str):
342
+ yield text
343
+ continue
344
+ if isinstance(item, dict):
345
+ maybe_text = item.get("text")
346
+ if isinstance(maybe_text, str):
347
+ yield maybe_text
348
+
349
+
350
+ def _summarize_call_result_text(content_items: Iterable[Any]) -> str:
351
+ parts = [text.strip() for text in _iter_text_content(content_items) if text.strip()]
352
+ if not parts:
353
+ return "no error details returned"
354
+ return " | ".join(parts)
355
+
356
+
357
+ def _extract_payload_from_call_result(result: Any, *, tool_name: str) -> dict[str, Any]:
358
+ is_error = bool(getattr(result, "isError", False))
359
+ content_items = getattr(result, "content", [])
360
+ if is_error:
361
+ detail = _summarize_call_result_text(content_items)
362
+ raise RuntimeError(f"{tool_name} failed: {detail}")
363
+
364
+ structured = getattr(result, "structuredContent", None)
365
+ if isinstance(structured, dict):
366
+ return structured
367
+
368
+ for text in _iter_text_content(content_items):
369
+ stripped = text.strip()
370
+ if not stripped:
371
+ continue
372
+ try:
373
+ parsed = json.loads(stripped)
374
+ except json.JSONDecodeError:
375
+ continue
376
+ if isinstance(parsed, dict):
377
+ return parsed
378
+
379
+ detail = _summarize_call_result_text(content_items)
380
+ raise RuntimeError(
381
+ f"{tool_name} returned no structured JSON payload (detail: {detail})"
382
+ )
383
+
384
+
385
+ def _extract_tree_payload(payload: Any) -> dict[str, Any]:
386
+ if isinstance(payload, dict) and "nodes" in payload and "anchor_node_id" in payload:
387
+ return payload
388
+ if isinstance(payload, dict):
389
+ for key in ("result", "data", "response"):
390
+ nested = payload.get(key)
391
+ if (
392
+ isinstance(nested, dict)
393
+ and "nodes" in nested
394
+ and "anchor_node_id" in nested
395
+ ):
396
+ return nested
397
+ raise RuntimeError(
398
+ "flywheel_get_node_tree did not return expected nodes/anchor_node_id payload."
399
+ )
400
+
401
+
402
+ def _candidate_node_id(candidate: Any) -> str:
403
+ if not isinstance(candidate, dict):
404
+ return ""
405
+ node = candidate.get("node")
406
+ if isinstance(node, dict):
407
+ node_id = node.get("node_id")
408
+ if isinstance(node_id, str) and node_id.strip():
409
+ return node_id.strip()
410
+ node_id = candidate.get("node_id")
411
+ if isinstance(node_id, str) and node_id.strip():
412
+ return node_id.strip()
413
+ return ""
414
+
415
+
416
+ async def _resolve_selector_to_node_id(
417
+ *,
418
+ session: ClientSession,
419
+ selector: str,
420
+ context_node_id: str | None,
421
+ ) -> str:
422
+ cleaned = selector.strip()
423
+ if UUID_PATTERN.fullmatch(cleaned):
424
+ return cleaned
425
+
426
+ args: dict[str, Any] = {"slug_name": cleaned}
427
+ if context_node_id:
428
+ args["context_node_id"] = context_node_id
429
+
430
+ resolve_result = await session.call_tool(
431
+ "flywheel_resolve_node_slug", arguments=args
432
+ )
433
+ payload = _extract_payload_from_call_result(
434
+ resolve_result, tool_name="flywheel_resolve_node_slug"
435
+ )
436
+
437
+ status = str(payload.get("status", "")).strip().lower()
438
+ if status in {"unique", "context_resolved"}:
439
+ node = payload.get("node")
440
+ if isinstance(node, dict):
441
+ node_id = node.get("node_id")
442
+ if isinstance(node_id, str) and node_id.strip():
443
+ return node_id.strip()
444
+ raise RuntimeError(
445
+ "Slug resolved but no node_id returned by flywheel_resolve_node_slug."
446
+ )
447
+
448
+ if status == "not_found":
449
+ raise RuntimeError(f"Slug '{cleaned}' was not found.")
450
+
451
+ if status == "ambiguous":
452
+ candidates = payload.get("candidates")
453
+ candidate_ids: list[str] = []
454
+ if isinstance(candidates, list):
455
+ for candidate in candidates:
456
+ node_id = _candidate_node_id(candidate)
457
+ if node_id:
458
+ candidate_ids.append(node_id)
459
+ suffix = (
460
+ f" Candidate node_ids: {', '.join(candidate_ids)}" if candidate_ids else ""
461
+ )
462
+ raise RuntimeError(f"Slug '{cleaned}' is ambiguous.{suffix}")
463
+
464
+ # Fallback for unexpected response shape.
465
+ node = payload.get("node")
466
+ if isinstance(node, dict):
467
+ node_id = node.get("node_id")
468
+ if isinstance(node_id, str) and node_id.strip():
469
+ return node_id.strip()
470
+
471
+ raise RuntimeError(
472
+ f"Unexpected flywheel_resolve_node_slug response status: {status or 'missing'}."
473
+ )
474
+
475
+
476
+ async def _fetch_tree_payload(
477
+ *,
478
+ session: ClientSession,
479
+ node_id: str,
480
+ max_nodes: int,
481
+ projection: str,
482
+ ) -> dict[str, Any]:
483
+ tree_result = await session.call_tool(
484
+ "flywheel_get_node_tree",
485
+ arguments={
486
+ "node_id": node_id,
487
+ "max_nodes": max_nodes,
488
+ "projection": projection,
489
+ },
490
+ )
491
+ payload = _extract_payload_from_call_result(
492
+ tree_result, tool_name="flywheel_get_node_tree"
493
+ )
494
+ return _extract_tree_payload(payload)
495
+
496
+
497
+ async def _run(args: argparse.Namespace) -> str:
498
+ configured_url = ""
499
+ configured_headers: dict[str, str] = {}
500
+
501
+ if not args.mcp_url or args.use_codex_credentials:
502
+ if isinstance(args.codex_config, str) and args.codex_config.strip():
503
+ resolved_codex_config = _resolve_mcp_config_path(args.codex_config)
504
+ configured_url, configured_headers = _load_codex_http_mcp_target(
505
+ codex_config_path=resolved_codex_config,
506
+ server_name=args.codex_mcp_server,
507
+ )
508
+ else:
509
+ configured_url, configured_headers, _resolved_config = (
510
+ _load_target_from_discovered_configs(
511
+ server_name=args.codex_mcp_server,
512
+ )
513
+ )
514
+
515
+ mcp_url = (
516
+ args.mcp_url.strip()
517
+ if isinstance(args.mcp_url, str) and args.mcp_url.strip()
518
+ else configured_url
519
+ )
520
+ if not mcp_url:
521
+ raise RuntimeError(
522
+ "Unable to resolve MCP url. Pass --mcp-url or configure Codex MCP server."
523
+ )
524
+
525
+ headers = dict(configured_headers)
526
+ if args.access_token:
527
+ headers["Authorization"] = _normalize_auth_header_value(args.access_token)
528
+
529
+ async with streamablehttp_client(
530
+ mcp_url,
531
+ headers=headers or None,
532
+ timeout=float(args.timeout_seconds),
533
+ sse_read_timeout=float(args.timeout_seconds),
534
+ ) as (read_stream, write_stream, _get_session_id):
535
+ async with ClientSession(read_stream, write_stream) as session:
536
+ await session.initialize()
537
+ node_id = await _resolve_selector_to_node_id(
538
+ session=session,
539
+ selector=args.selector,
540
+ context_node_id=args.context_node_id,
541
+ )
542
+ tree_payload = await _fetch_tree_payload(
543
+ session=session,
544
+ node_id=node_id,
545
+ max_nodes=args.max_nodes,
546
+ projection=args.projection,
547
+ )
548
+
549
+ palette_path = Path(args.palette).resolve()
550
+ return render_tree_text(
551
+ tree_payload=tree_payload,
552
+ use_color=not args.no_color,
553
+ palette_path=palette_path,
554
+ )
555
+
556
+
557
+ def _build_parser() -> argparse.ArgumentParser:
558
+ parser = argparse.ArgumentParser(
559
+ description=(
560
+ "Resolve a Flywheel selector via MCP, fetch node tree via "
561
+ "flywheel_get_node_tree, and render terminal tree text."
562
+ )
563
+ )
564
+ parser.add_argument(
565
+ "--selector",
566
+ required=True,
567
+ help="Root selector (node id UUID or slug).",
568
+ )
569
+ parser.add_argument(
570
+ "--context-node-id",
571
+ default=None,
572
+ help="Optional context node id for flywheel_resolve_node_slug.",
573
+ )
574
+ parser.add_argument(
575
+ "--codex-mcp-server",
576
+ default=DEFAULT_CODEX_MCP_SERVER,
577
+ help=(
578
+ "Codex MCP server alias (default: flywheel). "
579
+ "If flywheel is missing, the wrapper auto-resolves a flywheel* alias."
580
+ ),
581
+ )
582
+ parser.add_argument(
583
+ "--codex-config",
584
+ "--mcp-config",
585
+ dest="codex_config",
586
+ default=None,
587
+ help=(
588
+ "Optional MCP config path (Codex TOML or Claude JSON). "
589
+ "If omitted, auto-discovery checks CODEX_CONFIG, CODEX_HOME/config.toml, "
590
+ "nearest ./.codex/config.toml, ~/.codex/config.toml, nearest ./.mcp.json, "
591
+ "then ~/.claude.json."
592
+ ),
593
+ )
594
+ parser.add_argument(
595
+ "--mcp-url",
596
+ default=None,
597
+ help=("Optional MCP URL override. If omitted, use URL from Codex MCP config."),
598
+ )
599
+ parser.add_argument(
600
+ "--access-token",
601
+ default=None,
602
+ help=(
603
+ "Optional bearer token override. By default, use Codex-configured "
604
+ "HTTP headers/credentials."
605
+ ),
606
+ )
607
+ parser.add_argument(
608
+ "--use-codex-credentials",
609
+ action="store_true",
610
+ help=(
611
+ "Always load credentials from Codex MCP config even when --mcp-url is set."
612
+ ),
613
+ )
614
+ parser.add_argument(
615
+ "--max-nodes",
616
+ type=int,
617
+ default=1000,
618
+ help="Maximum nodes passed to flywheel_get_node_tree (default: 1000).",
619
+ )
620
+ parser.add_argument(
621
+ "--projection",
622
+ choices=["core", "topology", "full"],
623
+ default="topology",
624
+ help="Projection passed to flywheel_get_node_tree (default: topology).",
625
+ )
626
+ parser.add_argument(
627
+ "--palette",
628
+ default=str(
629
+ Path(__file__).resolve().parent.parent / "assets" / "ansi_palette.json"
630
+ ),
631
+ help="Path to ANSI palette JSON used by renderer.",
632
+ )
633
+ parser.add_argument(
634
+ "--no-color",
635
+ action="store_true",
636
+ help="Disable ANSI color output.",
637
+ )
638
+ parser.add_argument(
639
+ "--timeout-seconds",
640
+ type=float,
641
+ default=30.0,
642
+ help="MCP transport timeout (default: 30s).",
643
+ )
644
+ return parser
645
+
646
+
647
+ def _flatten_exception_messages(exc: BaseException) -> str:
648
+ stack: list[BaseException] = [exc]
649
+ leaves: list[str] = []
650
+
651
+ while stack:
652
+ current = stack.pop()
653
+ if EXCEPTION_GROUP_TYPE and isinstance(current, EXCEPTION_GROUP_TYPE):
654
+ stack.extend(current.exceptions)
655
+ continue
656
+ message = str(current).strip()
657
+ if message:
658
+ leaves.append(f"{type(current).__name__}: {message}")
659
+ else:
660
+ leaves.append(type(current).__name__)
661
+
662
+ if not leaves:
663
+ return str(exc).strip() or type(exc).__name__
664
+ return " | ".join(leaves)
665
+
666
+
667
+ def main() -> int:
668
+ parser = _build_parser()
669
+ args = parser.parse_args()
670
+
671
+ if args.max_nodes <= 0:
672
+ print("render_tree_via_mcp.py error: --max-nodes must be > 0", file=sys.stderr)
673
+ return 2
674
+
675
+ try:
676
+ rendered = asyncio.run(_run(args))
677
+ except Exception as exc: # noqa: BLE001
678
+ detail = _flatten_exception_messages(exc)
679
+ print(f"render_tree_via_mcp.py error: {detail}", file=sys.stderr)
680
+ return 1
681
+
682
+ try:
683
+ sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
684
+ except Exception: # noqa: BLE001
685
+ pass
686
+
687
+ sys.stdout.write(rendered)
688
+ if not rendered.endswith("\n"):
689
+ sys.stdout.write("\n")
690
+ return 0
691
+
692
+
693
+ if __name__ == "__main__":
694
+ raise SystemExit(main())