001/*
002 * SPDX-License-Identifier: Apache-2.0
003 *
004 * Copyright 2025-2026 The Enola <https://enola.dev> Authors
005 *
006 * Licensed under the Apache License, Version 2.0 (the "License");
007 * you may not use this file except in compliance with the License.
008 * You may obtain a copy of the License at
009 *
010 *     https://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018package dev.enola.chat;
019
020import static java.nio.file.Files.*;
021
022import com.google.common.collect.ImmutableList;
023import com.google.common.collect.ImmutableMap;
024import com.google.common.collect.ImmutableSet;
025
026import dev.enola.common.exec.ExecPATH;
027import dev.enola.common.exec.vorburger.ExpectedExitCode;
028import dev.enola.common.exec.vorburger.Runner;
029import dev.enola.common.exec.vorburger.VorburgerExecRunner;
030import dev.enola.common.io.resource.ClasspathResource;
031import dev.enola.common.linereader.IO;
032import dev.enola.identity.Hostnames;
033import dev.enola.identity.Subject;
034
035import org.slf4j.Logger;
036import org.slf4j.LoggerFactory;
037
038import java.io.File;
039import java.io.IOException;
040import java.nio.file.Path;
041import java.time.Duration;
042import java.util.*;
043
044public class ExecAgent extends AbstractAgent {
045
046    // TODO Must check if command is a path or on the PATH
047
048    // TODO If PATH contains "." then this won't really work yet as-is
049
050    // TODO Offer tab completion of all available commands in Chat
051    //   see https://jline.org/docs/tab-completion
052
053    // TODO Ctrl-R FZF History Search Widget; see https://github.com/jline/jline3/issues/1246
054
055    // TODO cd without argument should CWD to $HOME
056    // TODO implicit cd with dirname (UNLESS it's also a command on PATH), like Fish Shell does
057
058    // TODO Support "who am i"; see https://github.com/vorburger/ch.vorburger.exec/issues/269
059
060    private static final Logger LOG = LoggerFactory.getLogger(ExecAgent.class);
061
062    private final Runner runner;
063    private final Map<String, File> executablesMap;
064    private final List<String> executables;
065    private final Set<String> commandWords = loadCommandWords();
066
067    private Path cwd = Path.of(".");
068    private final String forceExecPrefix;
069
070    /**
071     * Constructor.
072     *
073     * @param pbx the PBX
074     * @param runner the exec runner
075     * @param executablesMap the executables on PATH
076     * @param forceExecPrefix the prefix to force execution of a command, e.g. "$ " or "!", or
077     *     whatever. (Note that <a href="https://github.com/jline/jline3/issues/1218">JLine handles
078     *     "!" as history expansion, so that needs to disabled</a>.)
079     */
080    public ExecAgent(
081            Switchboard pbx,
082            Runner runner,
083            Map<String, File> executablesMap,
084            String forceExecPrefix) {
085        super(
086                tbf.create(Subject.Builder.class, Subject.class)
087                        .iri("http://" + Hostnames.LOCAL)
088                        .label(Hostnames.LOCAL)
089                        .comment("Executes Commands on " + Hostnames.LOCAL)
090                        .build(),
091                pbx);
092        this.runner = runner;
093        this.executablesMap = ImmutableMap.copyOf(executablesMap); // skipcq: JAVA-E1086
094        // skipcq: JAVA-E1086
095        this.executables = ImmutableList.copyOf(executablesMap.keySet().stream().sorted().toList());
096        this.forceExecPrefix = forceExecPrefix;
097    }
098
099    public ExecAgent(Switchboard pbx, IO io) {
100        this(pbx, new VorburgerExecRunner(), ExecPATH.scan(), "$ ");
101    }
102
103    @Override
104    public void accept(Message message) {
105        // Skip processing self-replies from itself; this might need some more thought?
106        if (message.from().iri().equals(subject().iri())) return;
107
108        // /commands is inspired e.g. by Fish's "command --all",
109        //   see https://fishshell.com/docs/current/cmds/command.html
110        if (handle(message, "/commands", () -> reply(message, String.join("\n", executables))))
111            return;
112
113        if (handle(message, "cd", this::cd)) return;
114
115        // See https://github.com/enola-dev/enola/issues/1354
116        var potentialCommand = message.content();
117        if (potentialCommand.startsWith(forceExecPrefix)
118                && potentialCommand.length() > forceExecPrefix.length()) {
119            execute(potentialCommand.substring(forceExecPrefix.length()), false, message);
120            return;
121        }
122
123        execute(potentialCommand, true, message);
124    }
125
126    private void cd(String path) {
127        cwd = cwd.resolve(path.trim());
128    }
129
130    private void execute(String potentialCommand, boolean checkCommandWords, Message replyTo) {
131        var executable = executable(potentialCommand);
132        var executablePath = cwd.resolve(executable);
133        if (!executable.startsWith("/")
134                && !executable.startsWith("./")
135                && !executable.startsWith("../")) {
136            if (checkCommandWords && commandWords.contains(executable)) return;
137            var executableFile = executablesMap.get(executable);
138            if (executableFile == null) {
139                LOG.info("Unknown executable: {}", executable);
140                return;
141            }
142        } else if (!isRegularFile(executablePath)
143                || !isReadable(executablePath)
144                || !isExecutable(executablePath)) return;
145
146        // TODO Allow running without timeout?
147        var timeout = Duration.ofDays(1);
148
149        // TODO Support streaming outputBuilder into Chat (see also LangChain4jAgent)
150        var outputBuilder = new StringBuilder();
151        try {
152            // TODO Feedback exitCode also to Chat (somehow; but how?!)
153            //   Well, just like in Bash/Fish, with Emoji emoji (😊 for success, 😞 for failure)
154            //   in the NEXT prompt... how how to "generalize" this here?
155            // TODO Run command in $SHELL instead of hard-coding bash -c.
156            var exitCode =
157                    runner.bash(
158                            ExpectedExitCode.SUCCESS,
159                            cwd,
160                            potentialCommand,
161                            outputBuilder,
162                            timeout);
163            LOG.debug("Executed: {} => {}", potentialCommand, exitCode);
164            var output = outputBuilder.toString();
165            if (!output.trim().isEmpty()) {
166                reply(replyTo, output);
167            }
168
169        } catch (Exception e) {
170            LOG.warn("Failed to execute: {}", potentialCommand, e);
171            reply(
172                    replyTo,
173                    "Failed to execute: " + executable + "; due to " + e.getMessage() + "\n");
174        }
175    }
176
177    private String executable(String messageContent) {
178        String command;
179        var idx = messageContent.indexOf(' ');
180        if (idx == -1) {
181            command = messageContent.trim();
182        } else {
183            command = messageContent.substring(0, idx).trim();
184        }
185
186        idx = command.indexOf(';');
187        if (idx > -1) {
188            command = command.substring(0, idx).trim();
189        }
190
191        idx = command.indexOf('|');
192        if (idx > -1) {
193            command = command.substring(0, idx).trim();
194        }
195
196        idx = command.indexOf("&&");
197        if (idx > -1) {
198            command = command.substring(0, idx + 1).trim();
199        }
200
201        idx = command.indexOf('<');
202        if (idx > -1) {
203            command = command.substring(0, idx).trim();
204        }
205
206        idx = command.indexOf('>');
207        if (idx > -1) {
208            command = command.substring(0, idx).trim();
209        }
210
211        return command;
212    }
213
214    // See https://github.com/enola-dev/enola/issues/1354
215    private Set<String> loadCommandWords() {
216        var builder = ImmutableSet.<String>builder();
217        try {
218            new ClasspathResource("command-words.txt").charSource().forEachLine(builder::add);
219        } catch (IOException e) {
220            throw new IllegalStateException("Processing /command-words.txt failed?!", e);
221        }
222        return builder.build();
223    }
224}