[SSHD-677] Provide a quick default implementation for executing a single simple command that does not require any input
diff --git a/sshd-core/src/main/java/org/apache/sshd/client/SshClient.java b/sshd-core/src/main/java/org/apache/sshd/client/SshClient.java
index 9384fbe..1299518 100644
--- a/sshd-core/src/main/java/org/apache/sshd/client/SshClient.java
+++ b/sshd-core/src/main/java/org/apache/sshd/client/SshClient.java
@@ -1437,7 +1437,7 @@
                             ((ChannelShell) channel).setAgentForwarding(agentForward);
                             channel.setIn(new NoCloseInputStream(System.in));
                         } else {
-                            StringBuilder w = new StringBuilder();
+                            StringBuilder w = new StringBuilder(command.size() * Integer.SIZE);
                             for (String cmd : command) {
                                 w.append(cmd).append(' ');
                             }
diff --git a/sshd-core/src/main/java/org/apache/sshd/client/channel/ClientChannel.java b/sshd-core/src/main/java/org/apache/sshd/client/channel/ClientChannel.java
index b0b1f77..2b221c4 100644
--- a/sshd-core/src/main/java/org/apache/sshd/client/channel/ClientChannel.java
+++ b/sshd-core/src/main/java/org/apache/sshd/client/channel/ClientChannel.java
@@ -26,7 +26,6 @@
 
 import org.apache.sshd.client.future.OpenFuture;
 import org.apache.sshd.common.channel.Channel;
-import org.apache.sshd.common.future.CloseFuture;
 import org.apache.sshd.common.io.IoInputStream;
 import org.apache.sshd.common.io.IoOutputStream;
 
@@ -100,9 +99,6 @@
      */
     Set<ClientChannelEvent> waitFor(Collection<ClientChannelEvent> mask, long timeout);
 
-    @Override
-    CloseFuture close(boolean immediate);
-
     /**
      * @return The signaled exit status via &quot;exit-status&quot; request
      * - {@code null} if not signaled
diff --git a/sshd-core/src/main/java/org/apache/sshd/client/session/ClientSession.java b/sshd-core/src/main/java/org/apache/sshd/client/session/ClientSession.java
index 622a9a1..05edaac 100644
--- a/sshd-core/src/main/java/org/apache/sshd/client/session/ClientSession.java
+++ b/sshd-core/src/main/java/org/apache/sshd/client/session/ClientSession.java
@@ -18,9 +18,18 @@
  */
 package org.apache.sshd.client.session;
 
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
+import java.io.OutputStream;
 import java.net.SocketAddress;
+import java.net.SocketTimeoutException;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.rmi.RemoteException;
+import java.rmi.ServerException;
 import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
 import java.util.Map;
 import java.util.Set;
 
@@ -31,11 +40,14 @@
 import org.apache.sshd.client.channel.ChannelShell;
 import org.apache.sshd.client.channel.ChannelSubsystem;
 import org.apache.sshd.client.channel.ClientChannel;
+import org.apache.sshd.client.channel.ClientChannelEvent;
 import org.apache.sshd.client.future.AuthFuture;
 import org.apache.sshd.client.scp.ScpClientCreator;
 import org.apache.sshd.client.subsystem.sftp.SftpClientCreator;
 import org.apache.sshd.common.future.KeyExchangeFuture;
 import org.apache.sshd.common.session.Session;
+import org.apache.sshd.common.util.io.NoCloseOutputStream;
+import org.apache.sshd.common.util.io.NullOutputStream;
 import org.apache.sshd.common.util.net.SshdSocketAddress;
 
 /**
@@ -76,6 +88,9 @@
         AUTHED;
     }
 
+    Set<ClientChannelEvent> REMOTE_COMMAND_WAIT_EVENTS =
+            Collections.unmodifiableSet(EnumSet.of(ClientChannelEvent.CLOSED, ClientChannelEvent.EXIT_STATUS));
+
     /**
      * Returns the original address (after having been translated through host
      * configuration entries if any) that was request to connect. It contains the
@@ -138,6 +153,79 @@
     ChannelExec createExecChannel(String command) throws IOException;
 
     /**
+     * Execute a command that requires no input and returns its output
+     *
+     * @param command The command to execute
+     * @return The command's standard output result (assumed to be in US-ASCII)
+     * @throws IOException If failed to execute the command - including
+     * if <U>anything</U> was written to the standard error or a non-zero exit
+     * status was received. If this happens, then a {@link RemoteException} is
+     * thrown with a cause of {@link ServerException} containing the remote
+     * captured standard error - including CR/LF(s)
+     * @see #executeRemoteCommand(String, OutputStream, Charset)
+     */
+    default String executeRemoteCommand(String command) throws IOException {
+        try (ByteArrayOutputStream stderr = new ByteArrayOutputStream()) {
+            String response = executeRemoteCommand(command, stderr, StandardCharsets.US_ASCII);
+            if (stderr.size() > 0) {
+                byte[] error = stderr.toByteArray();
+                String errorMessage = new String(error, StandardCharsets.US_ASCII);
+                throw new RemoteException("Error reported from remote command='" + command, new ServerException(errorMessage));
+            }
+
+            return response;
+        }
+    }
+
+    /**
+     * Execute a command that requires no input and returns its output
+     *
+     * @param command The command to execute - without a terminating LF
+     * @param stderr Standard error output stream - if {@code null} then
+     * error stream data is ignored. <B>Note:</B> if the stream is not {@code null}
+     * then it will be left <U>open</U> when this method returns or exception
+     * is thrown
+     * @param charset The command {@link Charset} for input/output/error - if
+     * {@code null} then US_ASCII is assumed
+     * @return The command's standard output result
+     * @throws IOException If failed to manage the command channel - <B>Note:</B>
+     * the code does not check if anything was output to the standard error stream,
+     * but does check the reported exit status (if any) for non-zero value. If
+     * non-zero exit status received then a {@link RemoteException} is thrown with'
+     * a {@link ServerException} cause containing the exits value
+     */
+    default String executeRemoteCommand(String command, OutputStream stderr, Charset charset) throws IOException {
+        if (charset == null) {
+            charset = StandardCharsets.US_ASCII;
+        }
+
+        try (ByteArrayOutputStream channelOut = new ByteArrayOutputStream(Byte.MAX_VALUE);
+             OutputStream channelErr = (stderr == null) ? new NullOutputStream() : new NoCloseOutputStream(stderr);
+             ClientChannel channel = createExecChannel(command)) {
+            channel.setOut(channelOut);
+            channel.setErr(channelErr);
+            channel.open().await(); // TODO use verify and a configurable timeout
+
+            OutputStream invertedStream = channel.getInvertedIn();
+            invertedStream.write(command.getBytes(charset));
+            invertedStream.flush();
+
+            // TODO use a configurable timeout
+            Collection<ClientChannelEvent> waitMask = channel.waitFor(REMOTE_COMMAND_WAIT_EVENTS, 0L);
+            if (waitMask.contains(ClientChannelEvent.TIMEOUT)) {
+                throw new SocketTimeoutException("Failed to retrieve command result in time: " + command);
+            }
+
+            Integer exitStatus = channel.getExitStatus();
+            if ((exitStatus != null) && (exitStatus.intValue() != 0)) {
+                throw new RemoteException("Remote command failed (" + exitStatus + "): " + command, new ServerException(exitStatus.toString()));
+            }
+            byte[]  response = channelOut.toByteArray();
+            return new String(response, charset);
+        }
+    }
+
+    /**
      * Create a subsystem channel.
      *
      * @param subsystem The subsystem name
diff --git a/sshd-core/src/main/java/org/apache/sshd/server/ExitCallback.java b/sshd-core/src/main/java/org/apache/sshd/server/ExitCallback.java
index f6372ee..4da17f9 100644
--- a/sshd-core/src/main/java/org/apache/sshd/server/ExitCallback.java
+++ b/sshd-core/src/main/java/org/apache/sshd/server/ExitCallback.java
@@ -28,7 +28,9 @@
      *
      * @param exitValue the exit value
      */
-    void onExit(int exitValue);
+    default void onExit(int exitValue) {
+        onExit(exitValue, "");
+    }
 
     /**
      * Informs the SSH client/server that the shell has exited
diff --git a/sshd-core/src/main/java/org/apache/sshd/server/channel/ChannelSession.java b/sshd-core/src/main/java/org/apache/sshd/server/channel/ChannelSession.java
index 61241e1..5843917 100644
--- a/sshd-core/src/main/java/org/apache/sshd/server/channel/ChannelSession.java
+++ b/sshd-core/src/main/java/org/apache/sshd/server/channel/ChannelSession.java
@@ -677,11 +677,6 @@
         }
         command.setExitCallback(new ExitCallback() {
             @Override
-            public void onExit(int exitValue) {
-                onExit(exitValue, "");
-            }
-
-            @Override
             @SuppressWarnings("synthetic-access")
             public void onExit(int exitValue, String exitMessage) {
                 try {
diff --git a/sshd-core/src/test/java/org/apache/sshd/client/ClientTest.java b/sshd-core/src/test/java/org/apache/sshd/client/ClientTest.java
index 7ca04aa..7ac8825 100644
--- a/sshd-core/src/test/java/org/apache/sshd/client/ClientTest.java
+++ b/sshd-core/src/test/java/org/apache/sshd/client/ClientTest.java
@@ -135,14 +135,13 @@
  */
 @FixMethodOrder(MethodSorters.NAME_ASCENDING)
 public class ClientTest extends BaseTestSupport {
-
     private SshServer sshd;
     private SshClient client;
     private int port;
     private CountDownLatch authLatch;
     private CountDownLatch channelLatch;
 
-    private final AtomicReference<ClientSession> clientSessionHolder = new AtomicReference<ClientSession>(null);
+    private final AtomicReference<ClientSession> clientSessionHolder = new AtomicReference<>(null);
     @SuppressWarnings("synthetic-access")
     private final SessionListener clientSessionListener = new SessionListener() {
         @Override
@@ -1068,7 +1067,7 @@
 
     @Test   // see SSHD-504
     public void testDefaultKeyboardInteractivePasswordPromptLocationIndependence() throws Exception {
-        final Collection<String> mismatchedPrompts = new LinkedList<String>();
+        final Collection<String> mismatchedPrompts = new LinkedList<>();
         client.setUserAuthFactories(Arrays.<NamedFactory<UserAuth>>asList(new UserAuthKeyboardInteractiveFactory() {
             @Override
             public UserAuthKeyboardInteractive create() {
@@ -1405,7 +1404,7 @@
             channels.add(session.createChannel(Channel.CHANNEL_EXEC, getCurrentTestName()));
             channels.add(session.createChannel(Channel.CHANNEL_SHELL, getClass().getSimpleName()));
 
-            Set<Integer> ids = new HashSet<Integer>(channels.size());
+            Set<Integer> ids = new HashSet<>(channels.size());
             for (ClientChannel c : channels) {
                 int id = ((AbstractChannel) c).getId();
                 assertTrue("Channel ID repeated: " + id, ids.add(Integer.valueOf(id)));
diff --git a/sshd-core/src/test/java/org/apache/sshd/client/session/ClientSessionTest.java b/sshd-core/src/test/java/org/apache/sshd/client/session/ClientSessionTest.java
new file mode 100644
index 0000000..52b8faa
--- /dev/null
+++ b/sshd-core/src/test/java/org/apache/sshd/client/session/ClientSessionTest.java
@@ -0,0 +1,213 @@
+/*
+ * 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.sshd.client.session;
+
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.rmi.RemoteException;
+import java.rmi.ServerException;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.sshd.client.SshClient;
+import org.apache.sshd.server.Command;
+import org.apache.sshd.server.CommandFactory;
+import org.apache.sshd.server.SshServer;
+import org.apache.sshd.util.test.BaseTestSupport;
+import org.apache.sshd.util.test.CommandExecutionHelper;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.FixMethodOrder;
+import org.junit.Test;
+import org.junit.runners.MethodSorters;
+
+/**
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+@FixMethodOrder(MethodSorters.NAME_ASCENDING)
+public class ClientSessionTest extends BaseTestSupport {
+    private SshServer sshd;
+    private SshClient client;
+    private int port;
+
+    public ClientSessionTest() {
+        super();
+    }
+
+    @Before
+    public void setUp() throws Exception {
+        sshd = setupTestServer();
+        sshd.start();
+        port = sshd.getPort();
+
+        client = setupTestClient();
+        client.start();
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        if (sshd != null) {
+            sshd.stop(true);
+        }
+        if (client != null) {
+            client.stop();
+        }
+    }
+
+    @Test
+    public void testDefaultExecuteCommandMethod() throws Exception {
+        final String expectedCommand = getCurrentTestName() + "-CMD";
+        final String expectedResponse = getCurrentTestName() + "-RSP";
+        sshd.setCommandFactory(new CommandFactory() {
+            @Override
+            public Command createCommand(String command) {
+                return new CommandExecutionHelper() {
+                    private boolean cmdProcessed;
+
+                    @Override
+                    protected boolean handleCommandLine(String command) throws Exception {
+                        assertEquals("Mismatched incoming command", expectedCommand, command);
+                        assertFalse("Duplicated command call", cmdProcessed);
+                        OutputStream stdout = getOut();
+                        stdout.write(expectedResponse.getBytes(StandardCharsets.US_ASCII));
+                        stdout.flush();
+                        cmdProcessed = true;
+                        return false;
+                    }
+                };
+            }
+        });
+
+        try (ClientSession session = client.connect(getCurrentTestName(), TEST_LOCALHOST, port).verify(7L, TimeUnit.SECONDS).getSession()) {
+            session.addPasswordIdentity(getCurrentTestName());
+            session.auth().verify(5L, TimeUnit.SECONDS);
+
+            // NOTE !!! The LF is only because we are using a buffered reader on the server end to read the command
+            String actualResponse = session.executeRemoteCommand(expectedCommand + "\n");
+            assertEquals("Mismatched command response", expectedResponse, actualResponse);
+        } finally {
+            client.stop();
+        }
+    }
+
+    @Test
+    public void testExceptionThrownIfRemoteStderrWrittenTo() throws Exception {
+        final String expectedCommand = getCurrentTestName() + "-CMD";
+        final String expectedErrorMessage = getCurrentTestName() + "-ERR";
+        sshd.setCommandFactory(new CommandFactory() {
+            @Override
+            public Command createCommand(String command) {
+                return new CommandExecutionHelper() {
+                    private boolean cmdProcessed;
+
+                    @Override
+                    protected boolean handleCommandLine(String command) throws Exception {
+                        assertEquals("Mismatched incoming command", expectedCommand, command);
+                        assertFalse("Duplicated command call", cmdProcessed);
+                        OutputStream stderr = getErr();
+                        stderr.write(expectedErrorMessage.getBytes(StandardCharsets.US_ASCII));
+                        stderr.flush();
+                        cmdProcessed = true;
+                        return false;
+                    }
+                };
+            }
+        });
+
+        String actualErrorMessage = null;
+        try (ClientSession session = client.connect(getCurrentTestName(), TEST_LOCALHOST, port).verify(7L, TimeUnit.SECONDS).getSession()) {
+            session.addPasswordIdentity(getCurrentTestName());
+            session.auth().verify(5L, TimeUnit.SECONDS);
+
+            // NOTE !!! The LF is only because we are using a buffered reader on the server end to read the command
+            String response = session.executeRemoteCommand(expectedCommand + "\n");
+            fail("Unexpected successful response: " + response);
+        } catch (Exception e) {
+            if (!(e instanceof RemoteException)) {
+                throw e;
+            }
+
+            Throwable cause = e.getCause();
+            if (!(cause instanceof ServerException)) {
+                throw e;
+            }
+
+            actualErrorMessage = cause.getMessage();
+        } finally {
+            client.stop();
+        }
+
+        assertEquals("Mismatched captured error message", expectedErrorMessage, actualErrorMessage);
+    }
+
+    @Test
+    public void testExceptionThrownIfNonZeroExitStatus() throws Exception {
+        final String expectedCommand = getCurrentTestName() + "-CMD";
+        final int exepectedErrorCode = 7365;
+        sshd.setCommandFactory(new CommandFactory() {
+            @Override
+            public Command createCommand(String command) {
+                return new CommandExecutionHelper() {
+                    private boolean cmdProcessed;
+
+                    @Override
+                    public void onExit(int exitValue, String exitMessage) {
+                        super.onExit((exitValue == 0) ? exepectedErrorCode : exitValue, exitMessage);
+                    }
+
+                    @Override
+                    protected boolean handleCommandLine(String command) throws Exception {
+                        assertEquals("Mismatched incoming command", expectedCommand, command);
+                        assertFalse("Duplicated command call", cmdProcessed);
+                        OutputStream stdout = getOut();
+                        stdout.write(command.getBytes(StandardCharsets.US_ASCII));
+                        stdout.flush();
+                        cmdProcessed = true;
+                        return false;
+                    }
+                };
+            }
+        });
+
+        String actualErrorMessage = null;
+        try (ClientSession session = client.connect(getCurrentTestName(), TEST_LOCALHOST, port).verify(7L, TimeUnit.SECONDS).getSession()) {
+            session.addPasswordIdentity(getCurrentTestName());
+            session.auth().verify(5L, TimeUnit.SECONDS);
+
+            // NOTE !!! The LF is only because we are using a buffered reader on the server end to read the command
+            String response = session.executeRemoteCommand(expectedCommand + "\n");
+            fail("Unexpected successful response: " + response);
+        } catch (Exception e) {
+            if (!(e instanceof RemoteException)) {
+                throw e;
+            }
+
+            Throwable cause = e.getCause();
+            if (!(cause instanceof ServerException)) {
+                throw e;
+            }
+
+            actualErrorMessage = cause.getMessage();
+        } finally {
+            client.stop();
+        }
+
+        assertEquals("Mismatched captured error code", Integer.toString(exepectedErrorCode), actualErrorMessage);
+    }
+}
diff --git a/sshd-core/src/test/java/org/apache/sshd/util/test/CommandExecutionHelper.java b/sshd-core/src/test/java/org/apache/sshd/util/test/CommandExecutionHelper.java
new file mode 100644
index 0000000..a2006d5
--- /dev/null
+++ b/sshd-core/src/test/java/org/apache/sshd/util/test/CommandExecutionHelper.java
@@ -0,0 +1,152 @@
+/*
+ * 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.sshd.util.test;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.InterruptedIOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+
+import org.apache.sshd.server.Command;
+import org.apache.sshd.server.Environment;
+import org.apache.sshd.server.ExitCallback;
+
+/**
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+public abstract class CommandExecutionHelper implements Command, Runnable, ExitCallback {
+    private InputStream in;
+    private OutputStream out;
+    private OutputStream err;
+    private ExitCallback callback;
+    private Environment environment;
+    private Thread thread;
+    private boolean cbCalled;
+
+    protected CommandExecutionHelper() {
+        super();
+    }
+
+    public InputStream getIn() {
+        return in;
+    }
+
+    public OutputStream getOut() {
+        return out;
+    }
+
+    public OutputStream getErr() {
+        return err;
+    }
+
+    public Environment getEnvironment() {
+        return environment;
+    }
+
+    public ExitCallback getExitCallback() {
+        return callback;
+    }
+
+    @Override
+    public void setInputStream(InputStream in) {
+        this.in = in;
+    }
+
+    @Override
+    public void setOutputStream(OutputStream out) {
+        this.out = out;
+    }
+
+    @Override
+    public void setErrorStream(OutputStream err) {
+        this.err = err;
+    }
+
+    @Override
+    public void setExitCallback(ExitCallback callback) {
+        this.callback = callback;
+    }
+
+    @Override
+    public void start(Environment env) throws IOException {
+        environment = env;
+        thread = new Thread(this, "CommandExecutionHelper");
+        thread.setDaemon(true);
+        thread.start();
+    }
+
+    @Override
+    public void destroy() {
+        thread.interrupt();
+    }
+
+    @Override
+    public void run() {
+        String command = null;
+        try (BufferedReader r = new BufferedReader(new InputStreamReader(getIn(), StandardCharsets.UTF_8))) {
+            for (;;) {
+                command = r.readLine();
+                if (command == null) {
+                    return;
+                }
+
+                if (!handleCommandLine(command)) {
+                    return;
+                }
+            }
+        } catch (InterruptedIOException e) {
+            // Ignore - signaled end
+        } catch (Exception e) {
+            String message = "Failed (" + e.getClass().getSimpleName() + ") to handle '" + command + "': " + e.getMessage();
+            try {
+                OutputStream stderr = getErr();
+                stderr.write(message.getBytes(StandardCharsets.US_ASCII));
+            } catch (IOException ioe) {
+                ioe.printStackTrace();
+            } finally {
+                onExit(-1, message);
+            }
+        } finally {
+            onExit(0);
+        }
+    }
+
+    @Override
+    public void onExit(int exitValue, String exitMessage) {
+        if (!cbCalled) {
+            ExitCallback cb = getExitCallback();
+            try {
+                cb.onExit(exitValue, exitMessage);
+            } finally {
+                cbCalled = true;
+            }
+        }
+    }
+
+    /**
+     * @param command The command line
+     * @return {@code true} if continue accepting command
+     * @throws Exception If failed to handle the command line
+     */
+    protected abstract boolean handleCommandLine(String command) throws Exception;
+}
diff --git a/sshd-core/src/test/java/org/apache/sshd/util/test/EchoShell.java b/sshd-core/src/test/java/org/apache/sshd/util/test/EchoShell.java
index 4d12093..637ce43 100644
--- a/sshd-core/src/test/java/org/apache/sshd/util/test/EchoShell.java
+++ b/sshd-core/src/test/java/org/apache/sshd/util/test/EchoShell.java
@@ -18,104 +18,27 @@
  */
 package org.apache.sshd.util.test;
 
-import java.io.BufferedReader;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.io.InterruptedIOException;
 import java.io.OutputStream;
 import java.nio.charset.StandardCharsets;
 
-import org.apache.sshd.server.Command;
-import org.apache.sshd.server.Environment;
-import org.apache.sshd.server.ExitCallback;
-
 /**
  * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
  */
-public class EchoShell implements Command, Runnable {
-
-    private InputStream in;
-    private OutputStream out;
-    private OutputStream err;
-    private ExitCallback callback;
-    private Environment environment;
-    private Thread thread;
-
+public class EchoShell extends CommandExecutionHelper {
     public EchoShell() {
         super();
     }
 
-    public InputStream getIn() {
-        return in;
-    }
-
-    public OutputStream getOut() {
-        return out;
-    }
-
-    public OutputStream getErr() {
-        return err;
-    }
-
-    public Environment getEnvironment() {
-        return environment;
-    }
-
     @Override
-    public void setInputStream(InputStream in) {
-        this.in = in;
-    }
+    protected boolean handleCommandLine(String command) throws Exception {
+        OutputStream out = getOut();
+        out.write((command + "\n").getBytes(StandardCharsets.UTF_8));
+        out.flush();
 
-    @Override
-    public void setOutputStream(OutputStream out) {
-        this.out = out;
-    }
-
-    @Override
-    public void setErrorStream(OutputStream err) {
-        this.err = err;
-    }
-
-    @Override
-    public void setExitCallback(ExitCallback callback) {
-        this.callback = callback;
-    }
-
-    @Override
-    public void start(Environment env) throws IOException {
-        environment = env;
-        thread = new Thread(this, "EchoShell");
-        thread.setDaemon(true);
-        thread.start();
-    }
-
-    @Override
-    public void destroy() {
-        thread.interrupt();
-    }
-
-    @Override
-    public void run() {
-        BufferedReader r = new BufferedReader(new InputStreamReader(in));
-        try {
-            for (;;) {
-                String s = r.readLine();
-                if (s == null) {
-                    return;
-                }
-                out.write((s + "\n").getBytes(StandardCharsets.UTF_8));
-                out.flush();
-                if ("exit".equals(s)) {
-                    return;
-                }
-            }
-        } catch (InterruptedIOException e) {
-            // Ignore
-        } catch (Exception e) {
-            e.printStackTrace();
-        } finally {
-            callback.onExit(0);
+        if ("exit".equals(command)) {
+            return false;
         }
+
+        return true;
     }
 }
\ No newline at end of file