This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 63a049b286c7 CAMEL-24798: camel-jbang - camel_get_files understands
the Maven project layout
63a049b286c7 is described below
commit 63a049b286c7f1ecb43c360653571c45d8c59d68
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Sep 17 20:50:16 2026 +0200
CAMEL-24798: camel-jbang - camel_get_files understands the Maven project
layout
A local model asking "what's the name of the source file that has the
route" against camel run --runtime=spring-boot cost 26 requests and a
wrong answer, because camel_get_files listed one directory level and
never reached src/main/resources/camel.
The shared tool (camel mcp, camel tui --mcp, F8 panel) now lists the
project recursively with relative paths, skipping build and VCS
directories, and says whether it is a maven or flat layout. routeFiles
and configFiles name the route and configuration files up front, with
a kind per entry. Each running route's source (nested jar entry,
classpath:, file: or Java class) is mapped onto the project file and
line, or reported missing. file and camel_write_file take relative
paths; absolute or escaping paths are refused with a clear message, and
a missing file names the listing to call. In the TUI, the integration's
name works as the directory. The TUI's own listing/reading code is
replaced by the shared implementation.
Docs updated on the MCP and TUI pages; AuthoringToolsTest,
McpFacadeGetFilesTest and McpFacadeWriteFileTest cover the layout,
path safety and route-source mapping.
Closes #26558
Co-Authored-By: Claude <[email protected]>
---
.../modules/ROOT/pages/camel-jbang-mcp.adoc | 7 +-
.../modules/ROOT/pages/camel-jbang-tui.adoc | 7 +
.../dsl/jbang/core/commands/ai/AuthoringTools.java | 320 +++++++++++++++++++--
.../jbang/core/commands/ai/AuthoringToolsTest.java | 106 ++++++-
.../jbang/core/commands/mcp/AuthoringTools.java | 14 +-
.../dsl/jbang/core/commands/tui/McpFacade.java | 95 +++---
.../dsl/jbang/core/commands/tui/RouteInfo.java | 2 +
.../dsl/jbang/core/commands/tui/StatusParser.java | 1 +
.../jbang/core/commands/tui/TuiToolRegistry.java | 12 +-
.../core/commands/tui/McpFacadeGetFilesTest.java | 138 +++++++++
.../core/commands/tui/McpFacadeWriteFileTest.java | 9 +-
11 files changed, 624 insertions(+), 87 deletions(-)
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
index 6b272ccadbd8..99637a52a42b 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
@@ -315,7 +315,12 @@ project `directory` as an argument, the runtime tools take
the integration `name
read from its `camel-yaml-dsl` jar; without it the CLI's own, with nothing
to download.
| `camel_get_files`
-| The source files of a project directory (name, size, type), or the content
of one of them.
+| The source files of a project directory, subdirectories included (`target`,
`.git` and the like skipped). The
+ list says whether the directory is a Maven project or a flat folder, names
the route files and the configuration
+ files up front (`routeFiles`, `configFiles`) and, when an integration is
selected, which file and line each of its
+ routes was loaded from, mapped from the runtime's location (a jar entry of
an exported project, a classpath or
+ file resource) onto the source under `src/main`. With `file`, a path
relative to the directory as the list names
+ it, the content of that file.
| `camel_write_file`
| Writes the complete content of a file in the project directory. YAML and
`.properties` content is validated
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
index accb6284a792..3cc8ee23226b 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
@@ -1274,6 +1274,13 @@ sense: `devMode` (changes are reloaded automatically),
`temporary` (a copy that
integration stops) and an `editing` hint. `camel_write_file` then writes the
complete new content of
a file in that directory.
+An integration started with `--runtime=spring-boot` or `--runtime=quarkus`
runs from an exported Maven
+project, where the routes sit under `src/main/resources/camel` rather than
next to a `pom.xml`. The
+listing understands that layout: it names the route and configuration files
first, lists every file
+with its path relative to the project (build output skipped), and maps each
running route to its file
+and line, so the agent reads the right file in one call instead of guessing
names. File paths given to
+`camel_get_files` and `camel_write_file` are relative to that directory and
may name a subdirectory.
+
Every write is confirmed in the TUI first: a dialog names the file, the
directory and the size of the
change (`+3 -1` lines); press *d* to see the change as a unified diff, with
removed lines on red and
added lines on green like the Source tab's *F7* diff, and scroll it with the
arrow keys; *Enter*, *Esc* or
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
index f63629556da2..fcb10cf3b2ec 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
@@ -18,14 +18,23 @@ package org.apache.camel.dsl.jbang.core.commands.ai;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
+import java.nio.file.FileVisitOption;
+import java.nio.file.FileVisitResult;
import java.nio.file.Files;
+import java.nio.file.InvalidPathException;
import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.EnumSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
+import java.util.Set;
import java.util.function.Consumer;
-import java.util.stream.Stream;
+import java.util.regex.Pattern;
import org.apache.camel.dsl.jbang.core.common.RuntimeHelper;
import org.apache.camel.util.json.JsonArray;
@@ -53,7 +62,18 @@ public final class AuthoringTools {
static final String DIRECTORY_DESC = "Project directory with the source
files (default: the selected integration's)";
/** Files listed and read by the file tools; more than that and a
directory is not an integration's sources. */
+ static final String FILE_PATH_DESC = "File path relative to the directory,
e.g. src/main/resources/camel/foo.camel.yaml";
private static final int MAX_FILES = 99;
+ /** How many files a listing looks at before it stops; the route and
configuration files are found among them. */
+ private static final int SCAN_LIMIT = 2000;
+ private static final int MAX_DEPTH = 8;
+ /** Build output, tooling and VCS directories: never sources. */
+ private static final Set<String> SKIPPED_DIRS = Set.of(
+ "target", "build", "out", "node_modules", ".git", ".mvn", ".idea",
".vscode", ".gradle", ".settings",
+ ".camel-jbang");
+ private static final Pattern YAML_ROUTE = Pattern.compile(
+
"(?m)^\\s*-\\s*(route|from|rest|routeTemplate|route-template|templatedRoute|templated-route"
+ +
"|routeConfiguration|route-configuration|kamelet)\\s*:");
private AuthoringTools() {
}
@@ -145,11 +165,12 @@ public final class AuthoringTools {
}));
registry.accept(tool("camel_get_files",
- "The source files of a project directory: without file the
list (name, size, type), with file its "
- + "content. Use before editing
to see the routes, configuration and other "
- + "files of the integration.")
+ "The source files of a project directory, subdirectories
included: without file the list, with "
+ + "the route and configuration
files named first (routeFiles, configFiles) "
+ + "and, for a running
integration, which file and line each route comes "
+ + "from; with file (a path
relative to the directory, as listed) its content.")
.param("directory", "string", DIRECTORY_DESC, false)
- .param("file", "string", "File name to read; omitted lists the
files", false)
+ .param("file", "string", FILE_PATH_DESC + " to read; omitted
lists the files", false)
.core(true)
.executor((ctx, args) -> {
Path dir = ctx.resolveDirectory(args.get("directory"));
@@ -157,7 +178,17 @@ public final class AuthoringTools {
if (file != null && !file.isBlank()) {
return readFile(dir, file).toJson();
}
- return listFiles(dir).toJson();
+ JsonObject result = listFiles(dir);
+ if (ctx.hasProcess()) {
+ // the selected integration's routes, when they come
from this directory
+ JsonObject status = ctx.readFullStatus();
+ JsonArray routes = routeSources(
+ status != null && status.get("routes")
instanceof Collection<?> c ? c : null, dir);
+ if (routes.stream().anyMatch(r -> !((JsonObject)
r).containsKey("missing"))) {
+ result.put("routes", routes);
+ }
+ }
+ return result.toJson();
}));
registry.accept(tool("camel_write_file",
@@ -166,7 +197,7 @@ public final class AuthoringTools {
+ "An integration running in
dev mode reloads the change, otherwise restart it "
+ "with camel_control.")
.param("directory", "string", DIRECTORY_DESC, false)
- .param("file", "string", "File name, no path", true)
+ .param("file", "string", FILE_PATH_DESC + " (subdirectories
are created)", true)
.param("content", "string", "The complete new content", true)
.param("validate", "boolean", "Validate before writing
(default true)", false)
.param("camelVersion", "string", VERSION_DESC, false)
@@ -323,6 +354,7 @@ public final class AuthoringTools {
}
}
try {
+ Files.createDirectories(path.getParent());
Files.writeString(path, content, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new ToolExecutionException("Failed to write " + path + ": "
+ e.getMessage());
@@ -338,29 +370,55 @@ public final class AuthoringTools {
return result;
}
- /** The files of a project directory, as {@code camel_get_files} lists
them. */
+ /**
+ * The files of a project directory, subdirectories included, as {@code
camel_get_files} lists them. A human answers
+ * "which file has the route" with one {@code ls -R}; this gives a model
the same in one call: the layout
+ * ({@code maven} when the directory is a Maven project, else {@code
flat}), the route files and the configuration
+ * files named up front, then every file with its path relative to the
directory (build output and tooling
+ * directories skipped), capped at {@value #MAX_FILES} entries.
+ */
public static JsonObject listFiles(Path dir) {
+ List<Path> all = projectFiles(dir);
+ boolean maven = Files.isRegularFile(dir.resolve("pom.xml")) &&
Files.isDirectory(dir.resolve("src/main"));
+ JsonArray routeFiles = new JsonArray();
+ JsonArray configFiles = new JsonArray();
JsonArray files = new JsonArray();
- try (Stream<Path> stream = Files.list(dir)) {
- stream.filter(Files::isRegularFile)
- .sorted((a, b) ->
a.getFileName().toString().compareToIgnoreCase(b.getFileName().toString()))
- .limit(MAX_FILES)
- .forEach(p -> {
- JsonObject entry = new JsonObject();
- entry.put("name", p.getFileName().toString());
- entry.put("size", formatSize(size(p)));
- entry.put("type",
fileType(p.getFileName().toString()));
- files.add(entry);
- });
- } catch (IOException e) {
- throw new ToolExecutionException("Cannot list " + dir + ": " +
e.getMessage());
+ for (Path p : all) {
+ String rel = relativePath(dir, p);
+ String type = fileType(rel);
+ String kind = fileKind(p, rel, type);
+ if ("route".equals(kind)) {
+ routeFiles.add(rel);
+ } else if ("config".equals(kind)) {
+ configFiles.add(rel);
+ }
+ if (files.size() < MAX_FILES) {
+ JsonObject entry = new JsonObject();
+ entry.put("name", rel);
+ entry.put("size", formatSize(size(p)));
+ entry.put("type", type);
+ if (kind != null) {
+ entry.put("kind", kind);
+ }
+ files.add(entry);
+ }
}
JsonObject result = new JsonObject();
result.put("directory", dir.toString());
+ result.put("layout", maven ? "maven" : "flat");
+ result.put("routeFiles", routeFiles);
+ result.put("configFiles", configFiles);
result.put("files", files);
- result.put("totalFiles", files.size());
- if (files.isEmpty()) {
+ result.put("totalFiles", all.size());
+ if (all.isEmpty()) {
result.put("message", "The directory has no files");
+ } else if (all.size() > MAX_FILES) {
+ result.put("message", "Listing the first " + MAX_FILES + " of " +
all.size()
+ + " files; routeFiles and configFiles name
every route and configuration file");
+ }
+ if (maven) {
+ result.put("hint", "Maven project: routes live under
src/main/resources/camel or src/main/java and the"
+ + " configuration under src/main/resources;
pass file as the path listed here");
}
return result;
}
@@ -369,7 +427,9 @@ public final class AuthoringTools {
public static JsonObject readFile(Path dir, String file) {
Path path = resolveFile(dir, file);
if (!Files.isRegularFile(path)) {
- throw new ToolExecutionException("No such file in the directory: "
+ file);
+ throw new ToolExecutionException(
+ "No such file: " + file + " in " + dir
+ + "; call camel_get_files without
file to list them (routeFiles names the routes)");
}
JsonObject result = new JsonObject();
result.put("file", file);
@@ -380,14 +440,216 @@ public final class AuthoringTools {
return result;
}
- /** A plain file name inside the directory; anything else (a path, a
parent reference) is refused. */
- static Path resolveFile(Path dir, String file) {
+ /**
+ * Where each route of a running integration comes from, mapped onto the
files of the project directory. The status
+ * document's {@code routes[].source} names what the runtime loaded: a jar
entry for an exported project
+ * ({@code
nested:.../target/app.jar/!BOOT-INF/classes/!/camel/foo.camel.yaml:4}), a
{@code classpath:} or
+ * {@code file:} resource, or a Java class; the answer is the path a model
can read and edit
+ * ({@code src/main/resources/camel/foo.camel.yaml}) with the line, or the
location as given with {@code missing}
+ * when no such file exists under the directory.
+ *
+ * @param routes the status document's routes (maps with {@code routeId}
and {@code source}), may be null
+ */
+ public static JsonArray routeSources(Collection<?> routes, Path dir) {
+ JsonArray out = new JsonArray();
+ if (routes == null) {
+ return out;
+ }
+ for (Object o : routes) {
+ if (!(o instanceof Map<?, ?> r) || !(r.get("source") instanceof
String source) || source.isBlank()) {
+ continue;
+ }
+ SourceLocation loc = sourceLocation(source, dir);
+ JsonObject e = new JsonObject();
+ if (r.get("routeId") != null) {
+ e.put("routeId", String.valueOf(r.get("routeId")));
+ }
+ e.put("file", loc.file());
+ if (loc.line() > 0) {
+ e.put("line", loc.line());
+ }
+ if (!loc.exists()) {
+ e.put("missing", true);
+ }
+ out.add(e);
+ }
+ return out;
+ }
+
+ record SourceLocation(String file, int line, boolean exists) {
+ }
+
+ /** Maps one route source location onto a file under the directory; see
{@link #routeSources}. */
+ static SourceLocation sourceLocation(String source, Path dir) {
+ String s = source.trim();
+ int line = 0;
+ int colon = s.lastIndexOf(':');
+ if (colon > 0 && colon < s.length() - 1) {
+ String suffix = s.substring(colon + 1);
+ if (suffix.chars().allMatch(Character::isDigit)) {
+ try {
+ line = Integer.parseInt(suffix);
+ s = s.substring(0, colon);
+ } catch (NumberFormatException e) {
+ // too many digits for a line number: leave the location
as it is
+ }
+ }
+ }
+ String rel;
+ int bang = s.lastIndexOf("!/");
+ if (bang >= 0) {
+ rel = s.substring(bang + 2);
+ } else if (s.startsWith("classpath:")) {
+ rel = s.substring("classpath:".length());
+ } else if (s.startsWith("file:")) {
+ rel = s.substring("file:".length());
+ } else {
+ rel = s;
+ }
+ rel = rel.replace('\\', '/');
+ try {
+ Path abs = Path.of(rel);
+ if (abs.isAbsolute() && Files.isRegularFile(abs)) {
+ return new SourceLocation(abs.startsWith(dir) ?
relativePath(dir, abs) : abs.toString(), line, true);
+ }
+ } catch (InvalidPathException e) {
+ // not a path on this system; try it as a relative location below
+ }
+ while (rel.startsWith("/")) {
+ rel = rel.substring(1);
+ }
+ List<String> candidates = new ArrayList<>();
+ candidates.add("src/main/resources/" + rel);
+ candidates.add("src/main/java/" + rel);
+ if (!rel.contains("/") && !rel.contains(".java") && rel.contains("."))
{
+ // a Java route named by its class: org.acme.MyRoute
+ candidates.add("src/main/java/" + rel.replace('.', '/') + ".java");
+ }
+ candidates.add(rel);
+ for (String c : candidates) {
+ try {
+ Path p = dir.resolve(c).normalize();
+ if (p.startsWith(dir) && Files.isRegularFile(p)) {
+ return new SourceLocation(c, line, true);
+ }
+ } catch (InvalidPathException e) {
+ // skip
+ }
+ }
+ // last resort: a file of that name anywhere in the project (a flat
layout, a file that moved)
+ String leaf = rel.substring(rel.lastIndexOf('/') + 1);
+ if (!leaf.isEmpty()) {
+ for (Path p : projectFiles(dir)) {
+ if (p.getFileName().toString().equals(leaf)) {
+ return new SourceLocation(relativePath(dir, p), line,
true);
+ }
+ }
+ }
+ return new SourceLocation(rel, line, false);
+ }
+
+ /** The regular files under the directory, sorted by path, build and
tooling directories skipped. */
+ private static List<Path> projectFiles(Path dir) {
+ List<Path> files = new ArrayList<>();
+ try {
+ Files.walkFileTree(dir, EnumSet.noneOf(FileVisitOption.class),
MAX_DEPTH, new SimpleFileVisitor<>() {
+ @Override
+ public FileVisitResult preVisitDirectory(Path d,
BasicFileAttributes attrs) {
+ if (d.equals(dir)) {
+ return FileVisitResult.CONTINUE;
+ }
+ String name = d.getFileName().toString();
+ return SKIPPED_DIRS.contains(name) || name.startsWith(".")
+ ? FileVisitResult.SKIP_SUBTREE :
FileVisitResult.CONTINUE;
+ }
+
+ @Override
+ public FileVisitResult visitFile(Path f, BasicFileAttributes
attrs) {
+ if (attrs.isRegularFile() &&
!f.getFileName().toString().startsWith(".")) {
+ files.add(f);
+ }
+ return files.size() >= SCAN_LIMIT ?
FileVisitResult.TERMINATE : FileVisitResult.CONTINUE;
+ }
+
+ @Override
+ public FileVisitResult visitFileFailed(Path f, IOException e) {
+ return FileVisitResult.CONTINUE;
+ }
+ });
+ } catch (IOException e) {
+ throw new ToolExecutionException("Cannot list " + dir + ": " +
e.getMessage());
+ }
+ files.sort(Comparator.comparing((Path p) -> relativePath(dir, p),
String.CASE_INSENSITIVE_ORDER));
+ return files;
+ }
+
+ static String relativePath(Path dir, Path p) {
+ return dir.relativize(p).toString().replace('\\', '/');
+ }
+
+ /**
+ * What a file is to Camel: {@code route} (a YAML, XML or Java file that
defines routes, judged by its first lines),
+ * {@code config} ({@code application*.properties} or {@code .yaml}),
{@code pom}, or null for anything else.
+ */
+ static String fileKind(Path p, String rel, String type) {
+ String leaf = rel.substring(rel.lastIndexOf('/') +
1).toLowerCase(Locale.ROOT);
+ if (leaf.equals("pom.xml")) {
+ return "pom";
+ }
+ if (leaf.startsWith("application") && ("properties".equals(type) ||
"yaml".equals(type))) {
+ return "config";
+ }
+ if (leaf.endsWith(".camel.yaml") || leaf.endsWith(".camel.xml")) {
+ return "route";
+ }
+ if ("yaml".equals(type) || "xml".equals(type) || "java".equals(type)) {
+ String head = head(p);
+ if (head != null && isRouteSource(type, head)) {
+ return "route";
+ }
+ }
+ return null;
+ }
+
+ static boolean isRouteSource(String type, String head) {
+ return switch (type) {
+ case "yaml" -> YAML_ROUTE.matcher(head).find();
+ case "xml" -> head.contains("<routes") || head.contains("<route ")
|| head.contains("<route>")
+ || head.contains("<camelContext") ||
head.contains("<routeTemplates") || head.contains("<rests")
+ || head.contains("<routeConfigurations");
+ case "java" -> head.contains("RouteBuilder");
+ default -> false;
+ };
+ }
+
+ /** The first bytes of a file, enough to tell what it defines; null when
it cannot be read. */
+ private static String head(Path p) {
+ try (var in = Files.newInputStream(p)) {
+ return new String(in.readNBytes(8192), StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ return null;
+ }
+ }
+
+ /**
+ * A file path relative to the directory (subdirectories allowed, as
{@code camel_get_files} lists them); an
+ * absolute path or one that escapes the directory is refused.
+ */
+ public static Path resolveFile(Path dir, String file) {
if (file == null || file.isBlank()) {
throw new ToolExecutionException("file is required");
}
- Path path = dir.resolve(file).normalize();
- if (!path.startsWith(dir) || !dir.equals(path.getParent())) {
- throw new ToolExecutionException("file must be a plain file name
in the directory " + dir);
+ Path path;
+ try {
+ if (Path.of(file).isAbsolute()) {
+ throw new ToolExecutionException("file must be a path relative
to the directory " + dir + ", not " + file);
+ }
+ path = dir.resolve(file).normalize();
+ } catch (InvalidPathException e) {
+ throw new ToolExecutionException("Not a valid file path: " + file);
+ }
+ if (!path.startsWith(dir) || path.equals(dir)) {
+ throw new ToolExecutionException("file must stay inside the
directory " + dir + ": " + file);
}
return path;
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
index 5408d5b14286..8a48d3a9ea53 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
@@ -20,10 +20,12 @@ import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+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.Test;
@@ -119,9 +121,13 @@ class AuthoringToolsTest {
Files.createDirectory(dir.resolve("sub"));
JsonObject list = call("camel_get_files", new ToolContext(),
Map.of("directory", dir.toString()));
assertEquals(2, list.getInteger("totalFiles"), "directories are not
listed");
+ assertEquals("flat", list.getString("layout"));
+ assertEquals(List.of("demo.camel.yaml"),
list.getCollection("routeFiles"));
+ assertEquals(List.of("application.properties"),
list.getCollection("configFiles"));
JsonObject first = (JsonObject)
list.getCollection("files").iterator().next();
assertEquals("application.properties", first.getString("name"));
assertEquals("properties", first.getString("type"));
+ assertEquals("config", first.getString("kind"));
JsonObject file = call("camel_get_files", new ToolContext(),
Map.of("directory", dir.toString(), "file",
"demo.camel.yaml"));
assertEquals(VALID_ROUTE, file.getString("content"));
@@ -165,13 +171,107 @@ class AuthoringToolsTest {
}
@Test
- void fileNamesStayInsideTheDirectory(@TempDir Path dir) {
- for (String bad : List.of("../etc/passwd", "sub/x.yaml",
"/tmp/x.yaml")) {
+ void filePathsStayInsideTheDirectoryButMayNameASubdirectory(@TempDir Path
dir) throws IOException {
+ for (String bad : List.of("../etc/passwd", "/tmp/x.yaml",
"sub/../../x.yaml")) {
ToolExecutionException e =
assertThrows(ToolExecutionException.class,
() -> ToolRegistry.execute("camel_write_file", new
ToolContext(),
Map.of("directory", dir.toString(), "file", bad,
"content", "x")));
- assertTrue(e.getMessage().contains("plain file name"), bad + ": "
+ e.getMessage());
+ assertTrue(e.getMessage().contains("inside the directory") ||
e.getMessage().contains("relative to"),
+ bad + ": " + e.getMessage());
}
+ // a relative path is fine, and its directories are created
+ JsonObject written = call("camel_write_file", new ToolContext(),
+ Map.of("directory", dir.toString(), "file",
"src/main/resources/camel/x.camel.yaml", "content",
+ VALID_ROUTE));
+ assertEquals("created", written.getString("status"));
+ assertEquals(VALID_ROUTE,
Files.readString(dir.resolve("src/main/resources/camel/x.camel.yaml")));
+ }
+
+ @Test
+ void aMavenProjectListsItsRouteFilesFirstAndReadsByRelativePath(@TempDir
Path dir) throws IOException {
+ Files.writeString(dir.resolve("pom.xml"), "<project/>");
+ Files.createDirectories(dir.resolve("src/main/resources/camel"));
+ Files.createDirectories(dir.resolve("src/main/java/org/acme"));
+ Files.createDirectories(dir.resolve("target/classes/camel"));
+ Files.createDirectories(dir.resolve(".mvn"));
+
Files.writeString(dir.resolve("src/main/resources/camel/timer-log.camel.yaml"),
VALID_ROUTE);
+
Files.writeString(dir.resolve("src/main/resources/application.properties"),
"camel.main.name=timer-log\n");
+ Files.writeString(dir.resolve("src/main/resources/log4j2.properties"),
"rootLogger.level=info\n");
+ Files.writeString(dir.resolve("src/main/java/org/acme/MyRoute.java"),
+ "package org.acme;\nimport
org.apache.camel.builder.RouteBuilder;\n"
+
+ "public class MyRoute extends RouteBuilder { public void configure() { }
}\n");
+ Files.writeString(dir.resolve("src/main/java/org/acme/Helper.java"),
"package org.acme;\nclass Helper { }\n");
+
Files.writeString(dir.resolve("target/classes/camel/timer-log.camel.yaml"),
VALID_ROUTE);
+ Files.writeString(dir.resolve(".mvn/maven.config"), "-T1\n");
+ Files.writeString(dir.resolve("README.md"), "# demo\n");
+
+ JsonObject list = call("camel_get_files", new ToolContext(),
Map.of("directory", dir.toString()));
+ assertEquals("maven", list.getString("layout"));
+ assertEquals(List.of("src/main/java/org/acme/MyRoute.java",
"src/main/resources/camel/timer-log.camel.yaml"),
+ list.getCollection("routeFiles"));
+ assertEquals(List.of("src/main/resources/application.properties"),
list.getCollection("configFiles"));
+ assertEquals(7, list.getInteger("totalFiles"), "target and .mvn are
skipped");
+
assertTrue(list.getString("hint").contains("src/main/resources/camel"),
list.getString("hint"));
+ List<String> names = new ArrayList<>();
+ for (Object o : list.getCollection("files")) {
+ names.add(((JsonObject) o).getString("name"));
+ }
+ assertTrue(names.contains("pom.xml") &&
names.contains("src/main/resources/log4j2.properties"), names.toString());
+ assertTrue(names.stream().noneMatch(n -> n.startsWith("target/") ||
n.startsWith(".mvn/")), names.toString());
+
+ JsonObject file = call("camel_get_files", new ToolContext(),
+ Map.of("directory", dir.toString(), "file",
"src/main/resources/camel/timer-log.camel.yaml"));
+ assertEquals(VALID_ROUTE, file.getString("content"));
+ ToolExecutionException e = assertThrows(ToolExecutionException.class,
+ () -> ToolRegistry.execute("camel_get_files", new
ToolContext(),
+ Map.of("directory", dir.toString(), "file",
"camel/timer-log.camel.yaml")));
+ assertTrue(e.getMessage().contains("routeFiles"), "the error says how
to find the file: " + e.getMessage());
+ }
+
+ @Test
+ void routeSourcesMapTheRuntimesLocationsOntoProjectFiles(@TempDir Path
dir) throws IOException {
+ Files.createDirectories(dir.resolve("src/main/resources/camel"));
+ Files.createDirectories(dir.resolve("src/main/java/org/acme"));
+
Files.writeString(dir.resolve("src/main/resources/camel/timer-log.camel.yaml"),
VALID_ROUTE);
+ Files.writeString(dir.resolve("src/main/java/org/acme/MyRoute.java"),
"class MyRoute {}");
+ Files.writeString(dir.resolve("flat.camel.yaml"), VALID_ROUTE);
+
+ // Spring Boot fat jar, Quarkus / classpath, a plain file, a Java
class, a moved file and an unknown one
+ List<Map<String, String>> routes = List.of(
+ Map.of("routeId", "boot", "source", "nested:" + dir +
"/target/app-1.0.jar/!BOOT-INF/classes/!/camel/"
+ +
"timer-log.camel.yaml:4"),
+ Map.of("routeId", "cp", "source",
"classpath:camel/timer-log.camel.yaml:7"),
+ Map.of("routeId", "abs", "source", "file:" +
dir.resolve("flat.camel.yaml") + ":1"),
+ Map.of("routeId", "java", "source", "org.acme.MyRoute:12"),
+ Map.of("routeId", "moved", "source",
"file:old/place/flat.camel.yaml"),
+ Map.of("routeId", "gone", "source",
"classpath:camel/nowhere.yaml:3"));
+ JsonArray mapped = AuthoringTools.routeSources(routes, dir);
+
+ assertEquals(6, mapped.size());
+ JsonObject boot = (JsonObject) mapped.get(0);
+ assertEquals("src/main/resources/camel/timer-log.camel.yaml",
boot.getString("file"));
+ assertEquals(4, boot.getInteger("line"));
+ assertEquals(null, boot.get("missing"));
+ assertEquals("src/main/resources/camel/timer-log.camel.yaml",
((JsonObject) mapped.get(1)).getString("file"));
+ assertEquals("flat.camel.yaml", ((JsonObject)
mapped.get(2)).getString("file"));
+ assertEquals("src/main/java/org/acme/MyRoute.java", ((JsonObject)
mapped.get(3)).getString("file"));
+ assertEquals(12, ((JsonObject) mapped.get(3)).getInteger("line"));
+ assertEquals("flat.camel.yaml", ((JsonObject)
mapped.get(4)).getString("file"), "found by name");
+ JsonObject gone = (JsonObject) mapped.get(5);
+ assertEquals("camel/nowhere.yaml", gone.getString("file"));
+ assertEquals(Boolean.TRUE, gone.get("missing"));
+ assertTrue(AuthoringTools.routeSources(null, dir).isEmpty());
+ // a route without an id is listed by its file alone, not as "null"
+ JsonObject anonymous = (JsonObject) AuthoringTools.routeSources(
+ List.of(Map.of("source", "file:" +
dir.resolve("flat.camel.yaml"))), dir).get(0);
+ assertEquals("flat.camel.yaml", anonymous.getString("file"));
+ assertFalse(anonymous.containsKey("routeId"));
+ // a digit suffix too long for a line number is not one: no exception,
no line
+ JsonObject overflow = (JsonObject) AuthoringTools.routeSources(
+ List.of(Map.of("routeId", "big", "source",
"file:flat.camel.yaml:2147483648")), dir).get(0);
+ assertEquals("flat.camel.yaml:2147483648", overflow.getString("file"));
+ assertFalse(overflow.containsKey("line"));
+ assertEquals(Boolean.TRUE, overflow.get("missing"));
}
@Test
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
index 64ba31dfa8b5..d689b1acbb5c 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
@@ -120,12 +120,15 @@ public class AuthoringTools {
}
@Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint
= false, openWorldHint = false),
- description = "The source files of a project directory: without file
the list (name, size, type), with "
- + "file its content. Use before editing to see the
routes, configuration and other files of "
- + "the integration.")
+ description = "The source files of a project directory,
subdirectories included: without file the list, "
+ + "with the route and configuration files named first
(routeFiles, configFiles) and, for a "
+ + "running integration, which file and line each route
comes from; with file (a path "
+ + "relative to the directory, as listed) its content.")
public JsonObject camel_get_files(
@ToolArg(description = DIRECTORY_DESC, required = false) String
directory,
- @ToolArg(description = "File name to read; omitted lists the
files", required = false) String file) {
+ @ToolArg(description = "File path relative to the directory, e.g.
src/main/resources/camel/foo.camel.yaml,"
+ + " to read; omitted lists the files",
+ required = false) String file) {
return call("camel_get_files", args("directory", directory, "file",
file));
}
@@ -136,7 +139,8 @@ public class AuthoringTools {
+ "camel_control.")
public JsonObject camel_write_file(
@ToolArg(description = DIRECTORY_DESC, required = false) String
directory,
- @ToolArg(description = "File name, no path", required = true)
String file,
+ @ToolArg(description = "File path relative to the directory
(subdirectories are created)",
+ required = true) String file,
@ToolArg(description = "The complete new content", required =
true) String content,
@ToolArg(description = "Validate before writing (default true)",
required = false) Boolean validate,
@ToolArg(description = VERSION_DESC, required = false) String
camelVersion) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
index 095b3dfc467d..b44bf2d9c51f 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
@@ -37,7 +37,9 @@ import dev.tamboui.tui.event.KeyCode;
import dev.tamboui.tui.event.KeyEvent;
import dev.tamboui.tui.event.KeyModifiers;
import dev.tamboui.widgets.tabs.TabsState;
+import org.apache.camel.dsl.jbang.core.commands.ai.AuthoringTools;
import org.apache.camel.dsl.jbang.core.commands.ai.SourceValidator;
+import org.apache.camel.dsl.jbang.core.commands.ai.ToolExecutionException;
import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
import org.apache.camel.dsl.jbang.core.common.RuntimeHelper;
import org.apache.camel.util.json.JsonArray;
@@ -288,6 +290,11 @@ class McpFacade {
return ctx != null ? ctx.selectedPid : null;
}
+ /** Whether a running integration goes by this name (a model often passes
it where a directory is asked for). */
+ boolean hasIntegration(String name) {
+ return name != null && !name.isEmpty() && findIntegration(name) !=
null;
+ }
+
String getSelectedIntegrationName() {
if (ctx == null) {
return null;
@@ -978,8 +985,9 @@ class McpFacade {
}
if (content == null) {
JsonObject existing = getFiles(name, file);
- if (existing == null) {
- return writeError("No such file in the source directory: " +
file);
+ if (existing == null || existing.getString("content") == null) {
+ String error = existing != null ? existing.getString("error")
: null;
+ return writeError(error != null ? error : "No such file in the
source directory: " + file);
}
content = existing.getString("content");
}
@@ -1026,6 +1034,12 @@ class McpFacade {
result.put("editing", editing);
}
+ /**
+ * The shared {@code camel_get_files} on the integration's source
directory, with what only the TUI knows: whether
+ * the directory is editable and, for the listing, which file and line
each running route comes from. A missing file
+ * or a bad path comes back as {@code error} with the directory, so the
model corrects the path instead of
+ * concluding the sources are gone; null only when there is no integration
or no source directory.
+ */
JsonObject getFiles(String name, String file) {
IntegrationInfo target = findIntegration(name);
if (target == null) {
@@ -1035,56 +1049,40 @@ class McpFacade {
if (dir == null || !Files.isDirectory(dir)) {
return null;
}
- if (file != null && !file.isEmpty()) {
- Path filePath = dir.resolve(file).normalize();
- if (!filePath.startsWith(dir) || !Files.isRegularFile(filePath)) {
- return null;
+ JsonObject result;
+ try {
+ result = file != null && !file.isEmpty()
+ ? AuthoringTools.readFile(dir, file) :
AuthoringTools.listFiles(dir);
+ } catch (ToolExecutionException e) {
+ JsonObject error = new JsonObject();
+ error.put("error", e.getMessage());
+ describeSourceDirectory(target, dir, error);
+ return error;
+ }
+ describeSourceDirectory(target, dir, result);
+ if (file == null || file.isEmpty()) {
+ JsonArray routes = new JsonArray();
+ for (RouteInfo r : target.routes) {
+ if (r.source != null && !r.source.isBlank()) {
+ JsonObject j = new JsonObject();
+ if (r.routeId != null) {
+ j.put("routeId", r.routeId);
+ }
+ j.put("source", r.source);
+ routes.add(j);
+ }
}
- try {
- String content = Files.readString(filePath,
StandardCharsets.UTF_8);
- JsonObject result = new JsonObject();
- result.put("file", file);
- describeSourceDirectory(target, dir, result);
- result.put("size",
FilesBrowser.formatFileSize(Files.size(filePath)));
- result.put("type", FilesBrowser.fileType(filePath));
- result.put("content", content);
- return result;
- } catch (IOException e) {
- return null;
+ JsonArray mapped = AuthoringTools.routeSources(routes, dir);
+ if (!mapped.isEmpty()) {
+ result.put("routes", mapped);
}
}
- JsonArray files = new JsonArray();
- try (var stream = Files.list(dir)) {
- stream.filter(Files::isRegularFile)
- .sorted((a, b) ->
a.getFileName().toString().compareToIgnoreCase(b.getFileName().toString()))
- .limit(99)
- .forEach(p -> {
- JsonObject entry = new JsonObject();
- entry.put("name", p.getFileName().toString());
- try {
- entry.put("size",
FilesBrowser.formatFileSize(Files.size(p)));
- } catch (IOException e) {
- entry.put("size", "0 B");
- }
- entry.put("type", FilesBrowser.fileType(p));
- files.add(entry);
- });
- } catch (IOException e) {
- return null;
- }
- if (files.isEmpty()) {
- return null;
- }
- JsonObject result = new JsonObject();
- describeSourceDirectory(target, dir, result);
- result.put("files", files);
- result.put("totalFiles", files.size());
return result;
}
/**
* Writes (creates or replaces) a file in the integration's source
directory, after the user confirmed it in the TUI
- * unless {@code confirm} is false. The file must be a plain file name in
that directory.
+ * unless {@code confirm} is false. The file is a path relative to that
directory.
*/
JsonObject writeFile(String name, String file, String content, boolean
confirm) {
return writeFile(name, file, content, confirm, true);
@@ -1111,9 +1109,11 @@ class McpFacade {
if (content == null) {
return writeError("content is required");
}
- Path filePath = dir.resolve(file).normalize();
- if (!filePath.startsWith(dir) || !dir.equals(filePath.getParent())) {
- return writeError("file must be a plain file name in the source
directory " + dir);
+ Path filePath;
+ try {
+ filePath = AuthoringTools.resolveFile(dir, file);
+ } catch (ToolExecutionException e) {
+ return writeError(e.getMessage());
}
boolean exists = Files.exists(filePath);
if (exists && !Files.isRegularFile(filePath)) {
@@ -1183,6 +1183,7 @@ class McpFacade {
}
}
try {
+ Files.createDirectories(filePath.getParent());
Files.writeString(filePath, content, StandardCharsets.UTF_8);
} catch (IOException e) {
return writeError("Failed to write " + filePath + ": " +
e.getMessage());
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteInfo.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteInfo.java
index f8513a655ca4..20b0490827aa 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteInfo.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteInfo.java
@@ -22,6 +22,8 @@ import java.util.List;
class RouteInfo {
String routeId;
String description;
+ /** Where the runtime loaded the route from (a file, classpath resource or
jar entry, with the line). */
+ String source;
String group;
String from;
String state;
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java
index 72ac72430175..493d06d1733f 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java
@@ -247,6 +247,7 @@ final class StatusParser {
RouteInfo ri = new RouteInfo();
ri.routeId = rj.getString("routeId");
ri.description = rj.getString("description");
+ ri.source = rj.getString("source");
ri.group = rj.getString("group");
ri.from = rj.getString("from");
ri.state = rj.getString("state");
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
index 4da3058ec206..107be4c2a77b 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
@@ -17,6 +17,7 @@
package org.apache.camel.dsl.jbang.core.commands.tui;
import java.util.ArrayList;
+import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
@@ -91,8 +92,17 @@ class TuiToolRegistry {
* camel_control knows the TUI's own actions, camel_get_log also reads an
infra service log.
*/
private String executeSharedWithTuiExtras(String name, Map<String, Object>
args) {
+ if (facade != null && args.get("directory") instanceof String d &&
!d.isBlank() && !d.contains("/")
+ && !d.contains("\\") && facade.hasIntegration(d)) {
+ // a model often passes the integration's name where the tool asks
for its directory
+ Map<String, Object> byName = new HashMap<>(args);
+ byName.remove("directory");
+ byName.put("name", d);
+ args = byName;
+ }
boolean hasDirectory = args.get("directory") instanceof String d &&
!d.isBlank();
- boolean facadeSelection = facade != null &&
facade.getSelectedIntegrationName() != null;
+ boolean named = args.get("name") instanceof String n && !n.isBlank();
+ boolean facadeSelection = facade != null && (named ||
facade.getSelectedIntegrationName() != null);
return switch (name) {
case CONTROL_TOOL -> facade != null ? callControl(args) :
executeShared(name, args);
case LOG_TOOL -> facade != null ? callGetLog(args) :
executeShared(name, args);
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeGetFilesTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeGetFilesTest.java
new file mode 100644
index 000000000000..90c38b4b26dc
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeGetFilesTest.java
@@ -0,0 +1,138 @@
+/*
+ * 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.tui;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * {@code camel_get_files} on the selected integration: an exported Spring
Boot project is a Maven layout, and the
+ * running routes point at the files under {@code src/main}, so a model finds
the route file in one call instead of
+ * guessing names in the wrong folder (CAMEL-24798).
+ */
+class McpFacadeGetFilesTest {
+
+ private static final String ROUTE = "- route:\n id: timer-log\n
from:\n uri: timer:tick\n"
+ + " steps:\n - log:\n
message: hi\n";
+
+ private static McpFacade facade(Path projectDir, String routeSource) {
+ return facade(projectDir, "timer-log", routeSource);
+ }
+
+ private static McpFacade facade(Path projectDir, String routeId, String
routeSource) {
+ IntegrationInfo info = new IntegrationInfo();
+ info.name = "timer-log";
+ info.pid = "1";
+ info.directory = projectDir.toString();
+ RouteInfo route = new RouteInfo();
+ route.routeId = routeId;
+ route.source = routeSource;
+ info.routes.add(route);
+ return new McpFacade(
+ null, new AtomicReference<>(List.of(info)), null, null, null,
null, null, null, null, null, null,
+ null, null);
+ }
+
+ @Test
+ void
anExportedProjectListsItsRouteFileAndWhereTheRunningRouteComesFrom(@TempDir
Path dir) throws IOException {
+ Files.createDirectories(dir.resolve("src/main/resources/camel"));
+ Files.createDirectories(dir.resolve("target/classes/camel"));
+ Files.writeString(dir.resolve("pom.xml"), "<project/>");
+
Files.writeString(dir.resolve("src/main/resources/camel/timer-log.camel.yaml"),
ROUTE);
+
Files.writeString(dir.resolve("src/main/resources/application.properties"),
"camel.main.name=timer-log\n");
+
Files.writeString(dir.resolve("target/classes/camel/timer-log.camel.yaml"),
ROUTE);
+ McpFacade facade = facade(dir, "nested:" + dir
+ +
"/target/timer-log-1.0-SNAPSHOT.jar/!BOOT-INF/classes/!/camel/timer-log.camel.yaml:4");
+
+ JsonObject list = facade.getFiles("timer-log", null);
+
+ assertNotNull(list);
+ assertEquals("maven", list.getString("layout"));
+ assertEquals(List.of("src/main/resources/camel/timer-log.camel.yaml"),
list.getCollection("routeFiles"));
+ assertEquals(dir.toString(), list.getString("directory"));
+ assertNotNull(list.getString("editing"), "the TUI still says whether
the directory is editable");
+ JsonArray routes = (JsonArray) list.get("routes");
+ assertEquals(1, routes.size());
+ JsonObject route = (JsonObject) routes.get(0);
+ assertEquals("timer-log", route.getString("routeId"));
+ assertEquals("src/main/resources/camel/timer-log.camel.yaml",
route.getString("file"));
+ assertEquals(4, route.getInteger("line"));
+ assertNull(route.get("missing"));
+
+ JsonObject file = facade.getFiles("timer-log",
"src/main/resources/camel/timer-log.camel.yaml");
+ assertEquals(ROUTE, file.getString("content"));
+ assertNull(file.get("routes"), "reading one file does not repeat the
route list");
+ }
+
+ @Test
+ void theIntegrationNameIsAcceptedWhereTheToolAsksForADirectory(@TempDir
Path dir) throws Exception {
+ Files.writeString(dir.resolve("demo.camel.yaml"), ROUTE);
+ TuiToolRegistry registry = new TuiToolRegistry(facade(dir, "file:" +
dir.resolve("demo.camel.yaml") + ":1"));
+
+ // the model passed the integration's name as the directory: answered
for that integration, not an error
+ String answer = registry.execute("camel_get_files",
Map.of("directory", "timer-log"));
+ assertTrue(answer.contains("\"routeFiles\":[\"demo.camel.yaml\"]"),
answer);
+ assertTrue(answer.contains("\"editing\""), "answered by the TUI, with
its directory knowledge: " + answer);
+ }
+
+ @Test
+ void aRouteWithoutAnIdIsListedByItsFileAlone(@TempDir Path dir) throws
IOException {
+ Files.writeString(dir.resolve("demo.camel.yaml"), ROUTE);
+ McpFacade facade = facade(dir, null, "file:" +
dir.resolve("demo.camel.yaml") + ":1");
+
+ JsonObject list = facade.getFiles("timer-log", null);
+
+ JsonArray routes = (JsonArray) list.get("routes");
+ assertEquals(1, routes.size());
+ JsonObject route = (JsonObject) routes.get(0);
+ assertEquals("demo.camel.yaml", route.getString("file"));
+ assertFalse(route.containsKey("routeId"), "an anonymous route has no
routeId key, not a \"null\" one: " + route);
+ }
+
+ @Test
+ void aMissingFileIsAnErrorWithTheDirectoryNotAMissingProject(@TempDir Path
dir) throws IOException {
+ Files.writeString(dir.resolve("demo.camel.yaml"), ROUTE);
+ McpFacade facade = facade(dir, "file:" +
dir.resolve("demo.camel.yaml") + ":1");
+
+ JsonObject missing = facade.getFiles("timer-log", "routes/demo.yaml");
+ assertNotNull(missing);
+ assertTrue(missing.getString("error").contains("No such file"),
missing.getString("error"));
+ assertEquals(dir.toString(), missing.getString("directory"));
+
+ JsonObject escaped = facade.getFiles("timer-log",
"../demo.camel.yaml");
+ assertTrue(escaped.getString("error").contains("inside the
directory"), escaped.getString("error"));
+
+ JsonObject list = facade.getFiles("timer-log", null);
+ assertEquals("flat", list.getString("layout"));
+ assertEquals("demo.camel.yaml", ((JsonObject) ((JsonArray)
list.get("routes")).get(0)).getString("file"));
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeWriteFileTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeWriteFileTest.java
index 6e94fc252192..0d59e4edcbf6 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeWriteFileTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeWriteFileTest.java
@@ -300,13 +300,20 @@ class McpFacadeWriteFileTest {
McpFacade facade = facade(dir, true, bridge);
assertEquals("error", facade.writeFile("demo", "../escape.yaml", "x",
true).getString("status"));
- assertEquals("error", facade.writeFile("demo", "sub/dir.yaml", "x",
true).getString("status"));
+ assertEquals("error", facade.writeFile("demo", "sub/../../dir.yaml",
"x", true).getString("status"));
+ assertEquals("error", facade.writeFile("demo",
dir.resolve("abs.yaml").toString(), "x", true).getString("status"));
assertEquals("error", facade.writeFile("demo", "", "x",
true).getString("status"));
assertEquals("error", facade.writeFile("demo", "demo.camel.yaml",
null, true).getString("status"));
assertEquals("error", facade.writeFile("nope", "demo.camel.yaml", "x",
true).getString("status"));
assertEquals(0, bridge.asked);
assertFalse(Files.exists(dir.getParent().resolve("escape.yaml")));
+ // a path into a subdirectory is fine (a Maven project keeps its
routes under src/main/resources/camel)
+ JsonObject created = facade.writeFile("demo", "sub/dir.camel.yaml", "-
route: {}", true);
+ assertEquals("created", created.getString("status"));
+ assertEquals("sub/dir.camel.yaml", bridge.request.file());
+ assertTrue(Files.isRegularFile(dir.resolve("sub/dir.camel.yaml")));
+
// reading reports the same directory knowledge the agent needs before
writing
JsonObject files = facade.getFiles("demo", null);
assertNotNull(files);