feat(skill): typed wrapper tools for declared script entrypoints

This commit is contained in:
matevip 2026-05-19 09:56:39 +08:00
parent 31d4d014ed
commit 3d643f909d
6 changed files with 600 additions and 0 deletions

View File

@ -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<ScriptDef> 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_<skill>_<id>}.
*/
@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<String> 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<String, Object> 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)

View File

@ -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<SkillManifest.ScriptDef> parseScripts(Object rawScripts) {
if (!(rawScripts instanceof List<?> list)) return List.of();
List<SkillManifest.ScriptDef> out = new ArrayList<>();
for (Object item : list) {
if (!(item instanceof Map<?, ?> map)) continue;
Map<String, Object> m = (Map<String, Object>) map;
Map<String, Object> 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;

View File

@ -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.
*
* <p>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.
*
* <p>One wrapper per entrypoint, named {@code skill_<skill>_<entrypoint>}.
* 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<ToolCallback> 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<ToolCallback> out = new ArrayList<>();
Set<String> 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<String> 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<String> names = new ArrayList<>();
Set<String> 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<String> argv = buildArgv(def.getFixedArgs(), def.getArgStyle(), args);
// Inject this skill's stored secrets as subprocess env vars,
// mirroring the generic runSkillScript path.
Map<String, String> 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.
*
* <p>{@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}:
*
* <ul>
* <li>{@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.</li>
* <li>{@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.</li>
* </ul>
*
* <p>Package-private and static for direct unit testing.
*
* @return the argument list, or {@code null} when it would be empty
*/
static List<String> buildArgv(List<String> fixedArgs, String argStyle, JsonNode args) {
List<String> 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<Map.Entry<String, JsonNode>> fields = args.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> 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<String, Object> 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();
}
}

View File

@ -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<String, SkillManifest.RequirementDef> 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}.
*
* <p>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<ToolCallback> wrappers = scriptWrapperFactory.buildWrappers(resolved, manifest);
if (wrappers.isEmpty()) {
return;
}
java.util.Set<String> 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<String> 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) {

View File

@ -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<String, Object> 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<String, Object> 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<String, Object> 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();
}
}

View File

@ -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<String> 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<String> 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<String> 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_<slug>_<id> 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");
}
}