This is an automated email from the ASF dual-hosted git repository.

luigidemasi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 9f2250f498f1a87a52949b4531facca97e1e3c79
Author: Luigi De Masi <[email protected]>
AuthorDate: Fri Oct 9 23:12:34 2026 +0200

    CAMEL-25489: Add CLI commands to inspect and evaluate semantic definitions
    
    Expose the semantic metadata and evaluation connector actions through
    camel get semantic and camel cmd semantic-evaluate. Support named sample
    exchanges, direct expert calls, typed inputs, JSON output, and documented
    exit codes with isolated request/reply files.
    
    Include command tests, usage documentation, and generated CLI metadata.
    
    Co-authored-by: OpenAI Codex <[email protected]>
    Signed-off-by: Luigi De Masi <[email protected]>
---
 .../modules/ROOT/pages/camel-jbang-managing.adoc   |  90 +++++
 .../camel-jbang-cmd-semantic-evaluate.adoc         |  36 ++
 .../ROOT/pages/jbang-commands/camel-jbang-cmd.adoc |   1 +
 .../jbang-commands/camel-jbang-get-semantic.adoc   |  28 ++
 .../ROOT/pages/jbang-commands/camel-jbang-get.adoc |   1 +
 .../META-INF/camel-jbang-commands-metadata.json    |   4 +-
 .../dsl/jbang/core/commands/CamelJBangMain.java    |   2 +
 .../jbang/core/commands/action/CamelSemantic.java  | 105 ++++++
 .../commands/action/SemanticActionCommand.java     | 151 ++++++++
 .../core/commands/action/SemanticEvaluate.java     | 168 +++++++++
 .../MissingPluginParameterExceptionHandler.java    |   4 +
 .../core/commands/action/SemanticCommandTest.java  | 415 +++++++++++++++++++++
 12 files changed, 1003 insertions(+), 2 deletions(-)

diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc
index 3ebac3883204..06bf53b9e529 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc
@@ -279,6 +279,96 @@ camel cmd enable-processor
 
 TIP: Separate multiple IDs with commas: `--id=log1,log2,setBody2`.
 
+== Semantic definitions and evaluation
+
+The CLI can inspect and try the running application's
+xref:next@components:languages:semantic-language.adoc[semantic definitions and 
experts].
+The application must include `camel-semantic` with the `semantic-metadata` and
+`semantic-evaluate` developer consoles and have the local CLI connector 
enabled.
+These commands use the connector; enabling the HTTP console is unnecessary.
+
+[source,bash]
+----
+camel get semantic my-app
+camel get semantic my-app --json
+camel get semantic my-app --expert=guard
+----
+
+`get semantic` lists definition names, experts, operations, state selectors, 
result types
+and resolution errors, followed by the known experts and default expert. 
`--expert`
+shows one expert's operations, input and result semantics, and parameter 
contract.
+Inspection does not perform inference.
+
+Evaluate a named definition with a sample exchange:
+
+[source,bash]
+----
+camel cmd semantic-evaluate my-app --evaluation=safe \
+  --body='Please summarize this message' --header=subject=example \
+  --variable=language=en --json
+----
+
+The definition's state selector reads the supplied body, headers and 
exchange-local
+variables. Variables with repository prefixes such as `global:` are rejected. 
The
+sample is evaluated directly; it is not sent through the application's routes.
+
+Alternatively, invoke an expert operation directly. Use `get semantic 
--expert` to
+find the operation names and parameters supported by that expert:
+
+[source,bash]
+----
+camel cmd semantic-evaluate my-app --expert=guard --operation=detect \
+  --input='Please summarize this message' --json
+camel cmd semantic-evaluate my-app --expert=grader --operation=score \
+  --input='Sample answer' 
--parameter='criteria=json:["poor","good","excellent"]' --json
+----
+
+Choose either a named evaluation or a direct expert call. Direct calls require
+`--expert`, `--operation` and `--input`. Repeat `--header`, `--variable` or 
`--parameter`
+for multiple entries; duplicate keys are rejected.
+
+Values are text by default. Prefix a value with `json:` for a typed JSON 
value, for
+example `--parameter=threshold=json:0.7`, `--variable=enabled=json:true`, or
+`--input='json:["first","second"]'`. Use a JSON string to pass literal text 
beginning
+with `json:` or `file:`. `--body=file:sample.txt` and 
`--input=file:sample.txt` read the
+file as UTF-8 text.
+
+Evaluation prints the value, available probability or probabilities, 
confidence,
+provider metadata, elapsed milliseconds and any error. It invokes the 
configured
+provider and may incur charges. `--evaluation-timeout` bounds runtime 
evaluation
+(default and maximum: 50000 milliseconds). `--timeout` bounds the CLI's wait 
for the
+reply (default: 60000 milliseconds). A CLI timeout removes its request and 
asks the
+connector to cancel unfinished work. Provider interruption is cooperative; 
remote or
+native inference may continue. Configure provider-specific request timeouts as 
well.
+
+Both commands accept an integration name or PID. With no name they select the 
only
+running integration; ambiguous selection fails and lists the matching PIDs.
+Each request has its own reply file, so concurrent commands do not consume each
+other's results.
+
+=== Semantic command output and exit codes
+
+With `--json`, stdout contains exactly one JSON document. Inspection returns 
the
+runtime metadata object, including empty `evaluations` and `experts` arrays 
when
+nothing is published. Successful evaluations retain the runtime result fields.
+Errors use `{"status":"error","code":N,"message":"..."}` and retain any runtime
+result fields, such as `error` and `elapsedMillis`. Diagnostics go to stderr.
+
+[cols="1,4",options="header"]
+|===
+| Exit code | Meaning
+| 0 | Successful inspection or evaluation, including a boolean result of 
`false`.
+| 1 | The integration rejected the action or evaluation failed.
+| 2 | Invalid command arguments or sample values.
+| 3 | No matching integration, ambiguous selection, unavailable semantic 
tooling or expert operations.
+| 4 | No reply before the CLI timeout.
+| 70 | Unexpected internal error; stderr includes the stack trace.
+|===
+
+See xref:jbang-commands/camel-jbang-get-semantic.adoc[camel get semantic] and
+xref:jbang-commands/camel-jbang-cmd-semantic-evaluate.adoc[camel cmd 
semantic-evaluate]
+for the complete option reference.
+
 == Developer Console
 
 Enable the web-based developer console with `--console`:
diff --git 
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-semantic-evaluate.adoc
 
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-semantic-evaluate.adoc
new file mode 100644
index 000000000000..bdb5e5089a3c
--- /dev/null
+++ 
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-semantic-evaluate.adoc
@@ -0,0 +1,36 @@
+
+// AUTO-GENERATED by camel-package-maven-plugin - DO NOT EDIT THIS FILE
+= camel cmd semantic-evaluate
+
+Evaluate a semantic definition or expert operation
+
+
+== Usage
+
+[source,bash]
+----
+camel cmd semantic-evaluate [options]
+----
+
+
+
+== Options
+
+[cols="2,5,1,2",options="header"]
+|===
+| Option | Description | Default | Type
+| `--body` | Sample message body (text, file:path or json:value) |  | String
+| `--evaluation` | Named semantic definition to evaluate |  | String
+| `--evaluation-timeout` | Provider evaluation timeout in milliseconds 
(1-50000) | 50000 | long
+| `--expert` | Expert to call directly |  | String
+| `--header` | Sample header (key=value; use json: for a typed value) |  | List
+| `--input` | Direct expert input (text, file:path or json:value) |  | String
+| `--json` | Output a single JSON document |  | boolean
+| `--operation` | Expert operation to call directly |  | String
+| `--parameter` | Expert parameter (key=value; use json: for a typed value) |  
| List
+| `--timeout` | Timeout in milliseconds waiting for the integration | 60000 | 
long
+| `--variable` | Sample exchange-local variable (key=value; use json: for a 
typed value) |  | List
+| `-h,--help` | Display the help and sub-commands |  | boolean
+|===
+
+
diff --git 
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc 
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc
index e6edad675139..943602405d14 100644
--- a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc
+++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc
@@ -35,6 +35,7 @@ camel cmd [options]
 | xref:jbang-commands/camel-jbang-cmd-route-dump.adoc[route-dump] | Dump Camel 
route in XML, YAML, or Java DSL format
 | xref:jbang-commands/camel-jbang-cmd-route-structure.adoc[route-structure] | 
Dump Camel route structure
 | xref:jbang-commands/camel-jbang-cmd-route-topology.adoc[route-topology] | 
Display inter-route topology connections
+| 
xref:jbang-commands/camel-jbang-cmd-semantic-evaluate.adoc[semantic-evaluate] | 
Evaluate a semantic definition or expert operation
 | xref:jbang-commands/camel-jbang-cmd-send.adoc[send] | Send messages to 
endpoints
 | xref:jbang-commands/camel-jbang-cmd-span.adoc[span] | Display OpenTelemetry 
spans from running Camel integrations
 | xref:jbang-commands/camel-jbang-cmd-sql.adoc[sql] | Execute SQL query on a 
DataSource
diff --git 
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-semantic.adoc
 
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-semantic.adoc
new file mode 100644
index 000000000000..ba1b6d8db73e
--- /dev/null
+++ 
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get-semantic.adoc
@@ -0,0 +1,28 @@
+
+// AUTO-GENERATED by camel-package-maven-plugin - DO NOT EDIT THIS FILE
+= camel get semantic
+
+List semantic definitions and expert contracts
+
+
+== Usage
+
+[source,bash]
+----
+camel get semantic [options]
+----
+
+
+
+== Options
+
+[cols="2,5,1,2",options="header"]
+|===
+| Option | Description | Default | Type
+| `--expert` | Show the operations and parameter contract of this expert |  | 
String
+| `--json` | Output a single JSON document |  | boolean
+| `--timeout` | Timeout in milliseconds waiting for the integration | 60000 | 
long
+| `-h,--help` | Display the help and sub-commands |  | boolean
+|===
+
+
diff --git 
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get.adoc 
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get.adoc
index 1e4f3601dfc9..d15d01264c16 100644
--- a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get.adoc
+++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-get.adoc
@@ -44,6 +44,7 @@ camel get [options]
 | xref:jbang-commands/camel-jbang-get-rest.adoc[rest] | Get REST services of 
Camel integrations
 | xref:jbang-commands/camel-jbang-get-route.adoc[route] | Get status of Camel 
routes
 | xref:jbang-commands/camel-jbang-get-route-controller.adoc[route-controller] 
| List status of route controller
+| xref:jbang-commands/camel-jbang-get-semantic.adoc[semantic] | List semantic 
definitions and expert contracts
 | xref:jbang-commands/camel-jbang-get-service.adoc[service] | Get services of 
Camel integrations
 | xref:jbang-commands/camel-jbang-get-source.adoc[source] | Display Camel 
route source code
 | xref:jbang-commands/camel-jbang-get-sql-trace.adoc[sql-trace] | Get SQL 
query trace data
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
 
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
index fce709a9ad77..3b83afa8e364 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
@@ -3,7 +3,7 @@
     { "name": "ask", "fullName": "ask", "description": "Ask a question about a 
running Camel application using AI", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.Ask", "options": [ { "names": 
"--api-key", "description": "API key. Also reads ANTHROPIC_API_KEY, 
OPENAI_API_KEY, WATSONX_APIKEY, or LLM_API_KEY env vars", "javaType": 
"java.lang.String", "type": "string" }, { "names": "--api-type", "description": 
"API type: 'ollama', 'openai', 'anthropic', or 'watsonx'", "javaType":  [...]
     { "name": "bind", "fullName": "bind", "description": "DEPRECATED: Bind 
source and sink Kamelets as a new Camel integration", "deprecated": true, 
"sourceClass": "org.apache.camel.dsl.jbang.core.commands.bind.Bind", "options": 
[ { "names": "--error-handler", "description": "Add error handler 
(none|log|sink:<endpoint>). Sink endpoints are expected in the format 
[[apigroup\/]version:]kind:[namespace\/]name, plain Camel URIs or Kamelet 
name.", "javaType": "java.lang.String", "type": "stri [...]
     { "name": "catalog", "fullName": "catalog", "description": "List artifacts 
from Camel Catalog", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.catalog.CatalogCommand", "options": [ 
{ "names": "-h,--help", "description": "Display the help and sub-commands", 
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": 
"component", "fullName": "catalog component", "description": "List components 
from the Camel Catalog", "sourceClass": "org.apache.camel.dsl.jbang.co [...]
-    { "name": "cmd", "fullName": "cmd", "description": "Performs commands in 
the running Camel integrations, such as start\/stop route, or change logging 
levels.", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ { 
"names": "-h,--help", "description": "Display the help and sub-commands", 
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": 
"browse", "fullName": "cmd browse", "description": "Browse pending messages on 
endpoints [...]
+    { "name": "cmd", "fullName": "cmd", "description": "Performs commands in 
the running Camel integrations, such as start\/stop route, or change logging 
levels.", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ { 
"names": "-h,--help", "description": "Display the help and sub-commands", 
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": 
"browse", "fullName": "cmd browse", "description": "Browse pending messages on 
endpoints [...]
     { "name": "completion", "fullName": "completion", "description": "Generate 
completion script for bash\/zsh", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.Complete", "options": [ { "names": 
"-h,--help", "description": "Display the help and sub-commands", "javaType": 
"boolean", "type": "boolean" } ] },
     { "name": "config", "fullName": "config", "description": "Get and set user 
configuration values", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.config.ConfigCommand", "options": [ { 
"names": "-h,--help", "description": "Display the help and sub-commands", 
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "get", 
"fullName": "config get", "description": "Display user configuration value", 
"sourceClass": "org.apache.camel.dsl.jbang.core.commands.config. [...]
     { "name": "debug", "fullName": "debug", "description": "Debug local Camel 
integration", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Debug", 
"options": [ { "names": "--ago", "description": "Use ago instead of yyyy-MM-dd 
HH:mm:ss in timestamp.", "javaType": "boolean", "type": "boolean" }, { "names": 
"--background", "description": "Run in the background", "defaultValue": 
"false", "javaType": "boolean", "type": "boolean" }, { "names": 
"--background-wait", "description": "To  [...]
@@ -15,7 +15,7 @@
     { "name": "eval", "fullName": "eval", "description": "Evaluate Camel 
expressions and scripts", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.EvalCommand", "options": [ { "names": 
"-h,--help", "description": "Display the help and sub-commands", "javaType": 
"boolean", "type": "boolean" } ], "subcommands": [ { "name": "expression", 
"fullName": "eval expression", "description": "Evaluates Camel expression", 
"sourceClass": "org.apache.camel.dsl.jbang.core.commands.action.EvalEx [...]
     { "name": "explain", "fullName": "explain", "description": "Explain what a 
Camel route does using AI\/LLM", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.Explain", "options": [ { "names": 
"--api-key", "description": "API key for authentication. Also reads 
ANTHROPIC_API_KEY, OPENAI_API_KEY, WATSONX_APIKEY, or LLM_API_KEY env vars", 
"javaType": "java.lang.String", "type": "string" }, { "names": "--api-type", 
"description": "API type: 'ollama', 'openai' (OpenAI-compatible), ' [...]
     { "name": "export", "fullName": "export", "description": "Export to other 
runtimes (Camel Main, Spring Boot, or Quarkus)", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.Export", "options": [ { "names": 
"--build-property", "description": "Maven build properties, ex. 
--build-property=prop1=foo", "javaType": "java.util.List", "type": "array" }, { 
"names": "--camel-spring-boot-version", "description": "Camel version to use 
with Spring Boot", "javaType": "java.lang.String", "ty [...]
-    { "name": "get", "fullName": "get", "description": "Get status of Camel 
integrations", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.process.CamelStatus", "options": [ { 
"names": "--watch", "description": "Execute periodically and showing output 
fullscreen", "javaType": "boolean", "type": "boolean" }, { "names": 
"-h,--help", "description": "Display the help and sub-commands", "javaType": 
"boolean", "type": "boolean" } ], "subcommands": [ { "name": "activity", 
"fullName": " [...]
+    { "name": "get", "fullName": "get", "description": "Get status of Camel 
integrations", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.process.CamelStatus", "options": [ { 
"names": "--watch", "description": "Execute periodically and showing output 
fullscreen", "javaType": "boolean", "type": "boolean" }, { "names": 
"-h,--help", "description": "Display the help and sub-commands", "javaType": 
"boolean", "type": "boolean" } ], "subcommands": [ { "name": "activity", 
"fullName": " [...]
     { "name": "harden", "fullName": "harden", "description": "Suggest security 
hardening for Camel routes using AI\/LLM", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.Harden", "options": [ { "names": 
"--api-key", "description": "API key for authentication. Also reads 
OPENAI_API_KEY or LLM_API_KEY env vars", "javaType": "java.lang.String", 
"type": "string" }, { "names": "--api-type", "description": "API type: 'ollama' 
or 'openai' (OpenAI-compatible)", "defaultValue": "ollama", [...]
     { "name": "hawtio", "fullName": "hawtio", "description": "Launch Hawtio 
web console", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.process.Hawtio", "options": [ { 
"names": "--host", "description": "Hostname to bind the Hawtio web console to", 
"defaultValue": "127.0.0.1", "javaType": "java.lang.String", "type": "string" 
}, { "names": "--openUrl", "description": "To automatic open Hawtio web console 
in the web browser", "defaultValue": "true", "javaType": "boolean", "type": 
[...]
     { "name": "infra", "fullName": "infra", "description": "List and Run 
external services for testing and prototyping", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.infra.InfraCommand", "options": [ { 
"names": "--json", "description": "Output in JSON Format", "javaType": 
"boolean", "type": "boolean" }, { "names": "-h,--help", "description": "Display 
the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], 
"subcommands": [ { "name": "get", "fullName": "infra  [...]
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
index ddf4bb64b6b9..6b2d2bee8eca 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
@@ -123,6 +123,7 @@ public class CamelJBangMain implements Callable<Integer> {
                         .addSubcommand("route-dump", new CommandLine(new 
CamelRouteDumpAction(this)))
                         .addSubcommand("route-structure", new CommandLine(new 
CamelRouteStructureAction(this)))
                         .addSubcommand("route-topology", new CommandLine(new 
CamelRouteTopologyAction(this)))
+                        .addSubcommand("semantic-evaluate", new 
CommandLine(new SemanticEvaluate(this)))
                         .addSubcommand("send", new CommandLine(new 
CamelSendAction(this)))
                         .addSubcommand("sql", new CommandLine(new 
CamelSqlQueryAction(this)))
                         .addSubcommand("span", new CommandLine(new 
CamelSpanAction(this)))
@@ -183,6 +184,7 @@ public class CamelJBangMain implements Callable<Integer> {
                         .addSubcommand("rest", new CommandLine(new 
ListRest(this)))
                         .addSubcommand("route", new CommandLine(new 
CamelRouteStatus(this)))
                         .addSubcommand("route-controller", new CommandLine(new 
RouteControllerAction(this)))
+                        .addSubcommand("semantic", new CommandLine(new 
CamelSemantic(this)))
                         .addSubcommand("service", new CommandLine(new 
ListService(this)))
                         .addSubcommand("source", new CommandLine(new 
CamelSourceAction(this)))
                         .addSubcommand("sql-trace", new CommandLine(new 
ListSqlTrace(this)))
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSemantic.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSemantic.java
new file mode 100644
index 000000000000..b9c167ba7ee1
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSemantic.java
@@ -0,0 +1,105 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.action;
+
+import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import picocli.CommandLine;
+
[email protected](name = "semantic", description = "List semantic 
definitions and expert contracts",
+                     sortOptions = false, showDefaultValues = true)
+public class CamelSemantic extends SemanticActionCommand {
+
+    @CommandLine.Option(names = "--expert", description = "Show the operations 
and parameter contract of this expert")
+    String expert;
+
+    public CamelSemantic(CamelJBangMain main) {
+        super(main);
+    }
+
+    @Override
+    protected JsonObject request() {
+        if (expert != null && expert.isBlank()) {
+            throw new IllegalArgumentException("--expert must not be blank");
+        }
+        JsonObject request = new JsonObject();
+        request.put("action", "semantic-metadata");
+        if (expert == null) {
+            request.put("overview", true);
+        } else {
+            request.put("expert", expert);
+        }
+        return request;
+    }
+
+    @Override
+    protected int printResponse(JsonObject response) {
+        if (expert != null) {
+            JsonArray operations = response.getCollection("operations");
+            if (operations == null || operations.isEmpty()) {
+                return error(3, "No operations available for semantic expert: 
" + expert, null);
+            }
+        }
+        return super.printResponse(response);
+    }
+
+    @Override
+    protected void render(JsonObject response) {
+        if (expert != null) {
+            printer().println("Expert: " + expert);
+            renderOperations(response.getCollection("operations"));
+            return;
+        }
+        printer().println("Definitions:");
+        printer().println("NAME\tEXPERT\tOPERATION\tSTATE\tRESULT 
TYPE\tERROR");
+        JsonArray evaluations = response.getCollection("evaluations");
+        if (evaluations != null) {
+            for (int i = 0; i < evaluations.size(); i++) {
+                JsonObject value = evaluations.getMap(i);
+                printer().println(String.join("\t", text(value, "name"), 
text(value, "expert"), text(value, "operation"),
+                        text(value, "state"), text(value, "resultType"), 
text(value, "error")));
+            }
+        }
+        printer().println("Experts:");
+        JsonArray experts = response.getCollection("experts");
+        if (experts != null) {
+            for (int i = 0; i < experts.size(); i++) {
+                JsonObject value = experts.getMap(i);
+                printer().println(text(value, "reference") + "\t" + 
text(value, "name") + "\t" + text(value, "error"));
+            }
+        }
+        printer().println("Default expert: " + text(response, 
"defaultExpert"));
+        if (response.get("defaultError") != null) {
+            printer().println("Default expert error: " + 
response.get("defaultError"));
+        }
+    }
+
+    private void renderOperations(JsonArray operations) {
+        for (int i = 0; i < operations.size(); i++) {
+            JsonObject operation = operations.getMap(i);
+            printer().println(text(operation, "name") + " (" + text(operation, 
"resultType") + "): "
+                              + text(operation, "description"));
+            
printer().println(Jsoner.prettyPrint(Jsoner.serialize(operation.get("contract"))));
+        }
+    }
+
+    private static String text(JsonObject value, String key) {
+        return value.get(key) == null ? "" : value.get(key).toString();
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticActionCommand.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticActionCommand.java
new file mode 100644
index 000000000000..f142c1ec01eb
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticActionCommand.java
@@ -0,0 +1,151 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.action;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.Arrays;
+import java.util.List;
+import java.util.UUID;
+
+import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
+import org.apache.camel.util.json.JsonObject;
+import picocli.CommandLine;
+
+public abstract class SemanticActionCommand extends ActionBaseCommand {
+
+    @CommandLine.Parameters(description = "Name or pid of a running Camel 
integration", arity = "0..1")
+    String name = "*";
+
+    @CommandLine.Option(names = "--json", description = "Output a single JSON 
document")
+    boolean json;
+
+    @CommandLine.Option(names = "--timeout", defaultValue = "60000",
+                        description = "Timeout in milliseconds waiting for the 
integration")
+    long timeout = 60000;
+
+    @CommandLine.Spec
+    CommandLine.Model.CommandSpec commandSpec;
+
+    protected SemanticActionCommand(CamelJBangMain main) {
+        super(main);
+    }
+
+    @Override
+    public Integer doCall() {
+        try {
+            if (timeout <= 0) {
+                return error(2, "--timeout must be greater than zero", null);
+            }
+            JsonObject request;
+            try {
+                request = request();
+            } catch (IllegalArgumentException e) {
+                return error(2, e.getMessage(), null);
+            }
+            List<Long> pids = 
findPids(name).stream().filter(this::isRunningIntegration).toList();
+            if (pids.isEmpty()) {
+                return error(3, "No running Camel integration matches: " + 
name, null);
+            }
+            if (pids.size() != 1) {
+                return error(3, "Multiple Camel integrations match " + name + 
": " + pids + ". Specify a pid.", null);
+            }
+            JsonObject response = execute(pids.get(0), request);
+            if (response == null) {
+                return error(4, "No reply from integration within " + timeout 
+ " milliseconds", null);
+            }
+            if (response.isEmpty()) {
+                return error(3, "Semantic tooling is unavailable; the 
integration needs a matching camel-semantic version",
+                        null);
+            }
+            if ("failed".equals(response.getString("status")) || 
"error".equals(response.getString("status"))) {
+                String message = response.getString("error");
+                if (message == null || message.isBlank()) {
+                    message = "Semantic action failed";
+                }
+                return error(1, message, response);
+            }
+            return printResponse(response);
+        } catch (Exception e) {
+            e.printStackTrace(commandSpec.commandLine().getErr());
+            return error(70, e.getMessage() != null ? e.getMessage() : 
e.getClass().getSimpleName(), null);
+        }
+    }
+
+    private boolean isRunningIntegration(long pid) {
+        JsonObject status = loadStatus(pid);
+        JsonObject context = status == null ? null : status.getMap("context");
+        return context != null && context.getIntegerOrDefault("phase", 0) < 9
+                && 
ProcessHandle.of(pid).filter(ProcessHandle::isAlive).isPresent();
+    }
+
+    private JsonObject execute(long pid, JsonObject request) throws Exception {
+        String id = UUID.randomUUID().toString();
+        Path directory = CommandLineHelper.getCamelDir();
+        Path action = directory.resolve(pid + "-action-" + id + ".json");
+        Path output = directory.resolve(pid + "-output-" + id + ".json");
+        Path temporary = directory.resolve(pid + "-" + id + ".tmp");
+        try {
+            Files.writeString(temporary, request.toJson());
+            Files.move(temporary, action, StandardCopyOption.ATOMIC_MOVE);
+            return getJsonObject(output, timeout);
+        } finally {
+            // Removing an unfinished request asks the connector to cancel the 
evaluation.
+            Files.deleteIfExists(action);
+            Files.deleteIfExists(output);
+            Files.deleteIfExists(temporary);
+        }
+    }
+
+    public int usageError(String message, String[] args) {
+        json = Arrays.stream(args).takeWhile(arg -> !"--".equals(arg))
+                .anyMatch(arg -> "--json".equals(arg) || 
"--json=true".equals(arg));
+        return error(2, message, null);
+    }
+
+    protected int error(int code, String message, JsonObject result) {
+        commandSpec.commandLine().getErr().println(message);
+        if (json) {
+            JsonObject response = new JsonObject();
+            if (result != null) {
+                response.putAll(result);
+            }
+            response.put("status", "error");
+            response.put("code", code);
+            response.put("message", message);
+            printer().println(response.toJson());
+        } else if (result != null) {
+            render(result);
+        }
+        return code;
+    }
+
+    protected int printResponse(JsonObject response) {
+        if (json) {
+            printer().println(response.toJson());
+        } else {
+            render(response);
+        }
+        return 0;
+    }
+
+    protected abstract JsonObject request();
+
+    protected abstract void render(JsonObject response);
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticEvaluate.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticEvaluate.java
new file mode 100644
index 000000000000..b58777a3849f
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticEvaluate.java
@@ -0,0 +1,168 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.action;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.ObjectReader;
+import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import picocli.CommandLine;
+
[email protected](name = "semantic-evaluate", description = "Evaluate a 
semantic definition or expert operation",
+                     sortOptions = false, showDefaultValues = true)
+public class SemanticEvaluate extends SemanticActionCommand {
+
+    private static final ObjectReader JSON_VALUE = new 
ObjectMapper().readerFor(Object.class)
+            .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
+
+    @CommandLine.Option(names = "--evaluation", description = "Named semantic 
definition to evaluate")
+    String evaluation;
+
+    @CommandLine.Option(names = "--body", description = "Sample message body 
(text, file:path or json:value)")
+    String body;
+
+    @CommandLine.Option(names = "--header", description = "Sample header 
(key=value; use json: for a typed value)")
+    List<String> headers;
+
+    @CommandLine.Option(names = "--variable",
+                        description = "Sample exchange-local variable 
(key=value; use json: for a typed value)")
+    List<String> variables;
+
+    @CommandLine.Option(names = "--expert", description = "Expert to call 
directly")
+    String expert;
+
+    @CommandLine.Option(names = "--operation", description = "Expert operation 
to call directly")
+    String operation;
+
+    @CommandLine.Option(names = "--input", description = "Direct expert input 
(text, file:path or json:value)")
+    String input;
+
+    @CommandLine.Option(names = "--parameter", description = "Expert parameter 
(key=value; use json: for a typed value)")
+    List<String> parameters;
+
+    @CommandLine.Option(names = "--evaluation-timeout", defaultValue = "50000",
+                        description = "Provider evaluation timeout in 
milliseconds (1-50000)")
+    long evaluationTimeout = 50000;
+
+    public SemanticEvaluate(CamelJBangMain main) {
+        super(main);
+    }
+
+    @Override
+    protected JsonObject request() {
+        if (evaluationTimeout < 1 || evaluationTimeout > 50000) {
+            throw new IllegalArgumentException("--evaluation-timeout must be 
between 1 and 50000 milliseconds");
+        }
+        JsonObject request = new JsonObject();
+        request.put("action", "semantic-evaluate");
+        request.put("timeout", evaluationTimeout);
+        if (evaluation != null) {
+            requireText(evaluation, "--evaluation");
+            if (expert != null || operation != null || input != null || 
parameters != null) {
+                throw new IllegalArgumentException("Choose --evaluation or 
--expert with --operation and --input");
+            }
+            request.put("evaluation", evaluation);
+            if (body != null) {
+                request.put("body", value(body, true));
+            }
+            if (headers != null) {
+                request.put("headers", values(headers, "--header"));
+            }
+            if (variables != null) {
+                JsonObject sampleVariables = values(variables, "--variable");
+                if (sampleVariables.keySet().stream().anyMatch(key -> 
key.contains(":"))) {
+                    throw new IllegalArgumentException(
+                            "Sample variables must have exchange-local names 
without a repository prefix");
+                }
+                request.put("variables", sampleVariables);
+            }
+        } else {
+            requireText(expert, "--expert");
+            requireText(operation, "--operation");
+            if (input == null) {
+                throw new IllegalArgumentException("--input is required for a 
direct expert operation");
+            }
+            if (body != null || headers != null || variables != null) {
+                throw new IllegalArgumentException("--body, --header and 
--variable require --evaluation");
+            }
+            request.put("expert", expert);
+            request.put("operation", operation);
+            request.put("input", value(input, true));
+            if (parameters != null) {
+                request.put("parameters", values(parameters, "--parameter"));
+            }
+        }
+        return request;
+    }
+
+    private static void requireText(String value, String option) {
+        if (value == null || value.isBlank()) {
+            throw new IllegalArgumentException(option + " is required and must 
not be blank");
+        }
+    }
+
+    private static JsonObject values(List<String> values, String option) {
+        JsonObject answer = new JsonObject();
+        for (String entry : values) {
+            int separator = entry.indexOf('=');
+            if (separator < 1 || entry.substring(0, separator).isBlank()) {
+                throw new IllegalArgumentException(option + " must use 
key=value");
+            }
+            String key = entry.substring(0, separator);
+            if (answer.containsKey(key)) {
+                throw new IllegalArgumentException("Duplicate " + option + " 
key: " + key);
+            }
+            answer.put(key, value(entry.substring(separator + 1), false));
+        }
+        return answer;
+    }
+
+    private static Object value(String value, boolean allowFile) {
+        if (value.startsWith("json:")) {
+            try {
+                return JSON_VALUE.readValue(value.substring(5));
+            } catch (Exception e) {
+                throw new IllegalArgumentException("Invalid json: value", e);
+            }
+        }
+        if (allowFile && value.startsWith("file:")) {
+            try {
+                return Files.readString(Path.of(value.substring(5)));
+            } catch (IOException e) {
+                throw new IllegalArgumentException("Cannot read input file: " 
+ value.substring(5), e);
+            }
+        }
+        return value;
+    }
+
+    @Override
+    protected void render(JsonObject response) {
+        for (String key : List.of("evaluation", "status", "value", 
"probability", "probabilities", "confidence",
+                "metadata", "elapsedMillis", "error")) {
+            if (response.containsKey(key)) {
+                printer().println(key + ": " + 
Jsoner.serialize(response.get(key)));
+            }
+        }
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/exceptionhandler/MissingPluginParameterExceptionHandler.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/exceptionhandler/MissingPluginParameterExceptionHandler.java
index 6826a8a2e52a..2d16706bcde5 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/exceptionhandler/MissingPluginParameterExceptionHandler.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/exceptionhandler/MissingPluginParameterExceptionHandler.java
@@ -19,6 +19,7 @@ package 
org.apache.camel.dsl.jbang.core.commands.exceptionhandler;
 import java.io.PrintWriter;
 import java.util.Optional;
 
+import org.apache.camel.dsl.jbang.core.commands.action.SemanticActionCommand;
 import org.apache.camel.dsl.jbang.core.common.PluginHelper;
 import org.apache.camel.dsl.jbang.core.common.PluginType;
 import picocli.CommandLine;
@@ -33,6 +34,9 @@ public class MissingPluginParameterExceptionHandler 
implements IParameterExcepti
     public int handleParseException(ParameterException ex, String[] args) 
throws Exception {
         CommandLine cmd = ex.getCommandLine();
         PrintWriter err = cmd.getErr();
+        if (cmd.getCommand() instanceof SemanticActionCommand semantic) {
+            return semantic.usageError(ex.getMessage(), args);
+        }
 
         if (ex.getMessage().startsWith("Unmatched argument at index 0") && 
args.length > 0) {
             Optional<PluginType> pluginType = PluginType.findByName(args[0]);
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticCommandTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticCommandTest.java
new file mode 100644
index 000000000000..2ee93a075b0a
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticCommandTest.java
@@ -0,0 +1,415 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.action;
+
+import java.io.PrintWriter;
+import java.io.StringReader;
+import java.io.StringWriter;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
+
+import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import 
org.apache.camel.dsl.jbang.core.commands.exceptionhandler.MissingPluginParameterExceptionHandler;
+import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
+import org.apache.camel.dsl.jbang.core.common.StringPrinter;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import picocli.CommandLine;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class SemanticCommandTest {
+
+    @TempDir
+    Path home;
+    private final long pid = ProcessHandle.current().pid();
+    private final StringPrinter printer = new StringPrinter();
+    private final StringWriter errors = new StringWriter();
+    private ExecutorService executor;
+    private Path previousHome;
+
+    @BeforeEach
+    void setup() throws Exception {
+        previousHome = CommandLineHelper.getHomeDir();
+        CommandLineHelper.useHomeDir(home.toString());
+        Files.createDirectories(CommandLineHelper.getCamelDir());
+        Files.writeString(CommandLineHelper.getCamelDir().resolve(pid + 
"-status.json"),
+                "{\"context\":{\"name\":\"semantic-test\",\"phase\":4}}");
+        executor = Executors.newCachedThreadPool();
+    }
+
+    @AfterEach
+    void stop() throws Exception {
+        executor.shutdownNow();
+        assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS));
+        CommandLineHelper.useHomeDir(previousHome.toString());
+    }
+
+    @Test
+    void listsDefinitionsAndExperts() throws Exception {
+        JsonObject response = json("""
+                
{"evaluations":[{"name":"safe","expert":"guard","operation":"detect",
+                "state":"${body}","resultType":"boolean","error":"definition 
error"}],
+                "experts":[{"reference":"guard","name":"Safety 
guard","error":"expert error"}],
+                "defaultError":"No default expert"}
+                """);
+        AtomicReference<JsonObject> request = new AtomicReference<>();
+        assertEquals(0, respond(new CamelSemantic(main()), r -> {
+            request.set(r);
+            return response;
+        }));
+        assertEquals("semantic-metadata", request.get().get("action"));
+        assertEquals(true, request.get().get("overview"));
+        
assertTrue(printer.getOutput().contains("safe\tguard\tdetect\t${body}\tboolean\tdefinition
 error"));
+        assertTrue(printer.getOutput().contains("Safety guard"));
+        assertTrue(printer.getOutput().contains("expert error"));
+        assertTrue(printer.getOutput().contains("No default expert"));
+        assertEquals("", errors.toString());
+    }
+
+    @Test
+    void emitsMetadataAsOneJsonDocumentIncludingEmptyLists() throws Exception {
+        JsonObject response = json("{\"evaluations\":[],\"experts\":[]}");
+        assertEquals(0, respond(new CamelSemantic(main()), r -> response, 
"--json"));
+        assertEquals(response, json(printer.getOutput()));
+        assertEquals("", errors.toString());
+    }
+
+    @Test
+    void showsExpertOperationAndParameterContract() throws Exception {
+        JsonObject response = json("""
+                {"operations":[{"name":"rank","description":"Rank 
text","resultType":"score",
+                
"contract":{"inputTypes":["text"],"parameters":[{"name":"criteria","type":"List",
+                
"itemType":"String","required":true}],"resultMeaning":"quality"}}]}
+                """);
+        assertEquals(0, respond(new CamelSemantic(main()), r -> {
+            assertEquals("grader", r.get("expert"));
+            assertFalse(r.containsKey("overview"));
+            return response;
+        }, "--expert=grader"));
+        assertTrue(printer.getOutput().contains("rank (score): Rank text"));
+        assertTrue(printer.getOutput().contains("criteria"));
+        assertTrue(printer.getOutput().contains("quality"));
+    }
+
+    @Test
+    void unavailableExpertReturnsNotFound() throws Exception {
+        assertEquals(3, respond(new CamelSemantic(main()), r -> 
jsonUnchecked("{\"operations\":[]}"),
+                "--expert=unknown", "--json"));
+        assertError(3, "unknown");
+    }
+
+    @Test
+    void evaluatesNamedDefinitionWithSampleExchangeWithoutDoubleEscaping() 
throws Exception {
+        String body = "A \"quote\"\nwith a backslash \\ and = sign";
+        assertEquals(0, respond(new SemanticEvaluate(main()), r -> {
+            assertEquals("semantic-evaluate", r.get("action"));
+            assertEquals("safe", r.get("evaluation"));
+            assertEquals(body, r.get("body"));
+            assertEquals("a=b", r.getMap("headers").get("subject"));
+            assertEquals(true, r.getMap("variables").get("enabled"));
+            assertEquals(1000L, r.getLong("timeout"));
+            return 
jsonUnchecked("{\"status\":\"success\",\"value\":false,\"elapsedMillis\":12}");
+        }, "--evaluation=safe", "--body=" + body, "--header=subject=a=b", 
"--variable=enabled=json:true",
+                "--evaluation-timeout=1000", "--json"));
+        assertEquals(false, json(printer.getOutput()).get("value"));
+        assertEquals("", errors.toString());
+    }
+
+    @Test
+    void evaluatesDirectExpertWithTypedParameters() throws Exception {
+        assertEquals(0, respond(new SemanticEvaluate(main()), r -> {
+            assertEquals("grader", r.get("expert"));
+            assertEquals("rank", r.get("operation"));
+            assertEquals(List.of("one", "two"), r.get("input"));
+            JsonObject parameters = r.getMap("parameters");
+            assertEquals(List.of("poor", "good"), parameters.get("criteria"));
+            assertEquals(0.7, parameters.getDouble("threshold"));
+            assertEquals(true, parameters.get("strict"));
+            assertEquals("plain text", parameters.get("description"));
+            return jsonUnchecked("""
+                    
{"status":"success","value":0.8,"probability":0.9,"probabilities":{"good":0.9},
+                    "confidence":0.85,"elapsedMillis":12}
+                    """);
+        }, "--expert=grader", "--operation=rank", 
"--input=json:[\"one\",\"two\"]",
+                "--parameter=criteria=json:[\"poor\",\"good\"]", 
"--parameter=threshold=json:0.7",
+                "--parameter=strict=json:true", "--parameter=description=plain 
text"));
+        for (String key : List.of("value", "probability", "probabilities", 
"confidence", "elapsedMillis")) {
+            assertTrue(printer.getOutput().contains(key + ":"));
+        }
+    }
+
+    @Test
+    void readsBodyFileAsText() throws Exception {
+        Path input = home.resolve("sample.txt");
+        Files.writeString(input, "json:literal\n");
+        assertEquals(0, respond(new SemanticEvaluate(main()), r -> {
+            assertEquals("json:literal\n", r.get("body"));
+            return jsonUnchecked("{\"status\":\"success\",\"value\":true}");
+        }, "--evaluation=safe", "--body=file:" + input));
+    }
+
+    @Test
+    void providerFailureHasNonzeroExitAndRetainsTimingInJson() throws 
Exception {
+        assertEquals(1, respond(new SemanticEvaluate(main()), r -> 
jsonUnchecked("""
+                {"status":"failed","error":"provider 
unavailable","elapsedMillis":23}
+                """), "--evaluation=safe", "--json"));
+        assertError(1, "provider unavailable");
+        assertEquals(23L, json(printer.getOutput()).getLong("elapsedMillis"));
+    }
+
+    @Test
+    void providerFailureWithoutMessageStillHasDiagnostic() throws Exception {
+        assertEquals(1, respond(new SemanticEvaluate(main()), r -> 
jsonUnchecked(
+                "{\"status\":\"failed\",\"error\":null}"), 
"--evaluation=safe", "--json"));
+        assertError(1, "Semantic action failed");
+    }
+
+    @Test
+    void rejectsDuplicateParameterKeys() throws Exception {
+        assertEquals(2, command(new 
SemanticEvaluate(main())).execute(Long.toString(pid), "--expert=grader",
+                "--operation=rank", "--input=text", "--parameter=x=first", 
"--parameter=x=second", "--json"));
+        assertError(2, "Duplicate --parameter key: x");
+        assertTrue(actionFiles().isEmpty());
+    }
+
+    @Test
+    void missingBodyFileIsUsageError() throws Exception {
+        assertEquals(2, command(new 
SemanticEvaluate(main())).execute(Long.toString(pid), "--evaluation=safe",
+                "--body=file:" + home.resolve("missing.txt"), "--json"));
+        assertError(2, "Cannot read input file");
+        assertTrue(actionFiles().isEmpty());
+    }
+
+    @Test
+    void textUsageErrorsGoOnlyToStderr() throws Exception {
+        assertEquals(2, command(new 
SemanticEvaluate(main())).execute(Long.toString(pid)));
+        assertEquals("", printer.getOutput());
+        assertTrue(errors.toString().contains("--expert is required"));
+        assertTrue(actionFiles().isEmpty());
+    }
+
+    @Test
+    void unavailableConsoleReturnsNotFound() throws Exception {
+        assertEquals(3, respond(new SemanticEvaluate(main()), r -> new 
JsonObject(), "--evaluation=safe", "--json"));
+        assertError(3, "camel-semantic");
+    }
+
+    @Test
+    void timeoutCleansRequestAndIgnoresLegacyReply() throws Exception {
+        Files.writeString(CommandLineHelper.getCamelDir().resolve(pid + 
"-output.json"),
+                "{\"status\":\"success\",\"value\":true}");
+        assertEquals(4, command(new 
SemanticEvaluate(main())).execute(Long.toString(pid), "--evaluation=safe",
+                "--timeout=150", "--json"));
+        assertError(4, "No reply");
+        assertTrue(actionFiles().isEmpty());
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {
+            "--parameter=bad", "--parameter==value", "--parameter=x=json:[bad",
+            "--variable=global:x=value", "--evaluation=", 
"--evaluation-timeout=50001", "--timeout=0",
+            "--expert=other", "--unknown", "--timeout=not-a-number" })
+    void rejectsBadUsageWithoutSendingAnAction(String invalid) throws 
Exception {
+        assertEquals(2, command(new 
SemanticEvaluate(main())).execute(Long.toString(pid), "--evaluation=safe", 
invalid,
+                "--json"));
+        assertError(2, "");
+        assertTrue(actionFiles().isEmpty());
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = { "bad", "=value", "x=json:[bad", "x=json:1 2", 
"x=json:[1,]", "x=json:" })
+    void rejectsMalformedDirectParameters(String parameter) throws Exception {
+        assertEquals(2, command(new 
SemanticEvaluate(main())).execute(Long.toString(pid), "--expert=grader",
+                "--operation=rank", "--input=text", "--parameter=" + 
parameter, "--json"));
+        assertError(2, "");
+        assertTrue(actionFiles().isEmpty());
+    }
+
+    @Test
+    void requiresAnIntegrationStatusFileEvenForNumericPid() throws Exception {
+        Files.delete(CommandLineHelper.getCamelDir().resolve(pid + 
"-status.json"));
+        assertEquals(3, command(new 
CamelSemantic(main())).execute(Long.toString(pid), "--json"));
+        assertError(3, "No running Camel integration");
+        assertTrue(actionFiles().isEmpty());
+    }
+
+    @Test
+    void rejectsAmbiguousIntegrationSelection() throws Exception {
+        CamelSemantic semantic = new CamelSemantic(main()) {
+            @Override
+            List<Long> findPids(String name) {
+                return List.of(pid, pid);
+            }
+        };
+        assertEquals(3, command(semantic).execute("--json"));
+        assertError(3, "Multiple Camel integrations");
+        assertTrue(errors.toString().contains(Long.toString(pid)));
+        assertTrue(actionFiles().isEmpty());
+    }
+
+    @Test
+    void ioFailureIsInternalErrorRatherThanTimeout() throws Exception {
+        CamelSemantic semantic = new CamelSemantic(main()) {
+            @Override
+            List<Long> findPids(String name) {
+                
CommandLineHelper.useHomeDir(home.resolve("missing").toString());
+                return List.of(pid);
+            }
+
+            @Override
+            JsonObject loadStatus(long id) {
+                return jsonUnchecked("{\"context\":{\"phase\":4}}");
+            }
+        };
+        assertEquals(70, command(semantic).execute(Long.toString(pid), 
"--json"));
+        assertError(70, "");
+        assertTrue(errors.toString().contains("NoSuchFileException"));
+    }
+
+    @Test
+    void concurrentClientsReceiveOnlyTheirOwnResponse() throws Exception {
+        StringPrinter secondPrinter = new StringPrinter();
+        var first = CompletableFuture.supplyAsync(() -> command(new 
SemanticEvaluate(main())).execute(Long.toString(pid),
+                "--evaluation=first", "--json"), executor);
+        var second = CompletableFuture.supplyAsync(() -> command(new 
SemanticEvaluate(
+                new CamelJBangMain()
+                        .withPrinter(secondPrinter)))
+                .execute(Long.toString(pid), "--evaluation=second", "--json"), 
executor);
+        await().atMost(5, TimeUnit.SECONDS).until(() -> actionFiles().size() 
== 2);
+        List<Path> requests = actionFiles();
+        assertNotEquals(requests.get(0), requests.get(1));
+        for (Path request : requests) {
+            JsonObject action = json(Files.readString(request));
+            JsonObject response = new JsonObject();
+            response.put("status", "success");
+            response.put("value", action.get("evaluation"));
+            reply(request, response);
+        }
+        assertEquals(0, first.get(5, TimeUnit.SECONDS));
+        assertEquals(0, second.get(5, TimeUnit.SECONDS));
+        assertEquals("first", json(printer.getOutput()).get("value"));
+        assertEquals("second", json(secondPrinter.getOutput()).get("value"));
+        assertTrue(actionFiles().isEmpty());
+    }
+
+    @Test
+    void mainRegistersBothCommandsAndFormatsParseErrorsAsJson() {
+        CamelJBangMain main = new CamelJBangMain() {
+            @Override
+            public void postAddCommands(CommandLine command, String[] args) {
+                
assertNotNull(command.getSubcommands().get("get").getSubcommands().get("semantic"));
+                
assertNotNull(command.getSubcommands().get("cmd").getSubcommands().get("semantic-evaluate"));
+            }
+
+            @Override
+            public void preExecute(CommandLine command, String[] args) {
+                command.setErr(new PrintWriter(errors, true));
+            }
+
+            @Override
+            public void quit(int code) {
+                assertEquals(2, code);
+            }
+        };
+        main.withPrinter(printer);
+        main.execute("cmd", "semantic-evaluate", "--bad-option", "--json");
+        assertError(2, "Unknown option");
+    }
+
+    private CamelJBangMain main() {
+        return new CamelJBangMain().withPrinter(printer);
+    }
+
+    private CommandLine command(SemanticActionCommand command) {
+        return new CommandLine(command).setErr(new PrintWriter(errors, true))
+                .setParameterExceptionHandler(new 
MissingPluginParameterExceptionHandler());
+    }
+
+    private int respond(SemanticActionCommand command, Function<JsonObject, 
JsonObject> response, String... options)
+            throws Exception {
+        var responder = CompletableFuture.runAsync(() -> {
+            try {
+                await().atMost(5, TimeUnit.SECONDS).until(() -> 
!actionFiles().isEmpty());
+                Path request = actionFiles().get(0);
+                await().atMost(5, TimeUnit.SECONDS).until(() -> 
Files.size(request) > 0);
+                reply(request, 
response.apply(json(Files.readString(request))));
+            } catch (Exception e) {
+                throw new RuntimeException(e);
+            }
+        }, executor);
+        List<String> args = new ArrayList<>(List.of(options));
+        args.add(Long.toString(pid));
+        int result = command(command).execute(args.toArray(String[]::new));
+        responder.get(5, TimeUnit.SECONDS);
+        assertTrue(actionFiles().isEmpty());
+        return result;
+    }
+
+    private static void reply(Path request, JsonObject response) throws 
Exception {
+        Path output = 
request.resolveSibling(request.getFileName().toString().replace("-action-", 
"-output-"));
+        Files.writeString(output, response.toJson());
+    }
+
+    private List<Path> actionFiles() throws Exception {
+        try (var files = Files.list(CommandLineHelper.getCamelDir())) {
+            return files.filter(path -> 
path.getFileName().toString().startsWith(pid + "-action-")).toList();
+        }
+    }
+
+    private void assertError(int code, String message) {
+        JsonObject result = jsonUnchecked(printer.getOutput());
+        assertEquals("error", result.get("status"));
+        assertEquals(code, result.getInteger("code"));
+        assertTrue(result.getString("message").contains(message));
+        assertFalse(errors.toString().isBlank());
+    }
+
+    private static JsonObject json(String value) throws Exception {
+        JsonArray documents = Jsoner.deserializeMany(new StringReader(value));
+        assertEquals(1, documents.size(), "Expected exactly one JSON 
document");
+        return documents.getMap(0);
+    }
+
+    private static JsonObject jsonUnchecked(String value) {
+        try {
+            return json(value);
+        } catch (Exception e) {
+            throw new IllegalArgumentException(e);
+        }
+    }
+}

Reply via email to