@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.
- package/package.json +1 -1
- package/skills/flywheel/example-workflows/organizing-exploring-and-iterating-on-a-research-topic.md +11 -11
- package/skills/flywheel/example-workflows/reproducing-papers-on-a-budget.md +8 -8
- package/skills/flywheel/references/ARTIFACTS.md +48 -0
- package/skills/flywheel/references/INTERFACES.md +206 -0
- package/skills/flywheel/references/experiment-design-protocol.md +5 -5
- package/skills/flywheel/references/flywheel-mcp-tool-map.md +72 -60
- package/skills/flywheel-auto/SKILL.md +4 -4
- package/skills/flywheel-auto/references/ARTIFACTS.md +1 -1
- package/skills/flywheel-auto/references/INTERFACES.md +59 -59
- package/skills/flywheel-auto/references/experiment-design-protocol.md +5 -5
- package/skills/flywheel-auto/references/flywheel-mcp-tool-map.md +72 -60
- package/skills/flywheel-lookahead/references/ARTIFACTS.md +1 -1
- package/skills/flywheel-lookahead/references/INTERFACES.md +59 -59
- package/skills/flywheel-lookahead/references/flywheel-mcp-tool-map.md +72 -60
- package/skills/flywheel-prove/SKILL.md +8 -0
- package/skills/flywheel-reproduce/SKILL.md +4 -4
- package/skills/flywheel-reproduce/references/ARTIFACTS.md +1 -1
- package/skills/flywheel-reproduce/references/INTERFACES.md +59 -59
- package/skills/flywheel-reproduce/references/experiment-design-protocol.md +5 -5
- package/skills/flywheel-reproduce/references/flywheel-mcp-tool-map.md +72 -60
- package/skills/flywheel-to-graph/SKILL.md +4 -4
- package/skills/flywheel-to-graph/references/ARTIFACTS.md +1 -1
- package/skills/flywheel-to-graph/references/INTERFACES.md +59 -59
- package/skills/flywheel-to-graph/references/flywheel-mcp-tool-map.md +72 -60
- package/skills/flywheel-tree/SKILL.md +61 -0
- package/skills/flywheel-tree/agents/interface.yaml +4 -0
- package/skills/flywheel-tree/assets/ansi_palette.json +29 -0
- package/skills/flywheel-tree/references/workflow.md +108 -0
- package/skills/flywheel-tree/scripts/render_tree.py +407 -0
- 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())
|