Skip to main content

xtask/
generated_surfaces.rs

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}