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