1use anyhow::{bail, Context, Result};
2use serde_json::{json, Value};
3use soma_application::{dynamic_provider_registry, static_provider_registry, SomaService};
4use soma_client::SomaClient;
5use soma_config::SomaConfig;
6use soma_provider_core::ProviderCatalog;
7use std::{fs, path::Path};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10enum Mode {
11 Check,
12 Write,
13 CheckAndWrite,
14 Help,
15}
16
17impl Mode {
18 fn parse(args: &[String], usage: &str) -> Result<Self> {
19 let mut check = false;
20 let mut write = false;
21 for arg in args {
22 match arg.as_str() {
23 "--check" => check = true,
24 "--write" => write = true,
25 "--help" | "-h" => {
26 println!("{usage}");
27 return Ok(Self::Help);
28 }
29 unknown => bail!("unknown option: {unknown}"),
30 }
31 }
32 Ok(match (check, write) {
33 (false, false) | (true, false) => Self::Check,
34 (false, true) => Self::Write,
35 (true, true) => Self::CheckAndWrite,
36 })
37 }
38
39 fn should_check(self) -> bool {
40 matches!(self, Self::Check | Self::CheckAndWrite)
41 }
42
43 fn should_write(self) -> bool {
44 matches!(self, Self::Write | Self::CheckAndWrite)
45 }
46}
47
48pub fn check_palette_manifest(args: &[String]) -> Result<()> {
49 let mode = Mode::parse(
50 args,
51 "Usage: cargo xtask check-palette-manifest [--check] [--write]",
52 )?;
53 let root = std::env::current_dir().context("failed to read cwd")?;
54 let rendered = canonical_json(&render_palette_manifest()?)?;
55 let out = root.join("docs/generated/palette-manifest.json");
56
57 if mode.should_write() {
58 if let Some(parent) = out.parent() {
59 fs::create_dir_all(parent)
60 .with_context(|| format!("failed to create {}", parent.display()))?;
61 }
62 fs::write(&out, &rendered).with_context(|| format!("failed to write {}", out.display()))?;
63 println!("wrote {}", relative_display(&root, &out));
64 }
65
66 if mode.should_check() {
67 if !out.exists() {
68 bail!("docs/generated/palette-manifest.json is missing; run cargo xtask check-palette-manifest --write");
69 }
70 let current = fs::read_to_string(&out)
71 .with_context(|| format!("failed to read {}", out.display()))?;
72 if current != rendered {
73 bail!("docs/generated/palette-manifest.json is stale; run cargo xtask check-palette-manifest --write");
74 }
75 println!("Palette manifest is current");
76 }
77 Ok(())
78}
79
80pub fn provider_surfaces(args: &[String]) -> Result<()> {
81 let mode = Mode::parse(
82 args,
83 "Usage: cargo xtask generate-provider-surfaces [--check] [--write]",
84 )?;
85 let root = std::env::current_dir().context("failed to read cwd")?;
86 let snapshot = render_provider_snapshot()?;
87 let files = [
88 (
89 root.join("docs/generated/provider-surfaces.json"),
90 canonical_json(&snapshot)?,
91 ),
92 (
93 root.join("docs/generated/provider-surfaces.md"),
94 render_provider_docs(&snapshot)?,
95 ),
96 (
97 root.join("docs/generated/plugin.json"),
98 canonical_json(&render_distribution_plugin(&snapshot))?,
99 ),
100 (
101 root.join(".agents/plugins/marketplace.json"),
102 canonical_json(&render_codex_marketplace())?,
103 ),
104 (
105 root.join(".claude-plugin/marketplace.json"),
106 canonical_json(&render_claude_marketplace())?,
107 ),
108 ];
109
110 for (path, content) in files {
111 if mode.should_write() {
112 write_if_changed(&path, &content)?;
113 println!("wrote {}", relative_display(&root, &path));
114 }
115 if mode.should_check() {
116 if !path.exists() {
117 bail!(
118 "{} is missing; run cargo xtask generate-provider-surfaces --write",
119 relative_display(&root, &path)
120 );
121 }
122 let current = fs::read_to_string(&path)
123 .with_context(|| format!("failed to read {}", path.display()))?;
124 if current != content {
125 bail!(
126 "{} is stale; run cargo xtask generate-provider-surfaces --write",
127 relative_display(&root, &path)
128 );
129 }
130 }
131 }
132 write_or_check_generated_skills(&root, &snapshot, mode)?;
133 if mode.should_check() {
134 println!("Provider surface artifacts are current");
135 }
136 Ok(())
137}
138
139fn render_palette_manifest() -> Result<Value> {
140 let client = SomaClient::new(&SomaConfig {
141 api_url: String::new(),
142 api_key: "xtask".to_owned(),
143 ..SomaConfig::default()
144 })?;
145 let service = SomaService::new(client);
146 let registry = static_provider_registry(service)?;
147 let snapshot = registry.snapshot();
148 Ok(json!({
149 "schema_version": 1,
150 "provider_fingerprint": snapshot.fingerprint,
151 "commands": snapshot.action_names(),
152 "builtins": {
153 "file_explorer": false,
154 "github": false,
155 "browser": false,
156 "terminal": false
157 },
158 "limits": {
159 "max_inline_schema_bytes": 16384,
160 "max_examples_per_command": 3
161 }
162 }))
163}
164
165fn render_provider_snapshot() -> Result<Value> {
166 let provider_dir = provider_dir();
167 let client = SomaClient::new(&SomaConfig {
168 api_url: String::new(),
169 api_key: "xtask".to_owned(),
170 ..SomaConfig::default()
171 })?;
172 let service = SomaService::new(client);
173 let registry = dynamic_provider_registry(service)?;
174 let snapshot = registry.refresh_file_providers()?;
175 Ok(json!({
176 "schema_version": 1,
177 "provider_fingerprint": snapshot.fingerprint,
178 "provider_execution_abi": {
179 "schema_version": 1,
180 "request_fields": ["schema_version", "provider", "action", "params", "surface", "snapshot_id"],
181 "wasm_manifest_sources": ["<provider>.wasm.json", "soma.provider custom section"]
182 },
183 "operator_commands": {
184 "validate": "soma providers validate",
185 "inspect": "soma providers inspect",
186 "test": "soma providers test ACTION --json '{...}'",
187 "regenerate": "cargo xtask generate-provider-surfaces --write",
188 "check": "cargo xtask generate-provider-surfaces --check"
189 },
190 "providers": snapshot.catalogs.iter().map(provider_summary).collect::<Vec<_>>(),
191 "surfaces": {
192 "mcp_actions": surface_actions(&snapshot.catalogs, Surface::Mcp),
193 "cli_actions": surface_actions(&snapshot.catalogs, Surface::Cli),
194 "cli_commands": cli_commands(&snapshot.catalogs),
195 "rest_routes": rest_routes(&snapshot.catalogs),
196 "docs": "docs/generated/provider-surfaces.md",
197 "plugin": "docs/generated/plugin.json",
198 "codex_marketplace": ".agents/plugins/marketplace.json",
199 "claude_marketplace": ".claude-plugin/marketplace.json",
200 "node_package": "packages/soma-rmcp/package.json",
201 "provider_dir": provider_dir.display().to_string(),
202 "provider_files": provider_files(&provider_dir)?,
203 "generated_skills": generated_skill_paths(&snapshot.catalogs),
204 "palette": "deferred until Axon tauri-palette port lands"
205 }
206 }))
207}
208
209fn provider_summary(catalog: &ProviderCatalog) -> Value {
210 json!({
211 "name": catalog.provider.name,
212 "kind": catalog.provider.kind.as_str(),
213 "title": catalog.provider.title,
214 "description": catalog.provider.description,
215 "when_to_use": catalog.docs.as_ref().and_then(|docs| docs.when_to_use.clone()),
216 "tools": catalog.tools.iter().map(|tool| json!({
217 "name": tool.name,
218 "description": tool.description,
219 "input_schema": tool.input_schema,
220 "output_schema": tool.output_schema,
221 "scope": tool.scope,
222 "destructive": tool.destructive,
223 "requires_admin": tool.requires_admin,
224 "cost": tool.cost,
225 "env": tool.env.iter().map(|env| json!({
226 "name": env.name,
227 "required": env.required,
228 "sensitive": env.sensitive,
229 "server_prefixed": env.server_prefixed,
230 "allow_unprefixed": env.allow_unprefixed,
231 "description": env.description,
232 })).collect::<Vec<_>>(),
233 "mcp": tool.mcp.as_ref().map(|mcp| mcp.enabled).unwrap_or(true),
234 "cli": tool.cli.as_ref().map(|cli| cli.enabled).unwrap_or(false),
235 "cli_command": tool.cli.as_ref().filter(|cli| cli.enabled).and_then(|cli| cli.command.clone()).unwrap_or_else(|| if tool.cli.as_ref().map(|cli| cli.enabled).unwrap_or(false) { tool.name.clone() } else { "N/A".to_owned() }),
236 "cli_aliases": tool.cli.as_ref().map(|cli| cli.aliases.clone()).unwrap_or_default(),
237 "cli_flags": tool.cli.as_ref().map(|cli| cli.flags.clone()).unwrap_or_default(),
238 "cli_default_output": tool.cli.as_ref().and_then(|cli| cli.default_output.clone()),
239 "cli_usage": tool.meta.get("cli_usage").and_then(Value::as_str).map(ToOwned::to_owned),
240 "rest": rest_enabled(tool),
241 "rest_route": rest_route(tool),
242 "examples": tool.examples,
243 "meta": tool.meta,
244 })).collect::<Vec<_>>(),
245 "prompts": catalog.prompts.iter().map(|prompt| json!({
246 "name": prompt.name,
247 "description": prompt.description,
248 "arguments_schema": prompt.arguments_schema,
249 })).collect::<Vec<_>>(),
250 "resources": catalog.resources.iter().map(|resource| json!({
251 "name": resource.name,
252 "description": resource.description,
253 "uri_template": resource.uri_template,
254 "mime_type": resource.mime_type,
255 })).collect::<Vec<_>>(),
256 "tasks": catalog.tasks.iter().map(|task| json!({
257 "name": task.name,
258 "description": task.description,
259 "input_schema": task.input_schema,
260 "output_schema": task.output_schema,
261 })).collect::<Vec<_>>(),
262 "elicitation": catalog.elicitation.iter().map(|elicitation| json!({
263 "name": elicitation.name,
264 "description": elicitation.description,
265 "schema": elicitation.schema,
266 })).collect::<Vec<_>>(),
267 })
268}
269
270fn render_provider_docs(snapshot: &Value) -> Result<String> {
271 let mut out = String::from("# Generated Provider Surfaces\n\n");
272 out.push_str("Generated by `cargo xtask generate-provider-surfaces`. Do not edit by hand.\n\n");
273 out.push_str(&format!(
274 "- Provider fingerprint: `{}`\n",
275 snapshot["provider_fingerprint"].as_str().unwrap_or("")
276 ));
277 out.push_str("- Palette surface: deferred until the Axon tauri-palette port lands.\n\n");
278 out.push_str("## Contract Gate\n\n");
279 out.push_str("- Validate locally with `soma providers validate`.\n");
280 out.push_str("- Inspect manifests and capability posture with `soma providers inspect`.\n");
281 out.push_str("- Smoke one action with `soma providers test ACTION --json '{...}'`.\n");
282 out.push_str(
283 "- Regenerate this artifact with `cargo xtask generate-provider-surfaces --write`.\n",
284 );
285 out.push_str(
286 "- CI/static checks should run `cargo xtask generate-provider-surfaces --check`.\n\n",
287 );
288 out.push_str("## Execution ABI\n\n");
289 out.push_str("Provider runtimes receive a versioned JSON request with `schema_version`, `provider`, `action`, `params`, `surface`, and `snapshot_id`.\n\n");
290 out.push_str("Wasm provider manifests may come from `<provider>.wasm.json` or the embedded `soma.provider` custom section.\n\n");
291 out.push_str("## Providers\n\n");
292 for provider in snapshot["providers"].as_array().into_iter().flatten() {
293 out.push_str(&format!(
294 "### `{}` ({})\n\n",
295 provider["name"].as_str().unwrap_or("unknown"),
296 provider["kind"].as_str().unwrap_or("unknown")
297 ));
298 if let Some(description) = provider["description"].as_str() {
299 out.push_str(description);
300 out.push_str("\n\n");
301 }
302 out.push_str("| tool | MCP | CLI | REST | purpose |\n|---|---:|---:|---:|---|\n");
303 for tool in provider["tools"].as_array().into_iter().flatten() {
304 out.push_str(&format!(
305 "| `{}` | {} | {} | {} | {} |\n",
306 tool["name"].as_str().unwrap_or(""),
307 yes_no(tool["mcp"].as_bool().unwrap_or(false)),
308 yes_no(tool["cli"].as_bool().unwrap_or(false)),
309 yes_no(tool["rest"].as_bool().unwrap_or(false)),
310 tool["description"]
311 .as_str()
312 .unwrap_or("")
313 .replace('|', "\\|"),
314 ));
315 }
316 out.push('\n');
317 }
318 Ok(out)
319}
320
321fn render_provider_skill(provider: &Value) -> Result<String> {
322 let name = provider["name"].as_str().unwrap_or("provider");
323 let description = provider["description"]
324 .as_str()
325 .unwrap_or("Generated provider skill.");
326 let action_names = provider["tools"]
327 .as_array()
328 .into_iter()
329 .flatten()
330 .filter_map(|tool| tool["name"].as_str())
331 .take(4)
332 .collect::<Vec<_>>()
333 .join(", ");
334 let when_to_use = provider["when_to_use"]
335 .as_str()
336 .filter(|value| !value.trim().is_empty())
337 .map(ToOwned::to_owned)
338 .unwrap_or_else(|| {
339 if action_names.is_empty() {
340 format!("Use when working with the `{name}` provider.")
341 } else {
342 format!("Use when working with `{name}` provider actions such as {action_names}.")
343 }
344 });
345 let mut out = format!(
346 "---\nname: {name}\ndescription: {}\n---\n\nGenerated by `cargo xtask generate-provider-surfaces` from the current provider catalog.\n\n# `{name}` Provider\n\n{description}\n\n## When To Use\n\n{when_to_use}\n\n## Surface Selection\n\n",
347 yaml_string(&when_to_use),
348 );
349 out.push_str(
350 "- Use MCP first when the server is connected, especially for MCP-only actions.\n",
351 );
352 out.push_str("- Use CLI only when the action's CLI surface is `yes`; do not invent commands for `N/A` entries.\n");
353 out.push_str("- Use REST only when the action's REST surface is `yes`; send JSON bodies matching the action schema.\n");
354 out.push_str("- MCP-only elicitation actions require an elicitation-capable MCP client; do not attempt CLI or REST fallbacks.\n\n");
355 out.push_str("## Tools\n\n");
356 out.push_str("| tool | MCP | CLI | REST | CLI command | REST route | purpose |\n");
357 out.push_str("|---|---:|---:|---:|---|---|---|\n");
358 for tool in provider["tools"].as_array().into_iter().flatten() {
359 out.push_str(&format!(
360 "| `{}` | {} | {} | {} | `{}` | `{}` | {} |\n",
361 tool["name"].as_str().unwrap_or(""),
362 yes_no(tool["mcp"].as_bool().unwrap_or(false)),
363 yes_no(tool["cli"].as_bool().unwrap_or(false)),
364 yes_no(tool["rest"].as_bool().unwrap_or(false)),
365 tool["cli_command"].as_str().unwrap_or(""),
366 tool["rest_route"].as_str().unwrap_or(""),
367 tool["description"]
368 .as_str()
369 .unwrap_or("")
370 .replace('|', "\\|"),
371 ));
372 }
373 out.push_str("\n## Action Reference\n\n");
374 for tool in provider["tools"].as_array().into_iter().flatten() {
375 render_tool_reference(&mut out, tool);
376 }
377 render_primitive_section(&mut out, "Prompts", &provider["prompts"]);
378 render_primitive_section(&mut out, "Resources", &provider["resources"]);
379 render_primitive_section(&mut out, "Tasks", &provider["tasks"]);
380 render_primitive_section(&mut out, "Elicitation", &provider["elicitation"]);
381 while out.ends_with("\n\n") {
382 out.pop();
383 }
384 Ok(out)
385}
386
387fn render_tool_reference(out: &mut String, tool: &Value) {
388 let name = tool["name"].as_str().unwrap_or("");
389 out.push_str(&format!("### `{name}`\n\n"));
390 out.push_str(tool["description"].as_str().unwrap_or(""));
391 out.push_str("\n\n");
392 out.push_str(&format!(
393 "- Scope: `{}`\n",
394 tool["scope"].as_str().unwrap_or("public/default")
395 ));
396 out.push_str(&format!(
397 "- Cost: `{}`\n",
398 tool["cost"].as_str().unwrap_or("unspecified")
399 ));
400 out.push_str(&format!(
401 "- Destructive: `{}`\n",
402 tool["destructive"].as_bool().unwrap_or(false)
403 ));
404 out.push_str(&format!(
405 "- Requires admin: `{}`\n",
406 tool["requires_admin"].as_bool().unwrap_or(false)
407 ));
408 out.push_str(&format!(
409 "- Required args: `{}`\n",
410 schema_required_args(&tool["input_schema"])
411 ));
412 out.push_str(&format!(
413 "- Optional args: `{}`\n",
414 schema_optional_args(&tool["input_schema"])
415 ));
416 out.push_str(&format!("- Output: `{}`\n", output_summary(tool)));
417 out.push_str(&format!("- MCP: `soma(action=\"{name}\")`\n"));
418 if tool["cli"].as_bool().unwrap_or(false) {
419 out.push_str(&format!(
420 "- CLI: `soma {}`\n",
421 cli_usage(tool)
422 .unwrap_or_else(|| tool["cli_command"].as_str().unwrap_or(name).to_owned())
423 ));
424 if let Some(flags) = cli_flags_summary(tool) {
425 out.push_str(&format!("- CLI flags: {flags}\n"));
426 }
427 if let Some(aliases) = tool["cli_aliases"].as_array() {
428 let aliases = aliases
429 .iter()
430 .filter_map(Value::as_str)
431 .collect::<Vec<_>>()
432 .join(", ");
433 if !aliases.is_empty() {
434 out.push_str(&format!("- CLI aliases: `{aliases}`\n"));
435 }
436 }
437 } else {
438 out.push_str("- CLI: `N/A` - do not call this action from CLI.\n");
439 }
440 if tool["rest"].as_bool().unwrap_or(false) {
441 out.push_str(&format!(
442 "- REST: `{}`\n",
443 tool["rest_route"].as_str().unwrap_or("")
444 ));
445 } else {
446 out.push_str("- REST: `N/A` - do not invent an HTTP route.\n");
447 }
448 if let Some(env) = env_summary(tool) {
449 out.push_str(&format!("- Env: {env}\n"));
450 }
451 if let Some(examples) = examples_summary(tool) {
452 out.push_str(&format!("- Examples: {examples}\n"));
453 }
454 if let Some(fallback) = scaffold_fallback_summary(tool) {
455 out.push_str(&fallback);
456 }
457 out.push('\n');
458}
459
460fn yaml_string(value: &str) -> String {
461 serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_owned())
462}
463
464fn schema_required_args(schema: &Value) -> String {
465 let Some(required) = schema["required"].as_array() else {
466 return "none".to_owned();
467 };
468 let args = required
469 .iter()
470 .filter_map(Value::as_str)
471 .map(|name| format!("{name}: {}", schema_property_type(schema, name)))
472 .collect::<Vec<_>>();
473 if args.is_empty() {
474 "none".to_owned()
475 } else {
476 args.join(", ")
477 }
478}
479
480fn schema_optional_args(schema: &Value) -> String {
481 let required = schema["required"]
482 .as_array()
483 .into_iter()
484 .flatten()
485 .filter_map(Value::as_str)
486 .collect::<std::collections::BTreeSet<_>>();
487 let Some(properties) = schema["properties"].as_object() else {
488 return "none".to_owned();
489 };
490 let args = properties
491 .keys()
492 .filter(|name| !required.contains(name.as_str()))
493 .map(|name| format!("{name}: {}", schema_property_type(schema, name)))
494 .collect::<Vec<_>>();
495 if args.is_empty() {
496 "none".to_owned()
497 } else {
498 args.join(", ")
499 }
500}
501
502fn schema_property_type(schema: &Value, name: &str) -> String {
503 let property = &schema["properties"][name];
504 property["type"]
505 .as_str()
506 .or_else(|| property["format"].as_str())
507 .or_else(|| property["$ref"].as_str())
508 .unwrap_or("value")
509 .to_owned()
510}
511
512fn output_summary(tool: &Value) -> String {
513 if let Some(returns) = tool["meta"]["returns"].as_str() {
514 return returns.to_owned();
515 }
516 schema_output_summary(&tool["output_schema"])
517}
518
519fn schema_output_summary(schema: &Value) -> String {
520 if schema.is_null() {
521 return "unspecified".to_owned();
522 }
523 if let Some(properties) = schema["properties"].as_object() {
524 let fields = properties
525 .keys()
526 .take(6)
527 .map(|name| format!("{name}: {}", schema_property_type(schema, name)))
528 .collect::<Vec<_>>();
529 if !fields.is_empty() {
530 return fields.join(", ");
531 }
532 }
533 schema["type"]
534 .as_str()
535 .or_else(|| schema["$ref"].as_str())
536 .unwrap_or("structured JSON")
537 .to_owned()
538}
539
540fn cli_usage(tool: &Value) -> Option<String> {
541 tool["cli_usage"]
542 .as_str()
543 .map(|usage| usage.strip_prefix("soma ").unwrap_or(usage).to_owned())
544}
545
546fn cli_flags_summary(tool: &Value) -> Option<String> {
547 let flags = tool["cli_flags"].as_array()?;
548 let rendered = flags
549 .iter()
550 .filter_map(|flag| {
551 let name = flag["name"].as_str()?;
552 let value_name = flag["value_name"]
553 .as_str()
554 .map(|value| format!(" {value}"))
555 .unwrap_or_default();
556 let required = if flag["required"].as_bool().unwrap_or(false) {
557 " required"
558 } else {
559 " optional"
560 };
561 Some(format!("`{name}{value_name}`{required}"))
562 })
563 .collect::<Vec<_>>();
564 (!rendered.is_empty()).then(|| rendered.join(", "))
565}
566
567fn scaffold_fallback_summary(tool: &Value) -> Option<String> {
568 let fallback = &tool["meta"]["scaffold_fallback"];
569 if fallback.is_null() {
570 return None;
571 }
572 let skill = fallback["recommended_skill"].as_str()?;
573 let instructions = fallback["instructions"].as_str().unwrap_or("");
574 Some(format!(
575 "- Elicitation fallback: recommended_skill: `{skill}`. {instructions}\n"
576 ))
577}
578
579fn env_summary(tool: &Value) -> Option<String> {
580 let env = tool["env"].as_array()?;
581 let entries = env
582 .iter()
583 .filter_map(|item| {
584 let name = item["name"].as_str()?;
585 let mut qualifiers = Vec::new();
586 if item["required"].as_bool().unwrap_or(false) {
587 qualifiers.push("required");
588 }
589 if item["sensitive"].as_bool().unwrap_or(false) {
590 qualifiers.push("sensitive");
591 }
592 if qualifiers.is_empty() {
593 Some(format!("`{name}`"))
594 } else {
595 Some(format!("`{name}` ({})", qualifiers.join(", ")))
596 }
597 })
598 .collect::<Vec<_>>();
599 (!entries.is_empty()).then(|| entries.join(", "))
600}
601
602fn examples_summary(tool: &Value) -> Option<String> {
603 let examples = tool["examples"].as_array()?;
604 let rendered = examples
605 .iter()
606 .take(2)
607 .map(|example| {
608 let name = example["name"].as_str().unwrap_or("soma");
609 if let Some(args) = example["args"].as_object() {
610 let args = args
611 .keys()
612 .map(|key| format!("`{key}`"))
613 .collect::<Vec<_>>()
614 .join(", ");
615 format!("`{name}` args: {args}")
616 } else {
617 format!("`{name}`")
618 }
619 })
620 .collect::<Vec<_>>();
621 (!rendered.is_empty()).then(|| rendered.join("; "))
622}
623
624fn render_primitive_section(out: &mut String, title: &str, items: &Value) {
625 let Some(items) = items.as_array() else {
626 return;
627 };
628 if items.is_empty() {
629 return;
630 }
631 out.push_str(&format!("## {title}\n\n"));
632 for item in items {
633 let name = item["name"].as_str().unwrap_or("");
634 let description = item["description"].as_str().unwrap_or("");
635 out.push_str(&format!("- `{name}`: {description}\n"));
636 if let Some(uri) = item["uri_template"].as_str() {
637 out.push_str(&format!(" - URI template: `{uri}`\n"));
638 }
639 }
640 out.push('\n');
641}
642
643fn render_distribution_plugin(snapshot: &Value) -> Value {
644 json!({
645 "schema_version": 1,
646 "name": "soma",
647 "title": "Soma",
648 "description": "Generated distributable plugin surface for Soma.",
649 "publisher": {
650 "name": "dinglebear.ai",
651 "url": "https://dinglebear.ai"
652 },
653 "repository": "https://github.com/dinglebear-ai/soma",
654 "homepage": "https://soma.dinglebear.ai",
655 "website": "https://soma.dinglebear.ai",
656 "support": "https://github.com/dinglebear-ai/soma/issues",
657 "security_policy": "https://github.com/dinglebear-ai/soma/security/policy",
658 "license": "MIT",
659 "keywords": [
660 "mcp",
661 "mcp-server",
662 "model-context-protocol",
663 "rmcp",
664 "rust",
665 "agent-tools",
666 "ai-agents",
667 "provider-runtime",
668 "providers",
669 "developer-tools",
670 "automation",
671 "openapi",
672 "docker",
673 "cli",
674 "server-runtime",
675 "soma"
676 ],
677 "provider_fingerprint": snapshot["provider_fingerprint"].clone(),
678 "plugin_root": "plugins/soma",
679 "icons": {
680 "png": "plugins/soma/assets/icon.png",
681 "svg": "plugins/soma/assets/logo.svg"
682 },
683 "binaries": {
684 "cli": "soma",
685 "server": "soma"
686 },
687 "packages": {
688 "npm": "soma-rmcp",
689 "oci": "ghcr.io/dinglebear-ai/soma"
690 },
691 "runtime": {
692 "config_home": "~/.soma",
693 "container_data_dir": "/data",
694 "provider_dir_env": "SOMA_PROVIDER_DIR",
695 "default_provider_dir": "providers",
696 "default_http_endpoint": "http://127.0.0.1:40060/mcp",
697 "transports": ["stdio", "streamable-http"],
698 "auth_modes": ["loopback-dev", "bearer", "oauth", "trusted-gateway"]
699 },
700 "codex": {
701 "plugin_json": "plugins/soma/.codex-plugin/plugin.json",
702 "marketplace": ".agents/plugins/marketplace.json"
703 },
704 "claude": {
705 "plugin_json": "plugins/soma/.claude-plugin/plugin.json",
706 "marketplace": ".claude-plugin/marketplace.json"
707 },
708 "skills": "plugins/soma/skills",
709 "node_package": "packages/soma-rmcp/package.json",
710 "docs": "docs/generated/provider-surfaces.md",
711 "mcp_server": {
712 "manifest": "server.json",
713 "name": "ai.dinglebear/soma",
714 "registry_schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json"
715 },
716 "provider_files": snapshot["surfaces"]["provider_files"].clone(),
717 "surfaces": snapshot["surfaces"].clone(),
718 "providers": snapshot["providers"].clone()
719 })
720}
721
722fn generated_skill_paths(catalogs: &[ProviderCatalog]) -> Vec<String> {
723 let mut paths = catalogs
724 .iter()
725 .map(|catalog| format!("docs/generated/skills/{}/SKILL.md", catalog.provider.name))
726 .collect::<Vec<_>>();
727 paths.sort();
728 paths
729}
730
731fn write_or_check_generated_skills(root: &Path, snapshot: &Value, mode: Mode) -> Result<()> {
732 let skills_root = root.join("docs/generated/skills");
733 for provider in snapshot["providers"].as_array().into_iter().flatten() {
734 let name = provider["name"].as_str().unwrap_or("provider");
735 let path = skills_root.join(name).join("SKILL.md");
736 let content = render_provider_skill(provider)?;
737 if mode.should_write() {
738 write_if_changed(&path, &content)?;
739 println!("wrote {}", relative_display(root, &path));
740 }
741 if mode.should_check() {
742 if !path.exists() {
743 bail!(
744 "{} is missing; run cargo xtask generate-provider-surfaces --write",
745 relative_display(root, &path)
746 );
747 }
748 let current = fs::read_to_string(&path)
749 .with_context(|| format!("failed to read {}", path.display()))?;
750 if current != content {
751 bail!(
752 "{} is stale; run cargo xtask generate-provider-surfaces --write",
753 relative_display(root, &path)
754 );
755 }
756 }
757 }
758 Ok(())
759}
760
761fn render_codex_marketplace() -> Value {
762 json!({
763 "name": "soma",
764 "description": "Soma RMCP runtime plugins by dinglebear.ai.",
765 "owner": {
766 "name": "dinglebear.ai",
767 "url": "https://dinglebear.ai"
768 },
769 "homepage": "https://soma.dinglebear.ai",
770 "repository": "https://github.com/dinglebear-ai/soma",
771 "support": "https://github.com/dinglebear-ai/soma/issues",
772 "security_policy": "https://github.com/dinglebear-ai/soma/security/policy",
773 "license": "MIT",
774 "keywords": [
775 "mcp",
776 "mcp-server",
777 "model-context-protocol",
778 "rmcp",
779 "rust",
780 "agent-tools",
781 "ai-agents",
782 "provider-runtime",
783 "providers",
784 "developer-tools",
785 "automation",
786 "openapi",
787 "docker",
788 "cli",
789 "server-runtime",
790 "soma"
791 ],
792 "plugins": [{
793 "name": "soma",
794 "description": "Batteries-included RMCP runtime for drop-in provider-backed tools, prompts, and resources.",
795 "source": {
796 "source": "local",
797 "path": "./plugins/soma"
798 },
799 "policy": {
800 "installation": "AVAILABLE",
801 "authentication": "ON_INSTALL"
802 },
803 "category": "Infrastructure",
804 "interface": {
805 "displayName": "Soma",
806 "shortDescription": "Drop-in RMCP runtime.",
807 "developerName": "dinglebear.ai",
808 "brandColor": "#6366F1",
809 "composerIcon": "./plugins/soma/assets/icon.png",
810 "logo": "./plugins/soma/assets/logo.svg"
811 },
812 "metadata": {
813 "mcpServer": "server.json",
814 "nodePackage": "soma-rmcp",
815 "ociImage": "ghcr.io/dinglebear-ai/soma",
816 "binary": "soma"
817 }
818 }]
819 })
820}
821
822fn render_claude_marketplace() -> Value {
823 json!({
824 "$schema": "https://json.schemastore.org/claude-code-marketplace.json",
825 "name": "soma",
826 "description": "Generated marketplace catalog for Soma plugins.",
827 "owner": {
828 "name": "dinglebear.ai",
829 "url": "https://dinglebear.ai"
830 },
831 "homepage": "https://soma.dinglebear.ai",
832 "repository": "https://github.com/dinglebear-ai/soma",
833 "support": "https://github.com/dinglebear-ai/soma/issues",
834 "security_policy": "https://github.com/dinglebear-ai/soma/security/policy",
835 "license": "MIT",
836 "plugins": [{
837 "name": "soma",
838 "description": "Soma RMCP runtime plugin for drop-in provider-backed tools, prompts, and resources.",
839 "source": "./plugins/soma",
840 "category": "infrastructure",
841 "metadata": {
842 "mcpServer": "server.json",
843 "nodePackage": "soma-rmcp",
844 "ociImage": "ghcr.io/dinglebear-ai/soma",
845 "binary": "soma"
846 }
847 }]
848 })
849}
850
851#[derive(Debug, Clone, Copy)]
852enum Surface {
853 Mcp,
854 Cli,
855}
856
857fn surface_actions(catalogs: &[ProviderCatalog], surface: Surface) -> Vec<String> {
858 let mut actions = catalogs
859 .iter()
860 .flat_map(|catalog| catalog.tools.iter())
861 .filter_map(|tool| {
862 let enabled = match surface {
863 Surface::Mcp => tool.mcp.as_ref().map(|mcp| mcp.enabled).unwrap_or(true),
864 Surface::Cli => tool.cli.as_ref().map(|cli| cli.enabled).unwrap_or(false),
865 };
866 enabled.then(|| tool.name.clone())
867 })
868 .collect::<Vec<_>>();
869 actions.sort();
870 actions
871}
872
873fn rest_routes(catalogs: &[ProviderCatalog]) -> Vec<String> {
874 let mut routes = catalogs
875 .iter()
876 .flat_map(|catalog| catalog.tools.iter())
877 .filter(|tool| rest_enabled(tool))
878 .map(rest_route)
879 .collect::<Vec<_>>();
880 routes.sort();
881 routes
882}
883
884fn rest_enabled(tool: &soma_provider_core::ProviderTool) -> bool {
885 tool.rest.as_ref().map(|rest| rest.enabled).unwrap_or(true)
886}
887
888fn rest_route(tool: &soma_provider_core::ProviderTool) -> String {
889 let Some(rest) = tool.rest.as_ref().filter(|rest| rest.enabled) else {
890 return if rest_enabled(tool) {
891 format!("POST /v1/tools/{}", tool.name)
892 } else {
893 "N/A".to_owned()
894 };
895 };
896
897 format!(
898 "{} {}",
899 rest.method.as_deref().unwrap_or("POST"),
900 rest.path
901 .clone()
902 .unwrap_or_else(|| format!("/v1/tools/{}", tool.name))
903 )
904}
905
906fn cli_commands(catalogs: &[ProviderCatalog]) -> Vec<String> {
907 let mut commands = catalogs
908 .iter()
909 .flat_map(|catalog| catalog.tools.iter())
910 .flat_map(|tool| {
911 let Some(cli) = &tool.cli else {
912 return Vec::new();
913 };
914 if !cli.enabled {
915 return Vec::new();
916 }
917 let mut commands = vec![cli.command.clone().unwrap_or_else(|| tool.name.clone())];
918 commands.extend(cli.aliases.clone());
919 commands
920 })
921 .collect::<Vec<_>>();
922 commands.sort();
923 commands
924}
925
926fn provider_dir() -> std::path::PathBuf {
927 std::env::var_os("SOMA_PROVIDER_DIR")
928 .map(std::path::PathBuf::from)
929 .unwrap_or_else(|| std::path::PathBuf::from("providers"))
930}
931
932fn provider_files(provider_dir: &Path) -> Result<Vec<String>> {
933 if !provider_dir.exists() {
934 return Ok(Vec::new());
935 }
936 let label = provider_dir
937 .file_name()
938 .and_then(|name| name.to_str())
939 .unwrap_or("providers");
940 let mut files = fs::read_dir(provider_dir)
941 .with_context(|| format!("failed to read {}", provider_dir.display()))?
942 .filter_map(|entry| entry.ok())
943 .map(|entry| entry.path())
944 .filter(|path| path.is_file())
945 .filter(|path| {
946 matches!(
947 path.extension().and_then(|extension| extension.to_str()),
948 Some("json" | "ts" | "wasm" | "py")
949 )
950 })
951 .filter_map(|path| {
952 path.file_name()
953 .and_then(|name| name.to_str())
954 .map(|name| format!("{label}/{name}"))
955 })
956 .collect::<Vec<_>>();
957 files.sort();
958 Ok(files)
959}
960
961fn yes_no(value: bool) -> &'static str {
962 if value {
963 "yes"
964 } else {
965 "no"
966 }
967}
968
969fn canonical_json(value: &Value) -> Result<String> {
970 let mut text = serde_json::to_string_pretty(value)?;
971 text.push('\n');
972 Ok(text)
973}
974
975fn write_if_changed(path: &Path, content: &str) -> Result<()> {
976 if let Some(parent) = path.parent() {
977 fs::create_dir_all(parent)
978 .with_context(|| format!("failed to create {}", parent.display()))?;
979 }
980 if path.exists()
981 && fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?
982 == content
983 {
984 return Ok(());
985 }
986 fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))
987}
988
989fn relative_display(root: &Path, path: &Path) -> String {
990 path.strip_prefix(root)
991 .unwrap_or(path)
992 .display()
993 .to_string()
994}
995
996#[cfg(test)]
997mod tests {
998 use super::*;
999 use std::sync::{Mutex, OnceLock};
1000
1001 static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1002
1003 #[test]
1004 fn mixed_drop_in_providers_populate_generated_distribution_surfaces() {
1005 let _guard = ENV_LOCK.get_or_init(|| Mutex::new(())).lock().unwrap();
1006 let temp = tempfile::tempdir().expect("tempdir");
1007 let providers = temp.path().join("providers");
1008 fs::create_dir(&providers).expect("providers dir");
1009 fs::write(
1010 providers.join("weather.tool.ts"),
1011 format!(
1012 "export default {};\nexport async function call(input) {{ return {{ ok: true, action: input.action }}; }}\n",
1013 provider_manifest("weather-ts", "ai-sdk", "weather_ts")
1014 ),
1015 )
1016 .expect("ts provider");
1017 fs::write(
1018 providers.join("image.wasm"),
1019 wasm_provider(provider_manifest("image-wasm", "wasm", "image_wasm").as_bytes()),
1020 )
1021 .expect("wasm provider");
1022 fs::write(
1023 providers.join("python_math.py"),
1024 r#"
1025PROVIDER = {"name": "python-math", "kind": "python"}
1026
1027def python_add(a: int, b: int) -> int:
1028 """Add two integers."""
1029 return a + b
1030"#,
1031 )
1032 .expect("python provider");
1033 fs::write(
1034 providers.join("notes.mcp.json"),
1035 provider_manifest("notes-mcp", "mcp", "notes_search"),
1036 )
1037 .expect("mcp provider");
1038 fs::write(
1039 providers.join("github.openapi.json"),
1040 provider_manifest("github-openapi", "openapi", "github_issue"),
1041 )
1042 .expect("openapi provider");
1043
1044 std::env::set_var("SOMA_PROVIDER_DIR", &providers);
1045 let snapshot = render_provider_snapshot().expect("snapshot");
1046 std::env::remove_var("SOMA_PROVIDER_DIR");
1047 let plugin = render_distribution_plugin(&snapshot);
1048
1049 for action in [
1050 "weather_ts",
1051 "image_wasm",
1052 "python_add",
1053 "notes_search",
1054 "github_issue",
1055 ] {
1056 assert!(
1057 contains_string(&snapshot["surfaces"]["mcp_actions"], action),
1058 "MCP actions should include {action}"
1059 );
1060 assert!(
1061 contains_string(&snapshot["surfaces"]["cli_actions"], action),
1062 "CLI actions should include {action}"
1063 );
1064 }
1065 assert!(contains_string(
1066 &snapshot["surfaces"]["cli_commands"],
1067 "ship-weather-ts"
1068 ));
1069 assert!(contains_string(
1070 &snapshot["surfaces"]["cli_commands"],
1071 "ship-alias-weather_ts"
1072 ));
1073 assert!(contains_string(
1074 &snapshot["surfaces"]["rest_routes"],
1075 "POST /v1/providers/weather-ts"
1076 ));
1077 assert!(contains_string(
1078 &snapshot["surfaces"]["rest_routes"],
1079 "POST /v1/tools/python_add"
1080 ));
1081 assert!(contains_string(
1082 &plugin["provider_files"],
1083 "providers/weather.tool.ts"
1084 ));
1085 assert!(contains_string(
1086 &plugin["provider_files"],
1087 "providers/python_math.py"
1088 ));
1089 assert!(contains_string(
1090 &plugin["provider_files"],
1091 "providers/github.openapi.json"
1092 ));
1093 assert!(contains_string(
1094 &snapshot["surfaces"]["generated_skills"],
1095 "docs/generated/skills/weather-ts/SKILL.md"
1096 ));
1097 let skill = render_provider_skill(
1098 snapshot["providers"]
1099 .as_array()
1100 .unwrap()
1101 .iter()
1102 .find(|provider| provider["name"] == "weather-ts")
1103 .unwrap(),
1104 )
1105 .expect("skill");
1106 assert!(skill.contains("name: weather-ts"));
1107 assert!(skill.contains("When To Use"));
1108 assert!(skill.contains("weather_ts"));
1109 assert!(skill.contains("ship-weather-ts"));
1110 assert!(skill.contains("POST /v1/providers/weather-ts"));
1111 assert!(skill.contains("MCP"));
1112 assert!(skill.contains("CLI"));
1113 assert!(skill.contains("REST"));
1114 assert!(skill.contains("## Action Reference"));
1115 assert!(skill.contains("Required args"));
1116 assert!(skill.contains("Output"));
1117
1118 let python_skill = render_provider_skill(
1119 snapshot["providers"]
1120 .as_array()
1121 .unwrap()
1122 .iter()
1123 .find(|provider| provider["name"] == "python-math")
1124 .unwrap(),
1125 )
1126 .expect("python skill");
1127 assert!(python_skill.contains(
1128 "| `python_add` | yes | yes | yes | `python_add` | `POST /v1/tools/python_add` |"
1129 ));
1130 assert!(python_skill.contains("- REST: `POST /v1/tools/python_add`"));
1131
1132 let static_skill = render_provider_skill(
1133 snapshot["providers"]
1134 .as_array()
1135 .unwrap()
1136 .iter()
1137 .find(|provider| provider["name"] == "static-rust")
1138 .unwrap(),
1139 )
1140 .expect("static skill");
1141 assert!(static_skill.contains("| `elicit_name` | yes | no | no | `N/A` | `N/A` |"));
1142 assert!(static_skill.contains("- CLI: `N/A` - do not call this action from CLI."));
1143 assert!(static_skill.contains("- REST: `N/A` - do not invent an HTTP route."));
1144 assert!(static_skill.contains("Generated by `cargo xtask generate-provider-surfaces`"));
1145 assert!(static_skill.contains("Soma built-in Rust actions"));
1146 assert!(static_skill.contains("MCP elicitation"));
1147 assert!(static_skill.contains("- CLI: `soma echo --message MSG`"));
1148 assert!(static_skill.contains("- CLI flags: `--message MSG` required"));
1149 assert!(static_skill.contains("- Output: `EchoResult`"));
1150 assert!(static_skill.contains("- Output: `ScaffoldIntentReport`"));
1151 assert!(static_skill.contains("recommended_skill: `scaffold-project`"));
1152 assert!(static_skill.contains("Do not mutate files until the user approves the plan."));
1153 }
1154
1155 fn provider_manifest(name: &str, kind: &str, action: &str) -> String {
1156 json!({
1157 "schema_version": 1,
1158 "provider": {
1159 "name": name,
1160 "kind": kind,
1161 "enabled": true,
1162 "description": format!("Generated test provider {name}.")
1163 },
1164 "tools": [{
1165 "name": action,
1166 "description": format!("Generated test action {action}."),
1167 "input_schema": {
1168 "type": "object",
1169 "additionalProperties": false,
1170 "properties": {}
1171 },
1172 "cli": {
1173 "enabled": true,
1174 "command": format!("ship-{name}"),
1175 "aliases": [format!("ship-alias-{action}")]
1176 },
1177 "rest": {
1178 "enabled": true,
1179 "method": "POST",
1180 "path": format!("/v1/providers/{name}")
1181 }
1182 }]
1183 })
1184 .to_string()
1185 }
1186
1187 fn wasm_provider(manifest: &[u8]) -> Vec<u8> {
1188 let mut bytes = vec![0, b'a', b's', b'm', 1, 0, 0, 0];
1189 let name = b"soma.provider";
1190 let mut payload = Vec::new();
1191 write_leb(name.len() as u32, &mut payload);
1192 payload.extend_from_slice(name);
1193 payload.extend_from_slice(manifest);
1194 bytes.push(0);
1195 write_leb(payload.len() as u32, &mut bytes);
1196 bytes.extend(payload);
1197 bytes
1198 }
1199
1200 fn write_leb(mut value: u32, bytes: &mut Vec<u8>) {
1201 loop {
1202 let mut byte = (value & 0x7f) as u8;
1203 value >>= 7;
1204 if value != 0 {
1205 byte |= 0x80;
1206 }
1207 bytes.push(byte);
1208 if value == 0 {
1209 break;
1210 }
1211 }
1212 }
1213
1214 fn contains_string(value: &Value, needle: &str) -> bool {
1215 value
1216 .as_array()
1217 .into_iter()
1218 .flatten()
1219 .any(|value| value.as_str() == Some(needle))
1220 }
1221}