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.cli;
019
020import com.google.common.collect.ImmutableMap;
021
022import dev.enola.chat.AbstractAgent;
023import dev.enola.chat.Message;
024import dev.enola.chat.Prompter;
025import dev.enola.chat.Switchboard;
026import dev.enola.common.context.TLC;
027import dev.enola.common.linereader.SystemInOutIO;
028import dev.enola.common.linereader.jline.JLineAgent;
029import dev.enola.common.linereader.jline.JLineBuiltinCommandsProcessor;
030import dev.enola.common.linereader.jline.JLineIO;
031import dev.enola.common.secret.InMemorySecretManager;
032import dev.enola.identity.Subject;
033import dev.enola.identity.Subjects;
034import dev.enola.rdf.io.JavaThingIntoRdfAppendableConverter;
035import dev.enola.thing.impl.ImmutableThing;
036import dev.enola.thing.io.ThingIntoAppendableConverter;
037import dev.enola.thing.java.ProxyTBF;
038
039import org.jline.console.SystemRegistry;
040import org.jline.console.impl.SystemRegistryImpl;
041import org.jline.reader.impl.DefaultParser;
042import org.jline.terminal.TerminalBuilder;
043
044import picocli.CommandLine;
045import picocli.shell.jline3.PicocliCommands;
046
047import java.io.IOException;
048import java.util.List;
049import java.util.Set;
050import java.util.concurrent.Callable;
051
052@CommandLine.Command(
053        name = "chat",
054        description = "Chat with Enola, LLMs, Bots, Tools, Agents, and more.")
055public class ChatCommand implements Callable<Integer> {
056
057    // TODO Merge the new Chat2Command with this!!
058
059    @CommandLine.Spec CommandLine.Model.CommandSpec spec;
060
061    @Override
062    public Integer call() throws IOException {
063        try (var ctx = TLC.open()) {
064            var tbf = new ProxyTBF(ImmutableThing.FACTORY);
065            var subject = new Subjects(tbf).local();
066            ctx.push(ThingIntoAppendableConverter.class, new JavaThingIntoRdfAppendableConverter());
067            if (System.console() != null) {
068                // TODO Parser configuration should be in class JLineIO, not here
069                DefaultParser parser = new DefaultParser();
070                parser.setEofOnUnclosedQuote(false);
071                parser.setEofOnEscapedNewLine(false);
072                parser.setEofOnUnclosedBracket((DefaultParser.Bracket[]) null);
073                parser.setRegexVariable(null); // We do not have console variables!
074
075                try (var terminal = TerminalBuilder.terminal()) {
076                    var parentCommandLine = spec.commandLine().getParent();
077                    var picocliCommands = new PicocliCommands(parentCommandLine);
078                    var builtinCmdsProcessor = new JLineBuiltinCommandsProcessor(terminal);
079                    var cwdSupplier = builtinCmdsProcessor.cwdSupplier();
080                    SystemRegistry systemRegistry =
081                            new SystemRegistryImpl(parser, terminal, cwdSupplier, null);
082                    systemRegistry.setCommandRegistries(
083                            builtinCmdsProcessor.commandRegistry(), picocliCommands);
084
085                    try (var io =
086                            new JLineIO(
087                                    System.getenv(),
088                                    terminal,
089                                    parser,
090                                    systemRegistry.completer(),
091                                    ImmutableMap.of(),
092                                    systemRegistry::commandDescription,
093                                    true)) {
094                        builtinCmdsProcessor.lineReader(io.lineReader());
095                        var prompter = new Prompter(new InMemorySecretManager());
096                        var pbx = prompter.getSwitchboard();
097                        prompter.addAgent(new JLineAgent(pbx, builtinCmdsProcessor));
098                        prompter.addAgent(new EnolaAgent(pbx, parentCommandLine));
099                        prompter.chatLoop(io, subject, true);
100                    }
101                }
102            } else {
103                var io = new SystemInOutIO();
104                new Prompter(new InMemorySecretManager()).chatLoop(io, subject, true);
105            }
106        }
107        return 0;
108    }
109
110    /** EnolaAgent runs Enola's own CLI sub-commands. */
111    private static class EnolaAgent extends AbstractAgent {
112        private final CommandLine picocliCommandLine;
113        private final Set<String> commands;
114
115        public EnolaAgent(Switchboard pbx, CommandLine commandLine) {
116            super(
117                    tbf.create(Subject.Builder.class, Subject.class)
118                            .iri("https://enola.dev")
119                            .label("enola")
120                            .comment("Enola's own CLI sub-commands.")
121                            .build(),
122                    pbx);
123            this.picocliCommandLine = commandLine;
124            this.commands = picocliCommandLine.getSubcommands().keySet();
125        }
126
127        @Override
128        public void accept(Message message) {
129            var commandLine = message.content();
130            // split() won't handle quoted arguments correctly, which is fine here (for simple
131            // Builtins), but don't re-use this as-is for other more complex external commands.
132            var splitCommandLine = List.of(commandLine.split("\\s+"));
133
134            var command = splitCommandLine.get(0);
135            if (!commands.contains(command)) return;
136
137            // TODO Do something with exit code (just like for exec, where it's also still ignored)
138            int exit = picocliCommandLine.execute(splitCommandLine.toArray(new String[0]));
139        }
140    }
141}