@oneciel-ai/ciel-runtime 0.2.2 → 0.2.3

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 (126) hide show
  1. package/ciel_runtime.py +2553 -9635
  2. package/ciel_runtime_support/advisor_request_builder.py +8 -21
  3. package/ciel_runtime_support/anthropic_tool_turns.py +13 -8
  4. package/ciel_runtime_support/architecture.py +68 -0
  5. package/ciel_runtime_support/architecture_budget.py +1 -1
  6. package/ciel_runtime_support/channel_connection_context.py +233 -0
  7. package/ciel_runtime_support/channel_delivery_context.py +332 -0
  8. package/ciel_runtime_support/channel_mcp_context.py +313 -0
  9. package/ciel_runtime_support/channel_mcp_discovery.py +47 -0
  10. package/ciel_runtime_support/channel_mcp_transport.py +5 -1
  11. package/ciel_runtime_support/channel_message_context.py +212 -0
  12. package/ciel_runtime_support/channel_message_repository.py +14 -3
  13. package/ciel_runtime_support/channel_pending_injection.py +9 -0
  14. package/ciel_runtime_support/channel_probe_launch_context.py +213 -0
  15. package/ciel_runtime_support/channel_replay_policy.py +38 -0
  16. package/ciel_runtime_support/channel_runtime_environment.py +8 -0
  17. package/ciel_runtime_support/channel_session_context.py +236 -0
  18. package/ciel_runtime_support/channel_terminal_context.py +350 -0
  19. package/ciel_runtime_support/channel_wake_context.py +532 -0
  20. package/ciel_runtime_support/claude_environment.py +60 -0
  21. package/ciel_runtime_support/claude_launch_assembly.py +249 -0
  22. package/ciel_runtime_support/claude_router.py +62 -12
  23. package/ciel_runtime_support/cli_application_context.py +132 -0
  24. package/ciel_runtime_support/cli_assembly.py +50 -0
  25. package/ciel_runtime_support/codex_backend_context.py +363 -0
  26. package/ciel_runtime_support/codex_config.py +13 -1
  27. package/ciel_runtime_support/codex_launch_assembly.py +213 -0
  28. package/ciel_runtime_support/codex_launch_configuration.py +30 -1
  29. package/ciel_runtime_support/codex_mcp_integration.py +90 -8
  30. package/ciel_runtime_support/codex_model_catalog.py +4 -1
  31. package/ciel_runtime_support/codex_reasoning_rejects.py +225 -0
  32. package/ciel_runtime_support/codex_router.py +38 -8
  33. package/ciel_runtime_support/codex_turn_recovery.py +154 -0
  34. package/ciel_runtime_support/config_migrations.py +103 -0
  35. package/ciel_runtime_support/configuration_cli.py +38 -0
  36. package/ciel_runtime_support/context_compaction.py +9 -4
  37. package/ciel_runtime_support/credential_management.py +12 -0
  38. package/ciel_runtime_support/credentials.py +12 -0
  39. package/ciel_runtime_support/github_copilot_oauth.py +2 -2
  40. package/ciel_runtime_support/hosted_formula_tools.py +216 -0
  41. package/ciel_runtime_support/kimi_runtime_context.py +208 -0
  42. package/ciel_runtime_support/llm_preset_context.py +338 -0
  43. package/ciel_runtime_support/managed_mcp_config.py +8 -4
  44. package/ciel_runtime_support/mcp_configuration_context.py +291 -0
  45. package/ciel_runtime_support/mcp_http_proxy.py +14 -8
  46. package/ciel_runtime_support/mcp_probe_transport.py +47 -15
  47. package/ciel_runtime_support/mcp_transport.py +14 -1
  48. package/ciel_runtime_support/native_context_recovery.py +72 -0
  49. package/ciel_runtime_support/ollama_catalog_context.py +213 -0
  50. package/ciel_runtime_support/ollama_stream_collection.py +103 -0
  51. package/ciel_runtime_support/ollama_thinking.py +6 -1
  52. package/ciel_runtime_support/ollama_wire_projection.py +157 -0
  53. package/ciel_runtime_support/openai_forwarding.py +32 -10
  54. package/ciel_runtime_support/openai_responses_router.py +12 -0
  55. package/ciel_runtime_support/package_lifecycle.py +39 -0
  56. package/ciel_runtime_support/prelaunch_assembly.py +37 -0
  57. package/ciel_runtime_support/prelaunch_panel_context.py +418 -0
  58. package/ciel_runtime_support/prelaunch_shell_context.py +394 -0
  59. package/ciel_runtime_support/prompt_compaction.py +144 -0
  60. package/ciel_runtime_support/prompt_injection.py +45 -0
  61. package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +1 -1
  62. package/ciel_runtime_support/protocols/chat_projection.py +85 -5
  63. package/ciel_runtime_support/protocols/conversation_turn_policy.py +43 -0
  64. package/ciel_runtime_support/protocols/ollama_chat.py +31 -0
  65. package/ciel_runtime_support/protocols/ollama_response.py +57 -5
  66. package/ciel_runtime_support/protocols/openai_reasoning.py +5 -2
  67. package/ciel_runtime_support/protocols/openai_responses.py +61 -15
  68. package/ciel_runtime_support/provider_adapters.py +26 -0
  69. package/ciel_runtime_support/provider_administration_context.py +207 -0
  70. package/ciel_runtime_support/provider_config_mutations.py +3 -0
  71. package/ciel_runtime_support/provider_model_catalog_context.py +137 -0
  72. package/ciel_runtime_support/provider_model_context.py +107 -0
  73. package/ciel_runtime_support/provider_model_metadata_context.py +197 -0
  74. package/ciel_runtime_support/provider_model_selection.py +10 -3
  75. package/ciel_runtime_support/provider_models.py +45 -2
  76. package/ciel_runtime_support/provider_option_cli.py +19 -0
  77. package/ciel_runtime_support/provider_policy.py +1 -1
  78. package/ciel_runtime_support/provider_readiness_context.py +189 -0
  79. package/ciel_runtime_support/provider_request_builder.py +64 -28
  80. package/ciel_runtime_support/provider_responses_passthrough.py +21 -2
  81. package/ciel_runtime_support/provider_timeout_policy.py +54 -0
  82. package/ciel_runtime_support/provider_tool_policy.py +9 -1
  83. package/ciel_runtime_support/providers/__init__.py +6 -0
  84. package/ciel_runtime_support/providers/alibaba.py +634 -0
  85. package/ciel_runtime_support/providers/catalog.py +24 -16
  86. package/ciel_runtime_support/providers/deepseek.py +73 -0
  87. package/ciel_runtime_support/providers/github_copilot_oauth.py +22 -1
  88. package/ciel_runtime_support/providers/kimi.py +69 -9
  89. package/ciel_runtime_support/providers/ollama.py +8 -0
  90. package/ciel_runtime_support/providers/ollama_context.py +21 -2
  91. package/ciel_runtime_support/providers/vllm.py +7 -1
  92. package/ciel_runtime_support/response_collection.py +68 -18
  93. package/ciel_runtime_support/response_collection_context.py +391 -0
  94. package/ciel_runtime_support/response_stream_context.py +555 -0
  95. package/ciel_runtime_support/responses_input_compatibility.py +121 -0
  96. package/ciel_runtime_support/responses_usage_observer.py +83 -0
  97. package/ciel_runtime_support/router_client_lifecycle.py +1 -0
  98. package/ciel_runtime_support/router_http.py +239 -3
  99. package/ciel_runtime_support/router_observability_context.py +251 -0
  100. package/ciel_runtime_support/router_process_context.py +200 -0
  101. package/ciel_runtime_support/router_process_lifecycle.py +2 -0
  102. package/ciel_runtime_support/router_request_assembly.py +399 -0
  103. package/ciel_runtime_support/router_request_context.py +215 -0
  104. package/ciel_runtime_support/router_server_context.py +82 -0
  105. package/ciel_runtime_support/runaway_output_guard.py +488 -0
  106. package/ciel_runtime_support/runtime_asset_assembly.py +147 -0
  107. package/ciel_runtime_support/runtime_asset_context.py +297 -0
  108. package/ciel_runtime_support/runtime_constants.py +16 -1
  109. package/ciel_runtime_support/runtime_launch.py +9 -5
  110. package/ciel_runtime_support/runtime_launch_context.py +130 -0
  111. package/ciel_runtime_support/runtime_maintenance_assembly.py +60 -0
  112. package/ciel_runtime_support/runtime_maintenance_context.py +309 -0
  113. package/ciel_runtime_support/runtime_maintenance_services.py +265 -0
  114. package/ciel_runtime_support/runtime_paths.py +60 -40
  115. package/ciel_runtime_support/runtime_primitives.py +78 -0
  116. package/ciel_runtime_support/sse_stream_collection.py +236 -0
  117. package/ciel_runtime_support/statusline_script.py +57 -8
  118. package/ciel_runtime_support/streaming_anthropic.py +361 -24
  119. package/ciel_runtime_support/tool_schema.py +40 -2
  120. package/ciel_runtime_support/tool_side_effect_dedupe.py +117 -12
  121. package/ciel_runtime_support/upstream_dump.py +68 -0
  122. package/ciel_runtime_support/upstream_retry_context.py +259 -0
  123. package/ciel_runtime_support/workspace_router_selection.py +86 -0
  124. package/docs/Configuration.md +50 -0
  125. package/docs/Test-Suite.md +1 -0
  126. package/package.json +1 -1
@@ -0,0 +1,338 @@
1
+ """LLM preset identity, recommendation, and presentation bounded context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import re
7
+ from typing import Any, Callable, Mapping
8
+
9
+ from .architecture import ProviderContextPolicy
10
+ from . import llm_presets
11
+ from .llm_presets import PresetIdentityPolicy
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class LlmPresetCatalog:
16
+ presets: Mapping[str, tuple[str, str]]
17
+ preset_i18n: Mapping[str, Mapping[str, tuple[str, str]]]
18
+ family_i18n: Mapping[str, Mapping[str, str]]
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class LlmPresetQueries:
23
+ load_config: Callable[[], dict[str, Any]]
24
+ context_policy: Callable[[str, dict[str, Any]], ProviderContextPolicy]
25
+ context_capacity: Callable[[str, dict[str, Any]], int | None]
26
+ context_services: Callable[[str], Any]
27
+ ui_text: Callable[[str, str | None], str]
28
+ pad_cells: Callable[[str, int], str]
29
+ format_context: Callable[[int | None], str]
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class LlmPresetAlgorithms:
34
+ classify_family: Callable[..., str]
35
+ recommend: Callable[[str, int | None], str]
36
+ infer: Callable[..., str | None]
37
+ required_context: Callable[[str, ProviderContextPolicy], int | None]
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class LlmPresetDefinitionPorts:
42
+ context_heavy_presets: set[str] | frozenset[str]
43
+ llm_presets: Mapping[str, tuple[str, str]]
44
+ preset_text: Callable[..., tuple[str, str]]
45
+ load_config: Callable[[], dict[str, Any]]
46
+ family_text: Callable[..., str]
47
+ model_family: Callable[[str, dict[str, Any]], str]
48
+ positive_int: Callable[[Any], int | None]
49
+ required_context: Callable[[str, str | None], int | None]
50
+ ui_text: Callable[[str, str | None], str]
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class LlmPresetContextPolicyPorts:
55
+ lm_studio_guard: Callable[..., list[str]]
56
+ ollama_output_guard: Callable[..., list[str]]
57
+ recommended_timeout: Callable[..., list[str]]
58
+ cap_context: Callable[..., list[str]]
59
+ cap_output: Callable[..., list[str]]
60
+ ollama_context_status: Callable[..., str]
61
+ model_capacity: Callable[..., int | None]
62
+ sync_ollama_context: Callable[..., list[str]]
63
+ upstream_context_limit: Callable[..., int | None]
64
+ timeout_tokens: Callable[..., list[str]]
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class LlmPresetMutationPorts:
69
+ apply_ollama_option: Callable[..., list[str]]
70
+ apply_provider_option: Callable[..., list[str]]
71
+ ollama_options: Callable[[dict[str, Any]], dict[str, Any]]
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class LlmPresetContext:
76
+ catalog: LlmPresetCatalog
77
+ queries: LlmPresetQueries
78
+ algorithms: LlmPresetAlgorithms
79
+ definition: LlmPresetDefinitionPorts
80
+ policy: LlmPresetContextPolicyPorts
81
+ mutation: LlmPresetMutationPorts
82
+
83
+ def model_family(self, provider: str, config: dict[str, Any]) -> str:
84
+ return self.algorithms.classify_family(
85
+ config,
86
+ self.queries.context_policy(provider, config),
87
+ self.queries.context_capacity(provider, config),
88
+ self.queries.context_services(provider),
89
+ )
90
+
91
+ def recommended(self, provider: str, config: dict[str, Any]) -> str:
92
+ return self.algorithms.recommend(
93
+ self.model_family(provider, config),
94
+ self.queries.context_capacity(provider, config),
95
+ )
96
+
97
+ def slider_ids(self) -> list[str]:
98
+ return list(self.catalog.presets)
99
+
100
+ @staticmethod
101
+ def command_name(preset_id: str) -> str:
102
+ normalized = re.sub(
103
+ r"[^a-z0-9]+", "-", str(preset_id or "").lower()
104
+ ).strip("-")
105
+ return f"llm-{normalized}"
106
+
107
+ def slash_command(self, preset_id: str) -> str:
108
+ label, description = self.text(preset_id, "en")
109
+ return f"""---
110
+ description: Apply ciel-runtime live preset: {label}
111
+ argument-hint: [ignored]
112
+ ---
113
+
114
+ CIEL_RUNTIME_LIVE_LLM_OPTIONS
115
+
116
+ Value: {preset_id}
117
+
118
+ Apply the ciel-runtime live LLM preset `{preset_id}` ({description}) to this routed session. The original options are captured before the first live preset change and can be restored with `/llm-restore`.
119
+ """
120
+
121
+ def resolve(self, value: str) -> str | None:
122
+ return PresetIdentityPolicy(self.catalog.presets, self.command_name).resolve(value)
123
+
124
+ def required_context(
125
+ self, preset_id: str, provider: str | None = None
126
+ ) -> int | None:
127
+ selected = provider or "anthropic"
128
+ return self.algorithms.required_context(
129
+ preset_id, self.queries.context_policy(selected, {})
130
+ )
131
+
132
+ def available(
133
+ self, provider: str, config: dict[str, Any], preset_id: str
134
+ ) -> bool:
135
+ required = self.required_context(preset_id, provider)
136
+ if not required:
137
+ return True
138
+ capacity = self.queries.context_capacity(provider, config)
139
+ return not capacity or required <= capacity
140
+
141
+ def infer(self, provider: str, config: dict[str, Any]) -> str | None:
142
+ return self.algorithms.infer(
143
+ config,
144
+ self.queries.context_policy(provider, config),
145
+ self.queries.context_services(provider),
146
+ )
147
+
148
+ def applied(self, provider: str, config: dict[str, Any]) -> str:
149
+ preset_id = str(config.get("llm_preset") or "").strip()
150
+ if preset_id in self.catalog.presets:
151
+ return preset_id
152
+ inferred = self.infer(provider, config)
153
+ if inferred and self.available(provider, config, inferred):
154
+ return inferred
155
+ recommended = self.recommended(provider, config)
156
+ return recommended if self.available(provider, config, recommended) else "balanced"
157
+
158
+ def text(self, preset_id: str, lang: str | None = None) -> tuple[str, str]:
159
+ language = lang or str(self.queries.load_config().get("language") or "en")
160
+ return self.catalog.preset_i18n.get(language, {}).get(
161
+ preset_id, self.catalog.presets[preset_id]
162
+ )
163
+
164
+ def family_text(self, family: str, lang: str | None = None) -> str:
165
+ language = lang or str(self.queries.load_config().get("language") or "en")
166
+ return self.catalog.family_i18n.get(language, {}).get(family, family)
167
+
168
+ def panel_rows(
169
+ self,
170
+ provider: str,
171
+ config: dict[str, Any],
172
+ lang: str | None = None,
173
+ ) -> tuple[list[str], list[str]]:
174
+ language = lang or str(self.queries.load_config().get("language") or "en")
175
+ recommended = self.recommended(provider, config)
176
+ applied = self.applied(provider, config)
177
+ family = self.model_family(provider, config)
178
+ recommended_label, _ = self.text(recommended, language)
179
+ rows = [
180
+ f"{self.queries.ui_text('model_family', language)}: "
181
+ f"{self.family_text(family, language)}; "
182
+ f"{self.queries.ui_text('recommended_preset_is', language)} "
183
+ f"{recommended_label}"
184
+ ]
185
+ values = ["__info__"]
186
+ for preset_id in self.catalog.presets:
187
+ label, description = self.text(preset_id, language)
188
+ mark = "*" if preset_id == applied else " "
189
+ suffix = ""
190
+ required = self.required_context(preset_id, provider)
191
+ capacity = (
192
+ self.queries.context_capacity(provider, config) if required else None
193
+ )
194
+ if required and capacity and required > capacity:
195
+ suffix = (
196
+ f" (requires {self.queries.format_context(required)}; "
197
+ f"server {self.queries.format_context(capacity)})"
198
+ )
199
+ rows.append(
200
+ f"{mark} {self.queries.pad_cells(label, 24)} {description}{suffix}"
201
+ )
202
+ values.append(preset_id)
203
+ rows.append(self.queries.ui_text("back", language))
204
+ values.append("back")
205
+ return rows, values
206
+
207
+ def apply(
208
+ self,
209
+ provider: str,
210
+ config: dict[str, Any],
211
+ preset_id: str,
212
+ lang: str | None = None,
213
+ *,
214
+ sync_ollama_context: bool = True,
215
+ load_lm_studio: bool = False,
216
+ ) -> list[str]:
217
+ return llm_presets.apply_preset_to_provider(
218
+ provider,
219
+ config,
220
+ preset_id,
221
+ lang,
222
+ sync_ollama_context=sync_ollama_context,
223
+ load_lm_studio=load_lm_studio,
224
+ services=llm_presets.PresetServices(
225
+ definition=llm_presets.PresetDefinition(
226
+ CONTEXT_HEAVY_PRESETS=self.definition.context_heavy_presets,
227
+ LLM_PRESETS=self.definition.llm_presets,
228
+ llm_preset_text=self.definition.preset_text,
229
+ load_config=self.definition.load_config,
230
+ model_family_text=self.definition.family_text,
231
+ model_option_family=self.definition.model_family,
232
+ positive_int=self.definition.positive_int,
233
+ required_context_for_preset=self.definition.required_context,
234
+ ui_text=self.definition.ui_text,
235
+ ),
236
+ context_policy=llm_presets.PresetContextPolicy(
237
+ apply_lm_studio_loaded_context_guard=self.policy.lm_studio_guard,
238
+ apply_ollama_runtime_output_guard=self.policy.ollama_output_guard,
239
+ apply_recommended_timeout_for_model_context=self.policy.recommended_timeout,
240
+ cap_context_settings_to_model_capacity=self.policy.cap_context,
241
+ cap_output_settings_to_context_ratio=self.policy.cap_output,
242
+ ollama_num_ctx_status=self.policy.ollama_context_status,
243
+ provider_model_context_capacity=self.policy.model_capacity,
244
+ sync_ollama_library_context_limit=self.policy.sync_ollama_context,
245
+ upstream_model_context_limit=self.policy.upstream_context_limit,
246
+ with_preset_timeout_tokens=self.policy.timeout_tokens,
247
+ ),
248
+ provider_mutation=llm_presets.PresetProviderMutation(
249
+ apply_ollama_option=self.mutation.apply_ollama_option,
250
+ apply_provider_option=self.mutation.apply_provider_option,
251
+ ollama_extra_options=self.mutation.ollama_options,
252
+ ),
253
+ ),
254
+ )
255
+
256
+
257
+ @dataclass(frozen=True, slots=True)
258
+ class LlmPresetCompatibilityApi:
259
+ context: Callable[[], LlmPresetContext]
260
+
261
+ def model_family(self, provider: str, config: dict[str, Any]) -> str:
262
+ return self.context().model_family(provider, config)
263
+
264
+ def recommended(self, provider: str, config: dict[str, Any]) -> str:
265
+ return self.context().recommended(provider, config)
266
+
267
+ def slider_ids(self) -> list[str]:
268
+ return self.context().slider_ids()
269
+
270
+ def command_name(self, preset_id: str) -> str:
271
+ return self.context().command_name(preset_id)
272
+
273
+ def slash_command(self, preset_id: str) -> str:
274
+ return self.context().slash_command(preset_id)
275
+
276
+ def resolve(self, value: str) -> str | None:
277
+ return self.context().resolve(value)
278
+
279
+ def required_context(
280
+ self, preset_id: str, provider: str | None = None
281
+ ) -> int | None:
282
+ return self.context().required_context(preset_id, provider)
283
+
284
+ def available(
285
+ self, provider: str, config: dict[str, Any], preset_id: str
286
+ ) -> bool:
287
+ return self.context().available(provider, config, preset_id)
288
+
289
+ def applied(self, provider: str, config: dict[str, Any]) -> str:
290
+ return self.context().applied(provider, config)
291
+
292
+ def infer(self, provider: str, config: dict[str, Any]) -> str | None:
293
+ return self.context().infer(provider, config)
294
+
295
+ def text(self, preset_id: str, lang: str | None = None) -> tuple[str, str]:
296
+ return self.context().text(preset_id, lang)
297
+
298
+ def family_text(self, family: str, lang: str | None = None) -> str:
299
+ return self.context().family_text(family, lang)
300
+
301
+ def panel_rows(
302
+ self,
303
+ provider: str,
304
+ config: dict[str, Any],
305
+ lang: str | None = None,
306
+ ) -> tuple[list[str], list[str]]:
307
+ return self.context().panel_rows(provider, config, lang)
308
+
309
+ def apply(
310
+ self,
311
+ provider: str,
312
+ config: dict[str, Any],
313
+ preset_id: str,
314
+ lang: str | None = None,
315
+ *,
316
+ sync_ollama_context: bool = True,
317
+ load_lm_studio: bool = False,
318
+ ) -> list[str]:
319
+ return self.context().apply(
320
+ provider,
321
+ config,
322
+ preset_id,
323
+ lang,
324
+ sync_ollama_context=sync_ollama_context,
325
+ load_lm_studio=load_lm_studio,
326
+ )
327
+
328
+
329
+ __all__ = [
330
+ "LlmPresetAlgorithms",
331
+ "LlmPresetCatalog",
332
+ "LlmPresetCompatibilityApi",
333
+ "LlmPresetContext",
334
+ "LlmPresetContextPolicyPorts",
335
+ "LlmPresetDefinitionPorts",
336
+ "LlmPresetMutationPorts",
337
+ "LlmPresetQueries",
338
+ ]
@@ -119,19 +119,23 @@ class ManagedMcpConfigService:
119
119
  return self.paths.channel
120
120
 
121
121
  def _fetch_command(self, web: dict[str, Any]) -> tuple[str | None, list[Any]]:
122
- arguments: list[Any] = [web.get("fetch_package") or "mcp-server-fetch"]
122
+ package = str(web.get("fetch_package") or "mcp-server-fetch")
123
+ arguments: list[Any] = [package]
123
124
  if web.get("fetch_user_agent"):
124
125
  arguments.extend(["--user-agent", str(web["fetch_user_agent"])])
125
126
  if web.get("fetch_ignore_robots_txt", False):
126
127
  arguments.append("--ignore-robots-txt")
128
+ dependency_args = ["--with", "mcp<2"] if package == "mcp-server-fetch" else []
127
129
  command = self.ports.find_executable("uvx")
128
130
  if command:
129
- return command, arguments
131
+ return command, [*dependency_args, *arguments]
130
132
  uv = self.ports.find_executable("uv")
131
133
  if uv:
132
- return uv, ["tool", "run", *arguments]
134
+ return uv, ["tool", "run", *dependency_args, *arguments]
133
135
  if importlib.util.find_spec("uv") is not None:
134
- return sys.executable, ["-m", "uv", "tool", "run", *arguments]
136
+ return sys.executable, [
137
+ "-m", "uv", "tool", "run", *dependency_args, *arguments
138
+ ]
135
139
  pipx = self.ports.find_executable("pipx")
136
140
  return (pipx, ["run", *arguments]) if pipx else (None, arguments)
137
141
 
@@ -0,0 +1,291 @@
1
+ """MCP configuration discovery and native-config projection context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any, Callable, Protocol
10
+
11
+ from .mcp_config_reader import ClaudeMcpConfigPathPolicy
12
+ from .managed_mcp_discovery import (
13
+ ManagedMcpDiscoveryPaths,
14
+ ManagedMcpDiscoveryPorts,
15
+ ManagedMcpDiscoveryService,
16
+ NativeMcpConfigWriter,
17
+ NativeMcpConfigWriterPorts,
18
+ )
19
+
20
+
21
+ class JsonArtifactRepository(Protocol):
22
+ def save(self, data: Any, operation: str) -> None: ...
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class McpConfigurationFilePorts:
27
+ read_items: Callable[..., list[Any]]
28
+ names_from_mapping: Callable[..., Any]
29
+ servers_from_mapping: Callable[..., Any]
30
+ log: Callable[[str, str], None]
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class McpConfigurationPaths:
35
+ home: Path
36
+ web_tools: Path
37
+ proxy: Path
38
+ native_config: Path
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class McpConfigurationRuntimePorts:
43
+ resolve_executable: Callable[[str], str]
44
+ parse_bool: Callable[..., bool]
45
+ artifact_repository: Callable[[Path], JsonArtifactRepository]
46
+ discover_channel_specs: Callable[..., list[str]]
47
+ is_channel_tagged: Callable[[str], bool]
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class McpConfigurationContext:
52
+ files: McpConfigurationFilePorts
53
+ paths: McpConfigurationPaths
54
+ runtime: McpConfigurationRuntimePorts
55
+ native_channel_names: frozenset[str]
56
+
57
+ def read_server_names(self, path: Path, cwd: Path) -> list[str]:
58
+ return self.files.read_items(
59
+ path,
60
+ cwd,
61
+ self.files.names_from_mapping,
62
+ str,
63
+ self.files.log,
64
+ )
65
+
66
+ def read_servers(
67
+ self, path: Path, cwd: Path
68
+ ) -> list[tuple[str, dict[str, Any]]]:
69
+ return self.files.read_items(
70
+ path,
71
+ cwd,
72
+ self.files.servers_from_mapping,
73
+ lambda item: item[0],
74
+ self.files.log,
75
+ )
76
+
77
+ def server_is_stdio(self, server: dict[str, Any]) -> bool:
78
+ if not isinstance(server, dict):
79
+ return False
80
+ server_type = str(server.get("type") or "").strip().lower()
81
+ if server_type and server_type not in ("stdio", "command"):
82
+ return False
83
+ command = self.runtime.resolve_executable(
84
+ str(server.get("command") or "").strip()
85
+ )
86
+ if not command:
87
+ return False
88
+ raw_args = server.get("args", [])
89
+ args = (
90
+ [str(item) for item in raw_args if item is not None]
91
+ if isinstance(raw_args, list)
92
+ else []
93
+ )
94
+ return "mcp-proxy" not in args
95
+
96
+ @staticmethod
97
+ def server_is_streamable_http(server: dict[str, Any]) -> bool:
98
+ if not isinstance(server, dict):
99
+ return False
100
+ server_type = str(
101
+ server.get("type") or server.get("transport") or ""
102
+ ).strip().lower()
103
+ if server_type not in {"http", "streamable-http"}:
104
+ return False
105
+ url = str(server.get("url") or server.get("endpoint") or "").strip()
106
+ return url.startswith(("http://", "https://"))
107
+
108
+ def server_force_proxy(self, server: dict[str, Any]) -> bool:
109
+ if not isinstance(server, dict):
110
+ return False
111
+ return self.runtime.parse_bool(
112
+ server.get(
113
+ "ciel_runtime_mcp_proxy",
114
+ server.get(
115
+ "ciel_runtime_force_mcp_proxy",
116
+ server.get("force_mcp_proxy", False),
117
+ ),
118
+ ),
119
+ False,
120
+ )
121
+
122
+ def server_disables_proxy_notifications(self, server: dict[str, Any]) -> bool:
123
+ if not isinstance(server, dict):
124
+ return False
125
+ return self.runtime.parse_bool(
126
+ server.get(
127
+ "ciel_runtime_disable_notification_stream",
128
+ server.get("ciel_runtime_disable_mcp_notifications", False),
129
+ ),
130
+ False,
131
+ )
132
+
133
+ @staticmethod
134
+ def safe_proxy_name(name: str) -> str:
135
+ safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", name.strip())
136
+ return safe[:80] or "server"
137
+
138
+ def config_paths(
139
+ self,
140
+ passthrough: list[str] | None = None,
141
+ cwd: Path | None = None,
142
+ home: Path | None = None,
143
+ ) -> list[Path]:
144
+ return ClaudeMcpConfigPathPolicy.paths(
145
+ passthrough or [], cwd or Path.cwd(), home or self.paths.home
146
+ )
147
+
148
+ def existing_config_paths(
149
+ self,
150
+ passthrough: list[str] | None = None,
151
+ cwd: Path | None = None,
152
+ home: Path | None = None,
153
+ ) -> list[Path]:
154
+ return ClaudeMcpConfigPathPolicy.existing_paths(
155
+ passthrough or [], cwd or Path.cwd(), home or self.paths.home
156
+ )
157
+
158
+ def discover_user_servers(
159
+ self,
160
+ passthrough: list[str] | None = None,
161
+ cwd: Path | None = None,
162
+ home: Path | None = None,
163
+ ) -> dict[str, dict[str, Any]]:
164
+ working_directory = cwd or Path.cwd()
165
+ servers: dict[str, dict[str, Any]] = {}
166
+ for path in self.existing_config_paths(
167
+ passthrough, working_directory, home
168
+ ):
169
+ for name, server in self.read_servers(path, working_directory):
170
+ servers.setdefault(name, server)
171
+ return servers
172
+
173
+ def read_generated_servers(
174
+ self, path: Path, cwd: Path
175
+ ) -> dict[str, dict[str, Any]]:
176
+ if not path.exists() or not path.is_file():
177
+ return {}
178
+ servers: dict[str, dict[str, Any]] = {}
179
+ for name, server in self.read_servers(path, cwd):
180
+ if name.strip().lower() in self.native_channel_names:
181
+ continue
182
+ servers.setdefault(name, server)
183
+ return servers
184
+
185
+ def discover_managed_servers(
186
+ self, cwd: Path | None = None
187
+ ) -> dict[str, dict[str, Any]]:
188
+ service = ManagedMcpDiscoveryService(
189
+ paths=ManagedMcpDiscoveryPaths(
190
+ web_tools=self.paths.web_tools,
191
+ proxy=self.paths.proxy,
192
+ ),
193
+ ports=ManagedMcpDiscoveryPorts(
194
+ read_generated=self.read_generated_servers,
195
+ load_json=lambda path: json.loads(path.read_text(encoding="utf-8")),
196
+ log=self.files.log,
197
+ ),
198
+ native_channel_names=frozenset(
199
+ name.casefold() for name in self.native_channel_names
200
+ ),
201
+ )
202
+ return service.discover(cwd or Path.cwd())
203
+
204
+ def write_native_config(
205
+ self,
206
+ passthrough: list[str] | None = None,
207
+ cwd: Path | None = None,
208
+ home: Path | None = None,
209
+ ) -> Path | None:
210
+ writer = NativeMcpConfigWriter(
211
+ self.paths.native_config,
212
+ NativeMcpConfigWriterPorts(
213
+ self.discover_user_servers,
214
+ self.discover_managed_servers,
215
+ lambda path, data, operation: self.runtime.artifact_repository(
216
+ path
217
+ ).save(data, operation),
218
+ self.files.log,
219
+ ),
220
+ )
221
+ return writer.write(passthrough, cwd, home)
222
+
223
+ def auto_discovered_channel_specs(
224
+ self,
225
+ passthrough: list[str] | None = None,
226
+ cwd: Path | None = None,
227
+ home: Path | None = None,
228
+ ) -> list[str]:
229
+ working_directory = cwd or Path.cwd()
230
+ return self.runtime.discover_channel_specs(
231
+ self.config_paths(passthrough, working_directory, home),
232
+ working_directory,
233
+ self.read_server_names,
234
+ self.runtime.is_channel_tagged,
235
+ )
236
+
237
+
238
+ @dataclass(frozen=True, slots=True)
239
+ class McpConfigurationCompatibilityApi:
240
+ context: Callable[[], McpConfigurationContext]
241
+
242
+ def read_server_names(self, *args: Any, **kwargs: Any) -> list[str]:
243
+ return self.context().read_server_names(*args, **kwargs)
244
+
245
+ def read_servers(self, *args: Any, **kwargs: Any) -> list[Any]:
246
+ return self.context().read_servers(*args, **kwargs)
247
+
248
+ def server_is_stdio(self, server: dict[str, Any]) -> bool:
249
+ return self.context().server_is_stdio(server)
250
+
251
+ def server_is_streamable_http(self, server: dict[str, Any]) -> bool:
252
+ return self.context().server_is_streamable_http(server)
253
+
254
+ def server_force_proxy(self, server: dict[str, Any]) -> bool:
255
+ return self.context().server_force_proxy(server)
256
+
257
+ def server_disables_proxy_notifications(self, server: dict[str, Any]) -> bool:
258
+ return self.context().server_disables_proxy_notifications(server)
259
+
260
+ def safe_proxy_name(self, name: str) -> str:
261
+ return self.context().safe_proxy_name(name)
262
+
263
+ def config_paths(self, *args: Any, **kwargs: Any) -> list[Path]:
264
+ return self.context().config_paths(*args, **kwargs)
265
+
266
+ def existing_config_paths(self, *args: Any, **kwargs: Any) -> list[Path]:
267
+ return self.context().existing_config_paths(*args, **kwargs)
268
+
269
+ def discover_user_servers(self, *args: Any, **kwargs: Any) -> dict[str, dict[str, Any]]:
270
+ return self.context().discover_user_servers(*args, **kwargs)
271
+
272
+ def read_generated_servers(self, *args: Any, **kwargs: Any) -> dict[str, dict[str, Any]]:
273
+ return self.context().read_generated_servers(*args, **kwargs)
274
+
275
+ def discover_managed_servers(self, *args: Any, **kwargs: Any) -> dict[str, dict[str, Any]]:
276
+ return self.context().discover_managed_servers(*args, **kwargs)
277
+
278
+ def write_native_config(self, *args: Any, **kwargs: Any) -> Path | None:
279
+ return self.context().write_native_config(*args, **kwargs)
280
+
281
+ def auto_discovered_channel_specs(self, *args: Any, **kwargs: Any) -> list[str]:
282
+ return self.context().auto_discovered_channel_specs(*args, **kwargs)
283
+
284
+
285
+ __all__ = [
286
+ "McpConfigurationCompatibilityApi",
287
+ "McpConfigurationContext",
288
+ "McpConfigurationFilePorts",
289
+ "McpConfigurationPaths",
290
+ "McpConfigurationRuntimePorts",
291
+ ]