Skip to main content

xtask/
generated_surfaces.rs

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        // FIXME: Audit that the environment access only happens in single-threaded code.
1045        unsafe { std::env::set_var("SOMA_PROVIDER_DIR", &providers) };
1046        let snapshot = render_provider_snapshot().expect("snapshot");
1047        // FIXME: Audit that the environment access only happens in single-threaded code.
1048        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}