Creating a custom shell

View Source

This guide will show how to create a custom shell. The most common use case for this is to support other languages running on the Erlang VM, but it can also be used to create specialized debugging shells a system.

This guide will build on top of the built-in Erlang line editor, which means that the keybindings described in tty - A Command-Line Interface can be used edit the input before it is passed to the custom shell. This somewhat limits what the custom shell can do, but it also means that we do not have to implement line editing ourselves. If you need more control over the shell, then use Creating a terminal application as a starting-point to build your own line editor and shell.

A process inspection shell

The custom shell that we are going to build is a process inspection shell that supports the following commands:

  • list - lists all processes
  • inspect pid() - inspect a process
  • suspend pid() - suspend a process
  • resume pid() - resume a process

Lets get started!

Starting with a custom shell

The custom shell will be implemented in an escript, but it could just as well be in a regular system or as a remote shell. To start a custom shell we first need to start Erlang in -noinput or -noshell mode. escript are started by default in -noshell mode, so we don't have to do anything special here. To start the custom shell we then call shell:start_interactive/1.

#!/usr/bin/env escript
%% pshell.es
-export([start/0]).
main(_Args) ->
    shell:start_interactive({?MODULE, start, []}),
    timer:sleep(infinity). %% Make sure the escript does not exit

-spec start() -> pid().
start() ->
    spawn(fun() ->
                  io:format(~"Starting process inspection shell~n"),
                  loop()
          end).

loop() ->
    receive _M -> loop() end.

If we run the above we will get this:

$ ./pshell.es
Erlang/OTP 28 [DEVELOPMENT] [erts-15.0.1] [source-b395339a02] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [jit:ns]

Starting process inspection shell

The io:standard_io/0 of the created shell process will be set to the Erlang line editor, which means that we can use the normal io functions to read and write data to the terminal.

Adding our first command

Let's start adding the shell interface. We will use io:get_line/1 to read from io:standard_io/0 as this shell will be line based. However, for a more complex shell it is better to send get_until I/O requests as commands read that way can span multiple lines. So we expand our loop/0 with a io:get_line/1 and pass the results to our parser.

loop() ->
    case io:get_line("> ") of
        eof -> ok;
        {error, Reason} -> exit(Reason);
        Data -> eval(string:trim(Data))
    end,
    loop().

eval("list") ->
    Format = " ~.10ts | ~.10ts | ~.10ts~n",
    io:format(Format,["Pid", "Name", "MsgQ Len"]),
    [begin
         [{registered_name,Name},{message_queue_len,Len}]
             = erlang:process_info(Pid, [registered_name, message_queue_len]),
         io:format(Format,[to_list(Pid), to_list(Name), to_list(Len)])
     end || Pid <- processes()];
eval(Unknown) ->
    io:format("Unknown command: '~ts'~n",[Unknown]).

to_list(Pid) when is_pid(Pid) ->
    pid_to_list(Pid);
to_list(Atom) when is_atom(Atom) ->
    atom_to_list(Atom);
to_list(Int) when is_integer(Int) ->
    integer_to_list(Int);
to_list(List) when is_list(List) ->
    List.

If we run the above we will get this:

$ ./pshell.es
Erlang/OTP 28 [DEVELOPMENT] [erts-15.0.1] [source-b395339a02] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [jit:ns]

Starting process inspection shell
> list
 Pid        | Name       | MsgQ Len  
 <0.0.0>    | init       | 0         
 <0.1.0>    | erts_code_ | 0         
 <0.2.0>    |            | 0         
 <0.3.0>    |            | 0         
 <0.4.0>    |            | 0         
 <0.5.0>    |            | 0         
 <0.6.0>    |            | 0         
 <0.7.0>    |            | 0         
 <0.8.0>    | socket_reg | 0         
 <0.10.0>   |            | 0         
 <0.11.0>   | erl_prim_l | 0         
 <0.43.0>   | logger     | 0         
 <0.45.0>   | applicatio | 0
...

With this all in place we can now easily add inspect, suspend and resume as well.

eval("inspect " ++ PidStr) ->
    case parse_pid(PidStr) of
        invalid -> ok;
        Pid ->
            [{registered_name, Name}, {memory, Memory}, {messages, Messages}, {status, Status}] =
                erlang:process_info(Pid, [registered_name, memory, messages, status]),
            io:format("Pid: ~p~nName: ~ts~nStatus: ~p~nMemory: ~p~nMessages: ~p~n",
                      [Pid, to_list(Name), Status, Memory, Messages])
    end;
eval("suspend " ++ PidStr) ->
    case parse_pid(PidStr) of
        invalid -> ok;
        Pid ->
            erlang:suspend_process(Pid),
            io:format("Suspeneded ~ts~n")
    end;
eval("resume " ++ PidStr) ->
    case parse_pid(PidStr) of
        invalid -> ok;
        Pid ->
            erlang:resumne_process(Pid),
            io:format("Resumed ~ts~n")
    end;

Adding autocompletion

Wouldn't it be great if we could add some simple auto-completion for our shell? We can do that by setting a edlin_expand fun for our shell. This is done by calling io:setopts([{expand_fun, Fun}]). The fun that we provide is will receive the reversed current line from edlin and is expected to return possible expansions. Let's start by adding a simple fun to expand our commands.

-spec start() -> pid().
start() ->
    spawn(fun() ->
                  io:setopts([{expand_fun, fun expand_fun/1}]),
                  io:format(~"Starting process inspection shell~n"),
                  loop()
          end).

-spec expand_fun(ReverseLine :: string()) -> {yes, string(), list(string())} |
          {no, nil(), nil()}.
expand_fun("") -> %% If line is empty, we list all available commands
    {yes, "", ["list", "inspect", "suspend", "resume"]};
expand_fun(Curr) ->
    expand_fun(lists:reverse(Curr), ["list", "inspect", "suspend", "resume"]).

expand_fun(_Curr, []) ->
    {no, "", []};
expand_fun(Curr, [Cmd | T]) ->
    case lists:prefix(Curr, Cmd) of
        true ->
            %% If Curr is a prefix of Cmd we subtract Curr from Cmd to get the
            %% characters we need to complete with.
            {yes, lists:reverse(lists:reverse(Cmd) -- lists:reverse(Curr)), []};
        false ->
            expand_fun(Curr, T)
    end.

With the above code we will get expansions of our commands if we hit <TAB> in the shell. Its possible to make very complex completion algorithms, for example the Erlang shell has completions based on the function specifications of your code. It is important though that the shell still feels responsive, so calling out to a LLM model for completion may or may not be a good idea.

The complete source code for this example can be found here.