diff --git a/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifest.java b/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifest.java index ece5db00..eb79ea27 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifest.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifest.java @@ -91,6 +91,18 @@ public class SkillManifest { /** Set when {@code type=acp}. Resolves to a {@code mate_acp_endpoint} row. */ private AcpBinding acp; + // ==================== type=code script entrypoints ==================== + + /** + * Declared script entrypoints from the {@code scripts} frontmatter + * block. Each entry is exposed to the model as a typed wrapper tool — + * the model fills schema-described fields and the runtime serializes + * them into process arguments, so a script consuming a JSON payload + * never depends on the model hand-crafting a JSON string. + */ + @Builder.Default + private List scripts = List.of(); + // ==================== Forward-compat catch-all ==================== /** Unknown frontmatter keys are stashed here so a future field @@ -198,6 +210,45 @@ public class SkillManifest { private Long resolvedEndpointId; } + /** + * One script entrypoint declared under the {@code scripts} block. The + * resolver turns each into a typed wrapper tool named + * {@code skill__}. + */ + @Data + @Builder + @JsonInclude(JsonInclude.Include.NON_EMPTY) + public static class ScriptDef { + /** Stable id; forms the suffix of the generated wrapper tool name. */ + private String id; + /** Human-readable label for the entrypoint. */ + private String label; + /** Script path relative to the skill directory (e.g. {@code scripts/run.py}). */ + private String path; + /** What the entrypoint does — surfaced as the wrapper tool description. */ + private String description; + /** + * Literal arguments prepended before the typed arguments. Lets one + * dispatcher script back several entrypoints — e.g. a fixed method + * name as {@code argv[1]} with the typed JSON payload as {@code argv[2]}. + */ + @Builder.Default + private List fixedArgs = List.of(); + /** + * Raw JSON Schema object describing the entrypoint's parameters, + * forwarded verbatim as the wrapper tool's input schema. + */ + @Builder.Default + private Map parameters = Map.of(); + /** + * How typed arguments reach the script process: + * {@code json} (default) forwards one compact JSON argument; + * {@code flags} forwards each property as a {@code --key value} pair. + */ + @Builder.Default + private String argStyle = "json"; + } + @Data @Builder @JsonInclude(JsonInclude.Include.NON_EMPTY) diff --git a/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifestParser.java b/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifestParser.java index ce401207..c0defeaf 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifestParser.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifestParser.java @@ -40,6 +40,7 @@ public class SkillManifestParser { "dashboard", "self-evolution", "self_evolution", "knowledge", "acp", + "scripts", // legacy / housekeeping fields that aren't manifest-relevant "metadata" ); @@ -102,6 +103,7 @@ public class SkillManifestParser { .selfEvolution(parseSelfEvolution(coalesce(fm, "self-evolution", "self_evolution"))) .knowledge(parseKnowledge(fm.get("knowledge"))) .acp(parseAcp(fm.get("acp"))) + .scripts(parseScripts(fm.get("scripts"))) .extras(extractUnknown(fm)); return b.build(); @@ -261,6 +263,37 @@ public class SkillManifestParser { .build(); } + // ==================== scripts ==================== + + /** + * Parse the {@code scripts} block — a list of script entrypoint maps. + * The per-entry {@code parameters} map is carried through as a raw + * JSON Schema object; nested maps / lists from the YAML parse stay + * intact so the wrapper factory can serialize them verbatim. + */ + @SuppressWarnings("unchecked") + private List parseScripts(Object rawScripts) { + if (!(rawScripts instanceof List list)) return List.of(); + List out = new ArrayList<>(); + for (Object item : list) { + if (!(item instanceof Map map)) continue; + Map m = (Map) map; + Map parameters = m.get("parameters") instanceof Map p + ? toStringObjectMap((Map) p) : Map.of(); + out.add(SkillManifest.ScriptDef.builder() + .id(string(m, "id")) + .label(string(m, "label")) + .path(string(m, "path")) + .description(string(m, "description")) + .fixedArgs(stringList(coalesce(m, "fixed_args", "fixedArgs"))) + .parameters(parameters) + .argStyle(stringOrDefault(m, "arg_style", + stringOrDefault(m, "argStyle", "json"))) + .build()); + } + return out; + } + @SuppressWarnings("unchecked") private SkillManifest.AcpBinding parseAcp(Object raw) { if (!(raw instanceof Map map)) return null; diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/ScriptSkillWrapperToolFactory.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/ScriptSkillWrapperToolFactory.java new file mode 100644 index 00000000..fa590407 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/ScriptSkillWrapperToolFactory.java @@ -0,0 +1,262 @@ +package vip.mate.skill.runtime; + +import cn.hutool.json.JSONUtil; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.stereotype.Component; +import vip.mate.skill.knowledge.SkillScopedToolCallback; +import vip.mate.skill.manifest.SkillManifest; +import vip.mate.skill.runtime.model.ResolvedSkill; +import vip.mate.skill.secret.SkillSecretService; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Wrapper tool factory for skill script entrypoints declared in the + * {@code scripts} manifest block. + * + *

A directory-backed skill ships executable scripts under {@code scripts/}. + * The generic {@code runSkillScript} tool can run any of them, but it forces + * the model to hand-assemble the argument list — brittle whenever a script + * consumes a structured JSON payload. This factory turns each declared + * entrypoint into its own typed tool: the model fills schema-described + * fields, and the runtime serializes them into process arguments. The model + * never crafts a JSON string by hand. + * + *

One wrapper per entrypoint, named {@code skill__}. + * Each wrapper closes over the resolved skill directory and id, so a call + * always targets the declaring skill's own script and decrypted secrets and + * cannot be redirected elsewhere. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ScriptSkillWrapperToolFactory { + + private final SkillScriptExecutionService executionService; + private final SkillFileAccessPolicy accessPolicy; + private final SkillSecretService skillSecretService; + private final ObjectMapper objectMapper; + + /** + * Build one wrapper callback per declared script entrypoint. Returns an + * empty list when the skill declares no entrypoints or has no directory + * (a database-only skill cannot expose runnable scripts). + */ + public List buildWrappers(ResolvedSkill resolved, SkillManifest manifest) { + if (resolved == null || manifest == null + || manifest.getScripts() == null || manifest.getScripts().isEmpty() + || resolved.getSkillDir() == null) { + return List.of(); + } + String skillSlug = sanitize(manifest.getName()); + if (skillSlug.isBlank()) { + return List.of(); + } + Path skillDir = resolved.getSkillDir(); + Long skillId = resolved.getId(); + + List out = new ArrayList<>(); + Set seen = new LinkedHashSet<>(); + for (SkillManifest.ScriptDef def : manifest.getScripts()) { + if (!isUsable(def)) { + continue; + } + String name = "skill_" + skillSlug + "_" + sanitize(def.getId()); + if (!seen.add(name)) { + log.warn("Skill '{}' script entrypoint id '{}' collides on tool name '{}'; skipping duplicate", + manifest.getName(), def.getId(), name); + continue; + } + out.add(new SkillScopedToolCallback( + name, + buildDescription(manifest, def), + buildInputSchema(def), + input -> invoke(skillDir, skillId, def, input))); + } + return out; + } + + /** + * Names the wrappers this manifest would produce, without building them. + * Used by the resolver to merge entrypoint names into + * {@code manifest.allowedTools} so {@code getEffectiveAllowedTools()} + * surfaces them like any other declared tool. + */ + public List wrapperNames(SkillManifest manifest) { + if (manifest == null || manifest.getName() == null + || manifest.getScripts() == null || manifest.getScripts().isEmpty()) { + return List.of(); + } + String skillSlug = sanitize(manifest.getName()); + if (skillSlug.isBlank()) { + return List.of(); + } + List names = new ArrayList<>(); + Set seen = new LinkedHashSet<>(); + for (SkillManifest.ScriptDef def : manifest.getScripts()) { + if (!isUsable(def)) { + continue; + } + String name = "skill_" + skillSlug + "_" + sanitize(def.getId()); + if (seen.add(name)) { + names.add(name); + } + } + return names; + } + + /** An entrypoint is usable only when it has both an id and a script path. */ + private static boolean isUsable(SkillManifest.ScriptDef def) { + return def != null + && def.getId() != null && !def.getId().isBlank() + && def.getPath() != null && !def.getPath().isBlank(); + } + + // ==================== invocation ==================== + + private String invoke(Path skillDir, Long skillId, SkillManifest.ScriptDef def, String input) { + try { + JsonNode args = (input == null || input.isBlank()) + ? objectMapper.createObjectNode() + : objectMapper.readTree(input); + + // Path traversal is blocked here — only scripts under the + // skill's own scripts/ directory can be reached. + Path scriptPath = accessPolicy.validateScriptPath(skillDir, def.getPath()); + if (scriptPath == null) { + return errorJson("invalid or unsafe script path: " + def.getPath()); + } + + List argv = buildArgv(def.getFixedArgs(), def.getArgStyle(), args); + + // Inject this skill's stored secrets as subprocess env vars, + // mirroring the generic runSkillScript path. + Map envVars = skillId != null + ? skillSecretService.getDecrypted(skillId) + : Map.of(); + + SkillScriptExecutionService.ScriptResult result = + executionService.execute(scriptPath, argv, envVars); + return JSONUtil.createObj() + .set("exitCode", result.getExitCode()) + .set("stdout", result.getStdout()) + .set("stderr", result.getStderr()) + .toString(); + } catch (Exception e) { + log.warn("script wrapper for entrypoint '{}' failed: {}", def.getId(), e.getMessage()); + return errorJson(e.getMessage() == null ? "script invocation failed" : e.getMessage()); + } + } + + /** + * Translate the typed argument object into a process argument list. + * + *

{@code fixedArgs} are emitted first, verbatim — they let one + * dispatcher script back several entrypoints (e.g. a fixed method name + * as {@code argv[1]}). The typed arguments follow, shaped by + * {@code argStyle}: + * + *

    + *
  • {@code json} (default) — append the whole object as one compact + * JSON argument, the shape a script reading its last argv with a + * JSON parser expects.
  • + *
  • {@code flags} — append each property as {@code --key value}; + * a {@code true} boolean becomes a bare {@code --key}, while a + * {@code false} or null property is dropped.
  • + *
+ * + *

Package-private and static for direct unit testing. + * + * @return the argument list, or {@code null} when it would be empty + */ + static List buildArgv(List fixedArgs, String argStyle, JsonNode args) { + List out = new ArrayList<>(); + if (fixedArgs != null) { + for (String fixed : fixedArgs) { + if (fixed != null) { + out.add(fixed); + } + } + } + if ("flags".equalsIgnoreCase(argStyle)) { + if (args != null && args.isObject()) { + Iterator> fields = args.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + JsonNode value = field.getValue(); + if (value == null || value.isNull()) { + continue; + } + if (value.isBoolean()) { + if (value.asBoolean()) { + out.add("--" + field.getKey()); + } + continue; + } + out.add("--" + field.getKey()); + out.add(value.isValueNode() ? value.asText() : value.toString()); + } + } + } else { + // Default: json — append one compact JSON argument, unless the + // object is empty / absent (an entrypoint with no typed input). + if (args != null && !args.isNull() && !args.isMissingNode() + && !(args.isObject() && args.isEmpty())) { + out.add(args.toString()); + } + } + return out.isEmpty() ? null : out; + } + + // ==================== helpers ==================== + + private String buildDescription(SkillManifest manifest, SkillManifest.ScriptDef def) { + String base; + if (def.getDescription() != null && !def.getDescription().isBlank()) { + base = def.getDescription().trim(); + } else if (def.getLabel() != null && !def.getLabel().isBlank()) { + base = def.getLabel().trim(); + } else { + base = "Run the '" + def.getId() + "' script"; + } + return base + " (skill: " + manifest.getName() + "). " + + "Fill the described fields; the arguments are forwarded to the script for you."; + } + + private String buildInputSchema(SkillManifest.ScriptDef def) { + Map params = def.getParameters(); + if (params == null || params.isEmpty()) { + return "{\"type\":\"object\",\"properties\":{}}"; + } + try { + return objectMapper.writeValueAsString(params); + } catch (Exception e) { + log.warn("script entrypoint '{}' has an unserializable parameter schema: {}", + def.getId(), e.getMessage()); + return "{\"type\":\"object\",\"properties\":{}}"; + } + } + + /** Tool-name slug rule shared with the knowledge / acp wrapper factories. */ + private static String sanitize(String raw) { + if (raw == null) { + return ""; + } + return raw.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_]", "_"); + } + + private static String errorJson(String message) { + return JSONUtil.createObj().set("error", message).toString(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java index 74a2c645..25903b0b 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java @@ -72,6 +72,12 @@ public class SkillPackageResolver { * Spring lifecycle. */ private final AcpSkillWrapperToolFactory acpWrapperFactory; + /** + * Script-entrypoint wrapper factory for skills that declare a + * {@code scripts} manifest block. {@code @Lazy} to match the sibling + * wrapper factories and stay clear of bean construction-order loops. + */ + private final ScriptSkillWrapperToolFactory scriptWrapperFactory; /** * {@code @Lazy} on ToolRegistry — same lazy-resolution loop as * {@code SkillDependencyChecker}; without this we'd reach for the @@ -109,6 +115,7 @@ public class SkillPackageResolver { SkillMapper skillMapper, @Lazy WikiSkillWrapperToolFactory wikiWrapperFactory, @Lazy AcpSkillWrapperToolFactory acpWrapperFactory, + @Lazy ScriptSkillWrapperToolFactory scriptWrapperFactory, @Lazy ToolRegistry toolRegistry) { this.frontmatterParser = frontmatterParser; this.manifestParser = manifestParser; @@ -120,6 +127,7 @@ public class SkillPackageResolver { this.skillMapper = skillMapper; this.wikiWrapperFactory = wikiWrapperFactory; this.acpWrapperFactory = acpWrapperFactory; + this.scriptWrapperFactory = scriptWrapperFactory; this.toolRegistry = toolRegistry; } @@ -533,6 +541,11 @@ public class SkillPackageResolver { // registeredWrappers map so deregistration covers both. applyAcpWrappers(resolved, manifest); + // Skills that declare a scripts[] block get one typed wrapper + // tool per entrypoint, so a script consuming structured input + // is driven by schema fields instead of a hand-built JSON arg. + applyScriptWrappers(resolved, manifest); + // Build requirement lookup for feature checks. Map reqByKey = new LinkedHashMap<>(); for (SkillManifest.RequirementDef r : manifest.getRequires()) { @@ -808,6 +821,58 @@ public class SkillPackageResolver { manifest.setAllowedTools(mergedAllowed); } + /** + * Register typed wrapper tools for a skill's declared script entrypoints + * (the {@code scripts} manifest block). Parallel structure to + * {@link #applyKnowledgeWrappers} / {@link #applyAcpWrappers}. + * + *

Skipped for {@code knowledge} / {@code acp} skills: those types own + * the wrapper slot, and this method must never deregister wrappers a + * sibling branch just registered. Also skipped when the skill declares + * no entrypoints, is disabled, or has no directory. + */ + private void applyScriptWrappers(ResolvedSkill resolved, SkillManifest manifest) { + String type = manifest.getType(); + if ("knowledge".equalsIgnoreCase(type) || "acp".equalsIgnoreCase(type)) { + return; + } + boolean hasScripts = manifest.getScripts() != null && !manifest.getScripts().isEmpty(); + if (!hasScripts || !resolved.isEnabled() || resolved.getSkillDir() == null) { + return; + } + // Fresh build so a re-resolve diff-updates the registry cleanly. + // applyKnowledgeWrappers already cleared any prior set for a + // non-knowledge skill; this is a no-op safety net. + deregisterSkillWrappers(resolved.getId()); + + java.util.List wrappers = scriptWrapperFactory.buildWrappers(resolved, manifest); + if (wrappers.isEmpty()) { + return; + } + java.util.Set registered = new java.util.LinkedHashSet<>(); + Long entityId = resolved.getId(); + for (ToolCallback cb : wrappers) { + String name = cb.getToolDefinition().name(); + registered.add(name); + toolRegistry.registerPluginTool(cb, () -> + entityId != null && resolved.isEnabled()); + } + if (entityId != null) { + registeredWrappers.put(entityId, registered); + } + + // Append wrapper names to allowedTools so getEffectiveAllowedTools + // surfaces them like any other manifest-declared tool. + java.util.List mergedAllowed = new java.util.ArrayList<>( + manifest.getAllowedTools() == null ? java.util.List.of() : manifest.getAllowedTools()); + for (String wrapperName : scriptWrapperFactory.wrapperNames(manifest)) { + if (!mergedAllowed.contains(wrapperName)) { + mergedAllowed.add(wrapperName); + } + } + manifest.setAllowedTools(mergedAllowed); + } + // ==================== 阶段 4:综合判定 ==================== private void resolveRuntimeAvailability(ResolvedSkill resolved) { diff --git a/mateclaw-server/src/test/java/vip/mate/skill/manifest/SkillManifestParserScriptsTest.java b/mateclaw-server/src/test/java/vip/mate/skill/manifest/SkillManifestParserScriptsTest.java new file mode 100644 index 00000000..7c4e4cb9 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/manifest/SkillManifestParserScriptsTest.java @@ -0,0 +1,69 @@ +package vip.mate.skill.manifest; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link SkillManifestParser}'s handling of the {@code scripts} + * frontmatter block — the typed script entrypoint declarations. + */ +class SkillManifestParserScriptsTest { + + /** parseRawMap / parseFromFrontmatter never touch the frontmatter parser. */ + private final SkillManifestParser parser = new SkillManifestParser(null); + + @Test + @DisplayName("a scripts block parses into typed ScriptDef entries") + void parsesScriptsBlock() { + Map entry = Map.of( + "id", "create_meeting", + "label", "Create Meeting", + "path", "scripts/dispatcher.py", + "description", "Schedule a meeting", + "arg_style", "json", + "fixed_args", List.of("schedule_meeting"), + "parameters", Map.of( + "type", "object", + "properties", Map.of("topic", Map.of("type", "string")), + "required", List.of("topic"))); + Map fm = Map.of( + "name", "demo", + "type", "code", + "scripts", List.of(entry)); + + SkillManifest manifest = parser.parseRawMap(fm, null, null); + + assertThat(manifest).isNotNull(); + assertThat(manifest.getScripts()).hasSize(1); + SkillManifest.ScriptDef def = manifest.getScripts().get(0); + assertThat(def.getId()).isEqualTo("create_meeting"); + assertThat(def.getPath()).isEqualTo("scripts/dispatcher.py"); + assertThat(def.getArgStyle()).isEqualTo("json"); + assertThat(def.getFixedArgs()).containsExactly("schedule_meeting"); + assertThat(def.getParameters()).containsKey("properties"); + // 'scripts' is a known key — it must not also leak into extras. + assertThat(manifest.getExtras()).doesNotContainKey("scripts"); + } + + @Test + @DisplayName("arg_style defaults to json when omitted") + void argStyleDefaults() { + Map entry = Map.of("id", "run", "path", "scripts/run.sh"); + SkillManifest manifest = parser.parseRawMap( + Map.of("name", "demo", "scripts", List.of(entry)), null, null); + assertThat(manifest.getScripts()).hasSize(1); + assertThat(manifest.getScripts().get(0).getArgStyle()).isEqualTo("json"); + } + + @Test + @DisplayName("no scripts block yields an empty list, not null") + void noScriptsBlock() { + SkillManifest manifest = parser.parseRawMap(Map.of("name", "demo"), null, null); + assertThat(manifest.getScripts()).isEmpty(); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/runtime/ScriptSkillWrapperToolFactoryTest.java b/mateclaw-server/src/test/java/vip/mate/skill/runtime/ScriptSkillWrapperToolFactoryTest.java new file mode 100644 index 00000000..70d48d49 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/runtime/ScriptSkillWrapperToolFactoryTest.java @@ -0,0 +1,120 @@ +package vip.mate.skill.runtime; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.skill.manifest.SkillManifest; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link ScriptSkillWrapperToolFactory} — the argument + * translation ({@code buildArgv}) and wrapper naming ({@code wrapperNames}) + * that turn a declared script entrypoint into a typed tool. The model fills + * schema fields; these are the steps that carry that typed input into the + * script process without the model hand-crafting a JSON string. + */ +class ScriptSkillWrapperToolFactoryTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** wrapperNames touches no collaborators — null deps are fine here. */ + private final ScriptSkillWrapperToolFactory factory = + new ScriptSkillWrapperToolFactory(null, null, null, objectMapper); + + private JsonNode json(String raw) { + try { + return objectMapper.readTree(raw); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + @DisplayName("json style forwards the whole object as one compact JSON argument") + void jsonStyleSingleArg() { + List argv = ScriptSkillWrapperToolFactory.buildArgv( + List.of(), "json", json("{\"date\":\"2026-05-19\",\"topic\":\"智能体\"}")); + assertThat(argv).hasSize(1); + JsonNode back = json(argv.get(0)); + assertThat(back.get("date").asText()).isEqualTo("2026-05-19"); + assertThat(back.get("topic").asText()).isEqualTo("智能体"); + } + + @Test + @DisplayName("json style with an empty / absent object yields no arguments") + void jsonStyleEmpty() { + assertThat(ScriptSkillWrapperToolFactory.buildArgv(List.of(), "json", json("{}"))).isNull(); + assertThat(ScriptSkillWrapperToolFactory.buildArgv(List.of(), "json", null)).isNull(); + assertThat(ScriptSkillWrapperToolFactory.buildArgv(List.of(), null, json("{}"))).isNull(); + } + + @Test + @DisplayName("flags style emits --key value pairs and drops false / null properties") + void flagsStyle() { + List argv = ScriptSkillWrapperToolFactory.buildArgv(List.of(), "flags", + json("{\"verbose\":true,\"file\":\"in.txt\",\"count\":3,\"debug\":false,\"note\":null}")); + assertThat(argv).containsExactly("--verbose", "--file", "in.txt", "--count", "3"); + } + + @Test + @DisplayName("flags style with no usable properties yields no arguments") + void flagsStyleEmpty() { + assertThat(ScriptSkillWrapperToolFactory.buildArgv(List.of(), "flags", json("{}"))).isNull(); + assertThat(ScriptSkillWrapperToolFactory.buildArgv(List.of(), "flags", json("{\"off\":false}"))).isNull(); + } + + @Test + @DisplayName("fixedArgs are emitted before the typed JSON argument") + void fixedArgsPrependDispatcherMethod() { + // A dispatcher script: argv[1] = method, argv[2] = JSON payload. + List argv = ScriptSkillWrapperToolFactory.buildArgv( + List.of("schedule_meeting"), "json", json("{\"subject\":\"智能体\"}")); + assertThat(argv).hasSize(2); + assertThat(argv.get(0)).isEqualTo("schedule_meeting"); + assertThat(json(argv.get(1)).get("subject").asText()).isEqualTo("智能体"); + } + + @Test + @DisplayName("fixedArgs survive even when the entrypoint takes no typed input") + void fixedArgsWithoutTypedArgs() { + assertThat(ScriptSkillWrapperToolFactory.buildArgv( + List.of("convert_timestamp"), "json", json("{}"))) + .containsExactly("convert_timestamp"); + assertThat(ScriptSkillWrapperToolFactory.buildArgv( + List.of("m"), "flags", json("{\"v\":true}"))) + .containsExactly("m", "--v"); + } + + @Test + @DisplayName("wrapperNames builds skill__ for each usable entrypoint") + void wrapperNames() { + SkillManifest manifest = SkillManifest.builder() + .name("Tencent Meeting") + .scripts(List.of( + SkillManifest.ScriptDef.builder() + .id("create_meeting").path("scripts/create.py").build(), + SkillManifest.ScriptDef.builder() + .id("cancel_meeting").path("scripts/cancel.py").build())) + .build(); + assertThat(factory.wrapperNames(manifest)) + .containsExactly("skill_tencent_meeting_create_meeting", + "skill_tencent_meeting_cancel_meeting"); + } + + @Test + @DisplayName("wrapperNames skips entrypoints missing an id or a script path") + void wrapperNamesSkipsIncomplete() { + SkillManifest manifest = SkillManifest.builder() + .name("demo") + .scripts(List.of( + SkillManifest.ScriptDef.builder().id("ok").path("scripts/ok.py").build(), + SkillManifest.ScriptDef.builder().id("no_path").build(), + SkillManifest.ScriptDef.builder().path("scripts/no_id.py").build())) + .build(); + assertThat(factory.wrapperNames(manifest)).containsExactly("skill_demo_ok"); + } +}