A structured-data shell in Gleam, inspired by Nushell
1%% Erlang FFI for gleshell: REPL I/O, cwd, env, external processes.
2-module(gleshell_ffi).
3-include_lib("kernel/include/file.hrl").
4-export([
5 get_line/1,
6 parse_line/2,
7 run_as_shell/1,
8 spawn_shell/2,
9 set_cwd/1,
10 get_cwd/0,
11 getenv/1,
12 setenv/2,
13 list_env/0,
14 run_cmd/3,
15 run_cmd_tty/3,
16 which/1,
17 which_all/1,
18 home_dir/0,
19 stdout_isatty/0,
20 println/1,
21 take_output_shown/0,
22 clear_output_shown/0,
23 complete_word/2,
24 re_contains/3
25]).
26
27-define(ESC, 16#1b).
28-define(CSI_CLEAR_EOL, "\e[K").
29-define(HISTORY_MAX, 2000).
30
31%% ---------------------------------------------------------------------------
32%% Public: write a line (CRLF in raw TTY mode so multi-line output does not
33%% staircase — raw mode does not map LF → CR+LF the way cooked mode does).
34%% ---------------------------------------------------------------------------
35
36-spec println(binary()) -> nil.
37println(Text) when is_binary(Text) ->
38 case get(gleshell_raw) of
39 true ->
40 io:put_chars([to_crlf(Text), <<"\r\n">>]),
41 nil;
42 _ ->
43 io:put_chars([Text, $\n]),
44 nil
45 end.
46
47%% Normalize newlines to CRLF without turning existing \r\n into \r\r\n.
48to_crlf(Bin) when is_binary(Bin) ->
49 B1 = binary:replace(Bin, <<"\r\n">>, <<"\n">>, [global]),
50 B2 = binary:replace(B1, <<"\r">>, <<"\n">>, [global]),
51 binary:replace(B2, <<"\n">>, <<"\r\n">>, [global]).
52
53%% ---------------------------------------------------------------------------
54%% Public: read a line (syntax-highlighted when raw TTY mode is active)
55%% ---------------------------------------------------------------------------
56
57-spec get_line(binary()) -> {ok, binary()} | {error, binary()}.
58get_line(Prompt) when is_binary(Prompt) ->
59 case get(gleshell_raw) of
60 true ->
61 raw_get_line(Prompt);
62 _ ->
63 classic_get_line(Prompt)
64 end.
65
66%% Edlin / get_until path (non-TTY or when raw mode unavailable).
67classic_get_line(Prompt) ->
68 PromptChars = unicode:characters_to_list(Prompt),
69 case io:request(
70 standard_io,
71 {get_until, unicode, PromptChars, ?MODULE, parse_line, []}
72 ) of
73 eof ->
74 {error, <<"eof">>};
75 {error, interrupted} ->
76 %% Ctrl+C while reading: cancel line, keep the REPL alive.
77 {error, <<"interrupted">>};
78 {error, _} ->
79 {error, <<"io_error">>};
80 Line when is_list(Line); is_binary(Line) ->
81 Bin = unicode:characters_to_binary(Line),
82 Stripped = string:trim(Bin, trailing, [$\n, $\r]),
83 {ok, Stripped};
84 Other ->
85 try
86 Bin = unicode:characters_to_binary(Other),
87 Stripped = string:trim(Bin, trailing, [$\n, $\r]),
88 {ok, Stripped}
89 catch
90 _:_ ->
91 {error, <<"io_error">>}
92 end
93 end.
94
95%% get_until callback: edlin already gathers a full line.
96-spec parse_line(term(), term()) ->
97 {done, eof | string(), list()} | {more, term()}.
98parse_line(_Cont, eof) ->
99 {done, eof, []};
100parse_line(_Cont, Chars) when is_list(Chars) ->
101 {done, Chars, []}.
102
103%% ---------------------------------------------------------------------------
104%% Shell bootstrap: prefer OTP raw mode for live syntax highlighting.
105%% Falls back to edlin interactive shell when raw is unavailable.
106%%
107%% Ctrl+C: the BEAM default opens the BREAK menu (a = abort → process exit).
108%% Re-exec once with +Bc so Ctrl+C is delivered as a character to our editor
109%% (cancel line) instead of killing the shell. See erl(1) +B.
110%% ---------------------------------------------------------------------------
111
112-spec run_as_shell(fun(() -> term())) -> nil.
113run_as_shell(Fun) when is_function(Fun, 0) ->
114 case ensure_plus_bc() of
115 {parent, Port} ->
116 %% Child owns the TTY; we only wait for its exit status.
117 receive
118 {Port, {exit_status, Status}} ->
119 erlang:halt(Status)
120 end;
121 child ->
122 enable_shell_history(),
123 case try_start_raw() of
124 true ->
125 put(gleshell_raw, true),
126 load_line_history(),
127 configure_line_editor(),
128 try
129 Fun()
130 after
131 save_line_history()
132 end,
133 nil;
134 false ->
135 erase(gleshell_raw),
136 Parent = self(),
137 case try_start_interactive(Parent, Fun) of
138 {ok, started} ->
139 receive
140 {gleshell_shell_done, ok} ->
141 nil;
142 {gleshell_shell_done, {error, Class, Reason, Stack}} ->
143 erlang:raise(Class, Reason, Stack)
144 end;
145 {ok, direct} ->
146 configure_line_editor(),
147 Fun(),
148 nil
149 end
150 end
151 end.
152
153%% Ensure the emulator was started with +Bc (Ctrl+C → char, not BREAK/abort).
154%% Returns `child` when this process should run the REPL, or `{parent, Port}`
155%% when we re-exec'd and should wait on the child.
156-spec ensure_plus_bc() -> child | {parent, port()}.
157ensure_plus_bc() ->
158 case os:getenv("GLESHELL_PLUS_BC") of
159 "1" ->
160 child;
161 _ ->
162 case already_has_plus_bc() of
163 true ->
164 os:putenv("GLESHELL_PLUS_BC", "1"),
165 child;
166 false ->
167 reexec_with_plus_bc()
168 end
169 end.
170
171already_has_plus_bc() ->
172 lists:any(
173 fun(Var) ->
174 case os:getenv(Var) of
175 false ->
176 false;
177 Flags ->
178 string:find(Flags, "+Bc") =/= nomatch
179 end
180 end,
181 ["ERL_AFLAGS", "ERL_FLAGS", "ERL_ZFLAGS"]
182 ).
183
184reexec_with_plus_bc() ->
185 os:putenv("GLESHELL_PLUS_BC", "1"),
186 case os:getenv("ERL_AFLAGS") of
187 false ->
188 os:putenv("ERL_AFLAGS", "+Bc");
189 Flags ->
190 case string:find(Flags, "+Bc") of
191 nomatch ->
192 os:putenv("ERL_AFLAGS", "+Bc " ++ Flags);
193 _ ->
194 ok
195 end
196 end,
197 case os:find_executable("erl") of
198 false ->
199 %% No erl on PATH — continue without +Bc (BREAK menu may appear).
200 child;
201 Erl ->
202 Pa = lists:flatmap(fun(D) -> ["-pa", D] end, code:get_path()),
203 Extra = init:get_plain_arguments(),
204 Args =
205 ["+Bc", "-noshell"] ++
206 Pa ++
207 ["-eval", "gleshell@@main:run(gleshell)", "-extra" | Extra],
208 try
209 Port = open_port(
210 {spawn_executable, Erl},
211 [exit_status, nouse_stdio, {args, Args}]
212 ),
213 {parent, Port}
214 catch
215 _:_ ->
216 child
217 end
218 end.
219
220try_start_raw() ->
221 case stdout_isatty() of
222 false ->
223 false;
224 true ->
225 case catch shell:start_interactive({noshell, raw}) of
226 ok ->
227 true;
228 {error, already_started} ->
229 %% Cannot switch an existing interactive shell into raw.
230 false;
231 _ ->
232 false
233 end
234 end.
235
236try_start_interactive(Parent, Fun) ->
237 _ = application:set_env(stdlib, shell_slogan, "", [{persistent, true}]),
238 case shell:start_interactive({gleshell_ffi, spawn_shell, [Parent, Fun]}) of
239 ok ->
240 {ok, started};
241 {error, already_started} ->
242 {ok, direct};
243 {error, _} ->
244 {ok, direct}
245 end.
246
247-spec spawn_shell(pid(), fun(() -> term())) -> pid().
248spawn_shell(Parent, Fun) when is_pid(Parent), is_function(Fun, 0) ->
249 spawn(fun() ->
250 try
251 configure_line_editor(),
252 Fun()
253 of
254 _ ->
255 Parent ! {gleshell_shell_done, ok},
256 exit(die)
257 catch
258 Class:Reason:Stack ->
259 Parent ! {gleshell_shell_done, {error, Class, Reason, Stack}},
260 erlang:raise(Class, Reason, Stack)
261 end
262 end).
263
264configure_line_editor() ->
265 _ = io:setopts([{encoding, unicode}, binary]),
266 try
267 io:setopts([{line_history, true}])
268 catch
269 _:_ ->
270 ok
271 end,
272 ok.
273
274enable_shell_history() ->
275 case application:get_env(kernel, shell_history_path) of
276 {ok, _} ->
277 ok;
278 undefined ->
279 Path = filename:basedir(user_cache, "gleshell-history"),
280 _ = application:set_env(
281 kernel, shell_history_path, Path, [{persistent, true}]
282 ),
283 ok
284 end,
285 case application:get_env(kernel, shell_history) of
286 {ok, _} ->
287 ok;
288 undefined ->
289 _ = application:set_env(
290 kernel, shell_history, enabled, [{persistent, true}]
291 ),
292 ok
293 end.
294
295%% ---------------------------------------------------------------------------
296%% Raw-mode line editor with Nushell-style syntax highlighting
297%% ---------------------------------------------------------------------------
298%%
299%% Buffer model: Left is graphemes before the cursor (reversed),
300%% Right is graphemes after the cursor (normal order).
301%% History is a list of binaries (newest first).
302
303raw_get_line(Prompt) when is_binary(Prompt) ->
304 PromptList = unicode:characters_to_list(Prompt),
305 History = case get(gleshell_history) of
306 L when is_list(L) -> L;
307 _ -> []
308 end,
309 redraw(PromptList, [], []),
310 raw_loop(PromptList, [], [], History, 0, <<>>).
311
312%% HistPos: 0 = editing current buffer; N>0 = viewing Nth history entry.
313%% Saved: buffer saved when first entering history navigation.
314raw_loop(Prompt, Left, Right, History, HistPos, Saved) ->
315 case read_key() of
316 eof ->
317 io:put_chars("\r\n"),
318 {error, <<"eof">>};
319 {error, _} ->
320 io:put_chars("\r\n"),
321 {error, <<"io_error">>};
322 enter ->
323 Line = buffer_to_bin(Left, Right),
324 io:put_chars("\r\n"),
325 push_history(Line),
326 {ok, Line};
327 {char, C} when is_integer(C), C >= 32, C =/= 127 ->
328 %% Printable Unicode codepoint
329 NewLeft = [C | Left],
330 redraw(Prompt, NewLeft, Right),
331 raw_loop(Prompt, NewLeft, Right, History, 0, <<>>);
332 backspace ->
333 case Left of
334 [] ->
335 raw_loop(Prompt, Left, Right, History, HistPos, Saved);
336 [_ | Rest] ->
337 redraw(Prompt, Rest, Right),
338 raw_loop(Prompt, Rest, Right, History, 0, <<>>)
339 end;
340 delete ->
341 case Right of
342 [] ->
343 raw_loop(Prompt, Left, Right, History, HistPos, Saved);
344 [_ | Rest] ->
345 redraw(Prompt, Left, Rest),
346 raw_loop(Prompt, Left, Rest, History, 0, <<>>)
347 end;
348 left ->
349 case Left of
350 [] ->
351 raw_loop(Prompt, Left, Right, History, HistPos, Saved);
352 [C | Rest] ->
353 redraw(Prompt, Rest, [C | Right]),
354 raw_loop(Prompt, Rest, [C | Right], History, HistPos, Saved)
355 end;
356 right ->
357 case Right of
358 [] ->
359 raw_loop(Prompt, Left, Right, History, HistPos, Saved);
360 [C | Rest] ->
361 redraw(Prompt, [C | Left], Rest),
362 raw_loop(Prompt, [C | Left], Rest, History, HistPos, Saved)
363 end;
364 home ->
365 NewRight = lists:reverse(Left) ++ Right,
366 redraw(Prompt, [], NewRight),
367 raw_loop(Prompt, [], NewRight, History, HistPos, Saved);
368 'end' ->
369 NewLeft = lists:reverse(Right) ++ Left,
370 redraw(Prompt, NewLeft, []),
371 raw_loop(Prompt, NewLeft, [], History, HistPos, Saved);
372 up ->
373 hist_nav(Prompt, Left, Right, History, HistPos, Saved, 1);
374 down ->
375 hist_nav(Prompt, Left, Right, History, HistPos, Saved, -1);
376 ctrl_a ->
377 NewRight = lists:reverse(Left) ++ Right,
378 redraw(Prompt, [], NewRight),
379 raw_loop(Prompt, [], NewRight, History, HistPos, Saved);
380 ctrl_e ->
381 NewLeft = lists:reverse(Right) ++ Left,
382 redraw(Prompt, NewLeft, []),
383 raw_loop(Prompt, NewLeft, [], History, HistPos, Saved);
384 ctrl_u ->
385 redraw(Prompt, [], Right),
386 raw_loop(Prompt, [], Right, History, 0, <<>>);
387 ctrl_k ->
388 redraw(Prompt, Left, []),
389 raw_loop(Prompt, Left, [], History, 0, <<>>);
390 ctrl_w ->
391 {NewLeft, _} = kill_word(Left),
392 redraw(Prompt, NewLeft, Right),
393 raw_loop(Prompt, NewLeft, Right, History, 0, <<>>);
394 ctrl_d ->
395 case {Left, Right} of
396 {[], []} ->
397 io:put_chars("\r\n"),
398 {error, <<"eof">>};
399 {_, []} ->
400 raw_loop(Prompt, Left, Right, History, HistPos, Saved);
401 {_, [_ | Rest]} ->
402 redraw(Prompt, Left, Rest),
403 raw_loop(Prompt, Left, Rest, History, 0, <<>>)
404 end;
405 ctrl_c ->
406 %% Cancel current line (like bash) and return empty.
407 io:put_chars("^C\r\n"),
408 {ok, <<>>};
409 ctrl_l ->
410 io:put_chars("\e[H\e[2J"),
411 redraw(Prompt, Left, Right),
412 raw_loop(Prompt, Left, Right, History, HistPos, Saved);
413 ctrl_r ->
414 reverse_search(Prompt, History);
415 tab ->
416 tab_complete(Prompt, Left, Right, History, HistPos, Saved);
417 _Other ->
418 raw_loop(Prompt, Left, Right, History, HistPos, Saved)
419 end.
420
421%% ---------------------------------------------------------------------------
422%% Tab: command + filename completion (token under cursor)
423%% ---------------------------------------------------------------------------
424%%
425%% Command position (start of line / after | ; & =): complete builtins and
426%% PATH executables. Path-like command words (./foo, /bin/ls, ~/x) still use
427%% filename completion. Elsewhere: filename completion as before.
428%%
429%% One match → insert it (commands get a trailing space; dirs get /).
430%% Several matches → extend the longest common prefix; if that does not
431%% advance the buffer, list candidates under the line and redraw.
432
433tab_complete(Prompt, Left, Right, History, HistPos, Saved) ->
434 {PrefixRev, Word} = word_before_cursor(Left),
435 {Matches, Kind} = completions_for(PrefixRev, Word),
436 case Matches of
437 [] ->
438 beep(),
439 raw_loop(Prompt, Left, Right, History, HistPos, Saved);
440 [Only] ->
441 Insert = finalize_completion(Only, Kind),
442 NewLeft = apply_completed_word(PrefixRev, Insert),
443 redraw(Prompt, NewLeft, Right),
444 raw_loop(Prompt, NewLeft, Right, History, 0, <<>>);
445 _ ->
446 Common = longest_common_prefix(Matches),
447 case Common =/= Word andalso length(Common) >= length(Word) of
448 true ->
449 NewLeft = apply_completed_word(PrefixRev, Common),
450 redraw(Prompt, NewLeft, Right),
451 raw_loop(Prompt, NewLeft, Right, History, 0, <<>>);
452 false ->
453 show_completions(Matches),
454 redraw(Prompt, Left, Right),
455 raw_loop(Prompt, Left, Right, History, HistPos, Saved)
456 end
457 end.
458
459%% Test/helper: return {Matches, Kind} for a buffer prefix and word.
460%% Prefix is the text *before* the word being completed (not reversed).
461%% Kind is <<"command">> | <<"path">>.
462-spec complete_word(binary(), binary()) -> {list(binary()), binary()}.
463complete_word(PrefixBin, WordBin) when is_binary(PrefixBin), is_binary(WordBin) ->
464 Prefix = unicode:characters_to_list(PrefixBin),
465 Word = unicode:characters_to_list(WordBin),
466 PrefixRev = lists:reverse(Prefix),
467 {Matches, Kind} = completions_for(PrefixRev, Word),
468 KindBin =
469 case Kind of
470 command -> <<"command">>;
471 path -> <<"path">>
472 end,
473 {
474 [unicode:characters_to_binary(M) || M <- Matches],
475 KindBin
476 }.
477
478%% Trailing space after a unique command so the user can type args next.
479finalize_completion(Word, command) ->
480 case lists:last(Word) of
481 $/ -> Word;
482 $\s -> Word;
483 _ -> Word ++ " "
484 end;
485finalize_completion(Word, path) ->
486 Word.
487
488completions_for(PrefixRev, Word) ->
489 case is_command_position(PrefixRev) andalso not is_path_like_word(Word) of
490 true ->
491 {command_completions(Word), command};
492 false ->
493 {filename_completions(Word), path}
494 end.
495
496%% Command position: empty prefix, or last non-space before the word is a
497%% pipeline/statement separator or assignment (`let x = …`).
498is_command_position(PrefixRev) ->
499 Before = string:trim(lists:reverse(PrefixRev), trailing),
500 case Before of
501 [] ->
502 true;
503 _ ->
504 case lists:last(Before) of
505 $| -> true;
506 $; -> true;
507 $& -> true;
508 $= -> true;
509 _ -> false
510 end
511 end.
512
513%% ./script, ../bin/x, /usr/bin/ls, ~/bin/foo — complete as paths even as cmds.
514is_path_like_word([]) ->
515 false;
516is_path_like_word(Word) ->
517 lists:member($/, Word) orelse lists:member($\\, Word) orelse hd(Word) =:= $~.
518
519%% Builtins + keywords + PATH executables matching Word as a prefix.
520command_completions(Word) ->
521 Builtins = [
522 N
523 || N <- builtin_command_names(),
524 lists:prefix(Word, N)
525 ],
526 Keywords = [
527 N
528 || N <- ["let"],
529 lists:prefix(Word, N)
530 ],
531 PathCmds =
532 case Word of
533 %% Empty prefix: skip PATH dump (can be thousands of names).
534 [] ->
535 [];
536 _ ->
537 path_command_completions(Word)
538 end,
539 lists:usort(Builtins ++ Keywords ++ PathCmds).
540
541%% Prefer live Gleam registry; fall back if the module is not loaded yet.
542builtin_command_names() ->
543 try
544 Names = 'gleshell@builtins':names(),
545 [to_charlist(N) || N <- Names]
546 catch
547 _:_ ->
548 fallback_builtin_names()
549 end.
550
551to_charlist(B) when is_binary(B) ->
552 unicode:characters_to_list(B);
553to_charlist(L) when is_list(L) ->
554 L.
555
556fallback_builtin_names() ->
557 [
558 "append", "cat", "cd", "columns", "count", "describe", "echo", "env",
559 "exit", "filter", "find", "first", "flatten", "from", "get", "help",
560 "identity", "ignore", "is-empty", "is_empty", "keys", "last", "length",
561 "lines", "ls", "open", "prepend", "print", "pwd", "quit", "range",
562 "reverse", "save", "select", "skip", "sort-by", "sort_by", "sys",
563 "table", "take", "to", "type", "typeof", "uniq", "unwrap",
564 "values", "where", "which", "wrap"
565 ].
566
567%% Executable basenames on PATH that match Prefix (deduped, sorted).
568path_command_completions(Prefix) ->
569 case os:getenv("PATH") of
570 false ->
571 [];
572 PathStr ->
573 Dirs = string:tokens(PathStr, path_sep()),
574 Acc = lists:foldl(
575 fun(Dir, Seen) ->
576 collect_path_cmds(Dir, Prefix, Seen)
577 end,
578 #{},
579 Dirs
580 ),
581 lists:sort(maps:keys(Acc))
582 end.
583
584collect_path_cmds(Dir, Prefix, Seen) ->
585 case file:list_dir(Dir) of
586 {ok, Names} ->
587 lists:foldl(
588 fun(Name, Acc) ->
589 case
590 lists:prefix(Prefix, Name)
591 andalso show_dotfile(Prefix, Name)
592 andalso not maps:is_key(Name, Acc)
593 andalso is_executable_file(filename:join(Dir, Name))
594 of
595 true ->
596 Acc#{Name => true};
597 false ->
598 Acc
599 end
600 end,
601 Seen,
602 Names
603 );
604 {error, _} ->
605 Seen
606 end.
607
608beep() ->
609 io:put_chars([7]).
610
611%% Left is graphemes before the cursor in reverse order.
612%% Returns {PrefixRev, WordForward} where Word is the path token.
613word_before_cursor(Left) ->
614 take_completion_word(Left, []).
615
616%% Acc: walking Left (reversed buffer) with [C|Acc] rebuilds the word forward.
617take_completion_word([], Acc) ->
618 {[], Acc};
619take_completion_word([C | Rest], Acc) ->
620 case is_completion_break(C) of
621 true ->
622 {[C | Rest], Acc};
623 false ->
624 take_completion_word(Rest, [C | Acc])
625 end.
626
627is_completion_break(C) when C =:= $\s; C =:= $\t ->
628 true;
629is_completion_break(C) when C =:= $|; C =:= $;; C =:= $& ->
630 true;
631is_completion_break(C) when C =:= $(; C =:= $); C =:= $[; C =:= $] ->
632 true;
633is_completion_break(C) when C =:= ${; C =:= $}; C =:= $<; C =:= $> ->
634 true;
635is_completion_break(C) when C =:= $'; C =:= $" ->
636 true;
637is_completion_break(_) ->
638 false.
639
640apply_completed_word(PrefixRev, Word) ->
641 lists:reverse(Word) ++ PrefixRev.
642
643%% Return sorted completion strings (as typed, with ~ preserved; dirs end in /).
644filename_completions(Word) ->
645 {ListDirTyped, Base, InsertPrefix} = split_completion_word(Word),
646 ListDir = expand_home_path(ListDirTyped),
647 case file:list_dir(ListDir) of
648 {ok, Names0} ->
649 Names = lists:sort(Names0),
650 [
651 InsertPrefix ++ maybe_dir_slash(ListDir, Name)
652 || Name <- Names,
653 lists:prefix(Base, Name),
654 show_dotfile(Base, Name)
655 ];
656 {error, _} ->
657 []
658 end.
659
660%% Hide dotfiles unless the partial name already starts with '.'.
661show_dotfile([$. | _], _) ->
662 true;
663show_dotfile(_, [$. | _]) ->
664 false;
665show_dotfile(_, _) ->
666 true.
667
668maybe_dir_slash(ListDir, Name) ->
669 case filelib:is_dir(filename:join(ListDir, Name)) of
670 true -> Name ++ "/";
671 false -> Name
672 end.
673
674%% Split a path word into {dir_to_list, basename_prefix, insert_prefix}.
675%% insert_prefix is the directory part as the user typed it (incl. trailing /).
676split_completion_word(Word) ->
677 case rsplit_path(Word) of
678 {none, Base} ->
679 {".", Base, ""};
680 {Dir, Base} ->
681 ListDir =
682 case Dir of
683 "" -> "/";
684 _ -> Dir
685 end,
686 InsertPrefix =
687 case Dir of
688 "" -> "/";
689 _ -> Dir ++ "/"
690 end,
691 {ListDir, Base, InsertPrefix}
692 end.
693
694%% Rightmost / splits directory from the partial basename.
695rsplit_path(Word) ->
696 rsplit_path(lists:reverse(Word), []).
697
698rsplit_path([], Acc) ->
699 {none, Acc};
700rsplit_path([$/ | Rest], Acc) ->
701 {lists:reverse(Rest), Acc};
702rsplit_path([C | Rest], Acc) ->
703 rsplit_path(Rest, [C | Acc]).
704
705expand_home_path(Path) ->
706 case Path of
707 "~" ->
708 home_path_string();
709 [$~, $/ | More] ->
710 home_path_string() ++ "/" ++ More;
711 _ ->
712 Path
713 end.
714
715home_path_string() ->
716 case os:getenv("HOME") of
717 false -> ".";
718 Home when is_list(Home) -> Home;
719 Home when is_binary(Home) -> unicode:characters_to_list(Home)
720 end.
721
722longest_common_prefix([]) ->
723 "";
724longest_common_prefix([H | T]) ->
725 lists:foldl(fun lcp2/2, H, T).
726
727lcp2(A, B) ->
728 lcp2(A, B, []).
729
730lcp2([X | As], [X | Bs], Acc) ->
731 lcp2(As, Bs, [X | Acc]);
732lcp2(_, _, Acc) ->
733 lists:reverse(Acc).
734
735show_completions(Matches) ->
736 io:put_chars("\r\n"),
737 case io:columns() of
738 {ok, Cols} when is_integer(Cols), Cols > 8 ->
739 print_completion_columns(Matches, Cols);
740 _ ->
741 io:put_chars(lists:join(" ", Matches)),
742 io:put_chars("\r\n")
743 end.
744
745print_completion_columns(Matches, Cols) ->
746 MaxLen = lists:max([0 | [length(M) || M <- Matches]]),
747 Width = MaxLen + 2,
748 PerRow = max(1, Cols div Width),
749 print_rows(Matches, PerRow, Width).
750
751print_rows([], _PerRow, _Width) ->
752 ok;
753print_rows(Matches, PerRow, Width) ->
754 {Row, Rest} = take_n(Matches, PerRow, []),
755 Line = [
756 pad_cell(M, Width)
757 || M <- Row
758 ],
759 io:put_chars([Line, "\r\n"]),
760 print_rows(Rest, PerRow, Width).
761
762take_n(List, 0, Acc) ->
763 {lists:reverse(Acc), List};
764take_n([], _N, Acc) ->
765 {lists:reverse(Acc), []};
766take_n([H | T], N, Acc) ->
767 take_n(T, N - 1, [H | Acc]).
768
769pad_cell(S, Width) ->
770 Pad = Width - length(S),
771 case Pad > 0 of
772 true -> S ++ lists:duplicate(Pad, $\s);
773 false -> S ++ " "
774 end.
775
776hist_nav(Prompt, Left, Right, History, HistPos, Saved, Delta) ->
777 NewPos = HistPos + Delta,
778 Len = length(History),
779 if
780 NewPos < 0 ->
781 raw_loop(Prompt, Left, Right, History, HistPos, Saved);
782 NewPos =:= 0 ->
783 %% Restore saved draft
784 {L, R} = bin_to_buffer(Saved),
785 redraw(Prompt, L, R),
786 raw_loop(Prompt, L, R, History, 0, <<>>);
787 NewPos > Len ->
788 raw_loop(Prompt, Left, Right, History, HistPos, Saved);
789 true ->
790 NewSaved = case HistPos of
791 0 -> buffer_to_bin(Left, Right);
792 _ -> Saved
793 end,
794 Entry = lists:nth(NewPos, History),
795 {L, R} = bin_to_buffer(Entry),
796 redraw(Prompt, L, R),
797 raw_loop(Prompt, L, R, History, NewPos, NewSaved)
798 end.
799
800%% Minimal Ctrl+R reverse-i-search over history.
801reverse_search(Prompt, History) ->
802 reverse_search_loop(Prompt, History, [], match_history(History, [])).
803
804reverse_search_loop(Prompt, History, Query, Match) ->
805 Hint = unicode:characters_to_list(
806 ["(reverse-i-search)`", Query, "': ", Match]
807 ),
808 io:put_chars([$\r, Hint, ?CSI_CLEAR_EOL]),
809 case read_key() of
810 eof ->
811 io:put_chars("\r\n"),
812 {error, <<"eof">>};
813 enter ->
814 %% Accept match onto the edit line; do not submit (edit first).
815 Line = iolist_to_binary(Match),
816 {L, R} = bin_to_buffer(Line),
817 redraw(Prompt, L, R),
818 raw_loop(Prompt, L, R, History, 0, <<>>);
819 ctrl_c ->
820 io:put_chars("\r\n"),
821 redraw(Prompt, [], []),
822 raw_loop(Prompt, [], [], History, 0, <<>>);
823 ctrl_g ->
824 redraw(Prompt, [], []),
825 raw_loop(Prompt, [], [], History, 0, <<>>);
826 ctrl_r ->
827 %% Find older match
828 NewMatch = match_history_after(History, Query, Match),
829 reverse_search_loop(Prompt, History, Query, NewMatch);
830 backspace ->
831 NewQuery = case Query of
832 [] -> [];
833 [_ | _] -> lists:droplast(Query)
834 end,
835 reverse_search_loop(
836 Prompt, History, NewQuery, match_history(History, NewQuery)
837 );
838 {char, C} when is_integer(C), C >= 32, C =/= 127 ->
839 NewQuery = Query ++ [C],
840 reverse_search_loop(
841 Prompt, History, NewQuery, match_history(History, NewQuery)
842 );
843 _ ->
844 reverse_search_loop(Prompt, History, Query, Match)
845 end.
846
847match_history(_History, []) ->
848 "";
849match_history(History, Query) ->
850 QBin = unicode:characters_to_binary(Query),
851 case first_match(History, QBin) of
852 undefined -> "";
853 Bin -> unicode:characters_to_list(Bin)
854 end.
855
856match_history_after(History, Query, CurrentMatch) ->
857 QBin = unicode:characters_to_binary(Query),
858 CurBin = unicode:characters_to_binary(CurrentMatch),
859 case skip_until_then_match(History, CurBin, QBin, false) of
860 undefined -> CurrentMatch;
861 Bin -> unicode:characters_to_list(Bin)
862 end.
863
864first_match([], _) ->
865 undefined;
866first_match([H | T], Q) ->
867 case binary:match(H, Q) of
868 nomatch -> first_match(T, Q);
869 _ -> H
870 end.
871
872skip_until_then_match([], _Cur, _Q, _Seen) ->
873 undefined;
874skip_until_then_match([H | T], Cur, Q, false) ->
875 case H =:= Cur of
876 true -> skip_until_then_match(T, Cur, Q, true);
877 false -> skip_until_then_match(T, Cur, Q, false)
878 end;
879skip_until_then_match([H | T], Cur, Q, true) ->
880 case binary:match(H, Q) of
881 nomatch -> skip_until_then_match(T, Cur, Q, true);
882 _ -> H
883 end.
884
885kill_word([]) ->
886 {[], []};
887kill_word(Left) ->
888 %% Left is reversed: strip trailing spaces then a word.
889 L1 = drop_while_space(Left),
890 drop_while_word(L1).
891
892drop_while_space([C | Rest]) when C =:= $\s; C =:= $\t ->
893 drop_while_space(Rest);
894drop_while_space(L) ->
895 L.
896
897drop_while_word([]) ->
898 {[], []};
899drop_while_word([C | Rest]) when C =:= $\s; C =:= $\t ->
900 {[C | Rest], []};
901drop_while_word([_ | Rest]) ->
902 drop_while_word(Rest).
903
904redraw(Prompt, Left, Right) ->
905 Full = lists:reverse(Left) ++ Right,
906 FullBin = unicode:characters_to_binary(Full),
907 Colored = highlight_line(FullBin),
908 io:put_chars([$\r, Prompt, Colored, ?CSI_CLEAR_EOL]),
909 case length(Right) of
910 0 ->
911 ok;
912 N ->
913 io:put_chars(["\e[", integer_to_list(N), $D])
914 end.
915
916highlight_line(Bin) when is_binary(Bin) ->
917 case get(gleshell_color) of
918 false ->
919 Bin;
920 _ ->
921 try
922 case 'gleshell@highlight':line(Bin) of
923 Out when is_binary(Out) -> Out;
924 Out when is_list(Out) -> unicode:characters_to_binary(Out);
925 _ -> Bin
926 end
927 catch
928 _:_ ->
929 Bin
930 end
931 end.
932
933buffer_to_bin(Left, Right) ->
934 unicode:characters_to_binary(lists:reverse(Left) ++ Right).
935
936bin_to_buffer(Bin) when is_binary(Bin) ->
937 Chars = unicode:characters_to_list(Bin),
938 {lists:reverse(Chars), []};
939bin_to_buffer(List) when is_list(List) ->
940 {lists:reverse(List), []}.
941
942%% ---------------------------------------------------------------------------
943%% Key reading (raw mode — keys arrive as soon as pressed)
944%% ---------------------------------------------------------------------------
945
946read_key() ->
947 case io:get_chars("", 1) of
948 eof ->
949 eof;
950 {error, Reason} ->
951 {error, Reason};
952 <<C/utf8>> ->
953 decode_key(C, <<>>);
954 [C] when is_integer(C) ->
955 decode_key(C, <<>>);
956 Bin when is_binary(Bin), byte_size(Bin) > 0 ->
957 case unicode:characters_to_list(Bin) of
958 [C | _] -> decode_key(C, <<>>);
959 _ -> read_key()
960 end;
961 List when is_list(List), List =/= [] ->
962 decode_key(hd(List), <<>>);
963 _ ->
964 read_key()
965 end.
966
967decode_key($\r, _) -> enter;
968decode_key($\n, _) -> enter;
969decode_key($\t, _) -> tab;
970decode_key(127, _) -> backspace;
971decode_key($\b, _) -> backspace;
972decode_key(1, _) -> ctrl_a;
973decode_key(5, _) -> ctrl_e;
974decode_key(4, _) -> ctrl_d;
975decode_key(3, _) -> ctrl_c;
976decode_key(11, _) -> ctrl_k;
977decode_key(21, _) -> ctrl_u;
978decode_key(23, _) -> ctrl_w;
979decode_key(12, _) -> ctrl_l;
980decode_key(18, _) -> ctrl_r;
981decode_key(?ESC, _) ->
982 read_escape();
983decode_key(C, _) when is_integer(C), C >= 32 ->
984 {char, C};
985decode_key(_, _) ->
986 other.
987
988read_escape() ->
989 case io:get_chars("", 1) of
990 eof ->
991 other;
992 <<"[">> ->
993 read_csi();
994 <<$O>> ->
995 %% SS3 sequences: OH = home, OF = end, OA/OB/OC/OD arrows
996 case io:get_chars("", 1) of
997 <<"A">> -> up;
998 <<"B">> -> down;
999 <<"C">> -> right;
1000 <<"D">> -> left;
1001 <<"H">> -> home;
1002 <<"F">> -> 'end';
1003 _ -> other
1004 end;
1005 _ ->
1006 other
1007 end.
1008
1009read_csi() ->
1010 read_csi_params([]).
1011
1012read_csi_params(Acc) ->
1013 case io:get_chars("", 1) of
1014 eof ->
1015 other;
1016 <<C/utf8>> when C >= $0, C =< $9 ->
1017 read_csi_params([C | Acc]);
1018 <<$;>> ->
1019 read_csi_params([$; | Acc]);
1020 <<$~>> ->
1021 Params = lists:reverse(Acc),
1022 case Params of
1023 "1" -> home;
1024 "3" -> delete;
1025 "4" -> 'end';
1026 "7" -> home;
1027 "8" -> 'end';
1028 _ -> other
1029 end;
1030 <<"A">> ->
1031 up;
1032 <<"B">> ->
1033 down;
1034 <<"C">> ->
1035 right;
1036 <<"D">> ->
1037 left;
1038 <<"H">> ->
1039 home;
1040 <<"F">> ->
1041 'end';
1042 _ ->
1043 other
1044 end.
1045
1046%% ---------------------------------------------------------------------------
1047%% History persistence
1048%% ---------------------------------------------------------------------------
1049
1050history_file() ->
1051 case application:get_env(kernel, shell_history_path) of
1052 {ok, Path} when is_list(Path) ->
1053 filename:join(Path, "lines");
1054 {ok, Path} when is_binary(Path) ->
1055 filename:join(unicode:characters_to_list(Path), "lines");
1056 _ ->
1057 filename:join(
1058 filename:basedir(user_cache, "gleshell-history"), "lines"
1059 )
1060 end.
1061
1062load_line_history() ->
1063 File = history_file(),
1064 case file:read_file(File) of
1065 {ok, Bin} ->
1066 Lines = [
1067 L
1068 || L <- binary:split(Bin, <<"\n">>, [global]),
1069 L =/= <<>>
1070 ],
1071 %% Newest first
1072 put(gleshell_history, lists:reverse(Lines));
1073 _ ->
1074 put(gleshell_history, [])
1075 end,
1076 put(gleshell_color, true),
1077 ok.
1078
1079save_line_history() ->
1080 case get(gleshell_history) of
1081 Hist when is_list(Hist) ->
1082 File = history_file(),
1083 _ = filelib:ensure_dir(File),
1084 %% Store oldest-first for human readability
1085 Body = [[L, $\n] || L <- lists:reverse(lists:sublist(Hist, ?HISTORY_MAX))],
1086 _ = file:write_file(File, Body),
1087 ok;
1088 _ ->
1089 ok
1090 end.
1091
1092push_history(<<>>) ->
1093 ok;
1094push_history(Line) when is_binary(Line) ->
1095 Hist = case get(gleshell_history) of
1096 L when is_list(L) -> L;
1097 _ -> []
1098 end,
1099 New = case Hist of
1100 [Line | _] -> Hist;
1101 _ -> [Line | Hist]
1102 end,
1103 put(gleshell_history, lists:sublist(New, ?HISTORY_MAX)),
1104 ok.
1105
1106%% ---------------------------------------------------------------------------
1107%% OS / process helpers
1108%% ---------------------------------------------------------------------------
1109
1110-spec set_cwd(binary()) -> {ok, nil} | {error, binary()}.
1111set_cwd(Path) when is_binary(Path) ->
1112 case file:set_cwd(unicode:characters_to_list(Path)) of
1113 ok ->
1114 {ok, nil};
1115 {error, Reason} ->
1116 {error, reason_to_bin(Reason)}
1117 end.
1118
1119-spec get_cwd() -> {ok, binary()} | {error, binary()}.
1120get_cwd() ->
1121 case file:get_cwd() of
1122 {ok, Dir} ->
1123 {ok, unicode:characters_to_binary(Dir)};
1124 {error, Reason} ->
1125 {error, reason_to_bin(Reason)}
1126 end.
1127
1128-spec getenv(binary()) -> {ok, binary()} | {error, nil}.
1129getenv(Name) when is_binary(Name) ->
1130 case os:getenv(unicode:characters_to_list(Name)) of
1131 false ->
1132 {error, nil};
1133 Value ->
1134 {ok, unicode:characters_to_binary(Value)}
1135 end.
1136
1137-spec setenv(binary(), binary()) -> {ok, nil}.
1138setenv(Name, Value) when is_binary(Name), is_binary(Value) ->
1139 os:putenv(unicode:characters_to_list(Name), unicode:characters_to_list(Value)),
1140 {ok, nil}.
1141
1142%% All process environment variables as a list of {Name, Value} binaries.
1143-spec list_env() -> list({binary(), binary()}).
1144list_env() ->
1145 lists:map(
1146 fun(Entry) ->
1147 case string:split(Entry, "=", leading) of
1148 [K, V] ->
1149 {unicode:characters_to_binary(K), unicode:characters_to_binary(V)};
1150 [K] ->
1151 {unicode:characters_to_binary(K), <<>>};
1152 _ ->
1153 {<<>>, <<>>}
1154 end
1155 end,
1156 os:getenv()
1157 ).
1158
1159%% Substring/regex search helper for the `find` builtin.
1160%% Returns {ok, true|false} or {error, Message} on invalid pattern.
1161-spec re_contains(binary(), binary(), boolean()) -> {ok, boolean()} | {error, binary()}.
1162re_contains(Text, Pattern, IgnoreCase)
1163 when is_binary(Text), is_binary(Pattern), is_boolean(IgnoreCase) ->
1164 Opts0 = [unicode],
1165 Opts =
1166 case IgnoreCase of
1167 true ->
1168 [caseless | Opts0];
1169 false ->
1170 Opts0
1171 end,
1172 case re:compile(Pattern, Opts) of
1173 {ok, Re} ->
1174 case re:run(Text, Re, [{capture, none}]) of
1175 match ->
1176 {ok, true};
1177 nomatch ->
1178 {ok, false}
1179 end;
1180 {error, {Reason, _}} ->
1181 {error, iolist_to_binary(io_lib:format("~p", [Reason]))};
1182 {error, Reason} ->
1183 {error, iolist_to_binary(io_lib:format("~p", [Reason]))}
1184 end.
1185
1186-spec which(binary()) -> {ok, binary()} | {error, nil}.
1187which(Command) when is_binary(Command) ->
1188 case which_all(Command) of
1189 [Path | _] ->
1190 {ok, Path};
1191 [] ->
1192 {error, nil}
1193 end.
1194
1195%% All matching executables on PATH (or the path itself if absolute/relative).
1196%% Order matches PATH search; duplicates from the same resolved path are dropped.
1197-spec which_all(binary()) -> [binary()].
1198which_all(Command) when is_binary(Command) ->
1199 Cmd = unicode:characters_to_list(Command),
1200 case Cmd of
1201 [] ->
1202 [];
1203 _ ->
1204 case has_path_sep(Cmd) of
1205 true ->
1206 case is_executable_file(Cmd) of
1207 true ->
1208 [unicode:characters_to_binary(filename:absname(Cmd))];
1209 false ->
1210 []
1211 end;
1212 false ->
1213 case os:getenv("PATH") of
1214 false ->
1215 [];
1216 PathStr ->
1217 Dirs = string:tokens(PathStr, path_sep()),
1218 find_all_in_path(Cmd, Dirs, #{}, [])
1219 end
1220 end
1221 end.
1222
1223path_sep() ->
1224 case os:type() of
1225 {win32, _} -> ";";
1226 _ -> ":"
1227 end.
1228
1229has_path_sep(Cmd) ->
1230 lists:member($/, Cmd) orelse lists:member($\\, Cmd).
1231
1232find_all_in_path(_Cmd, [], _Seen, Acc) ->
1233 lists:reverse(Acc);
1234find_all_in_path(Cmd, [Dir | Rest], Seen, Acc) ->
1235 File = filename:join(Dir, Cmd),
1236 case is_executable_file(File) of
1237 true ->
1238 Abs = filename:absname(File),
1239 Bin = unicode:characters_to_binary(Abs),
1240 case maps:is_key(Abs, Seen) of
1241 true ->
1242 find_all_in_path(Cmd, Rest, Seen, Acc);
1243 false ->
1244 find_all_in_path(Cmd, Rest, Seen#{Abs => true}, [Bin | Acc])
1245 end;
1246 false ->
1247 find_all_in_path(Cmd, Rest, Seen, Acc)
1248 end.
1249
1250is_executable_file(Path) ->
1251 case file:read_file_info(Path) of
1252 {ok, #file_info{type = regular, mode = Mode}} ->
1253 %% Any execute bit (owner/group/other).
1254 (Mode band 8#111) =/= 0;
1255 _ ->
1256 false
1257 end.
1258
1259-spec home_dir() -> {ok, binary()} | {error, binary()}.
1260home_dir() ->
1261 case os:getenv("HOME") of
1262 false ->
1263 {error, <<"HOME not set">>};
1264 Home ->
1265 {ok, unicode:characters_to_binary(Home)}
1266 end.
1267
1268-spec stdout_isatty() -> boolean().
1269stdout_isatty() ->
1270 case io:columns() of
1271 {ok, _} ->
1272 true;
1273 _ ->
1274 try
1275 case prim_tty:isatty(stdout) of
1276 true -> true;
1277 _ -> false
1278 end
1279 catch
1280 _:_ ->
1281 false
1282 end
1283 end.
1284
1285%% ---------------------------------------------------------------------------
1286%% External commands
1287%%
1288%% Two modes:
1289%%
1290%% 1. `run_cmd/2` — capture stdout/stderr into a binary (pipelines, `let x =`,
1291%% non-TTY). Uses pipes; the child does NOT get a real terminal.
1292%%
1293%% 2. `run_cmd_tty/2` — foreground interactive. Prefer util-linux `script`
1294%% (PTY + key relay via `io:get_chars`) so Ctrl+C can SIGINT the child.
1295%% `erl_child_setup` calls setsid, so the child is never in the terminal's
1296%% foreground process group — kernel SIGINT goes to BEAM, not the child.
1297%% Fallback: inherit real stdio (`nouse_stdio`) when script/TTY is missing.
1298%%
1299%% Auth tools (`sudo`, `run0`, …) also need a controlling TTY; the PTY path
1300%% covers that. Host termios during children: cooked for OPOST/ONLCR (no
1301%% staircase) but ISIG off so Ctrl+C is readable as byte 3 instead of opening
1302%% the Erlang BREAK menu.
1303%% ---------------------------------------------------------------------------
1304
1305-spec run_cmd(binary(), [binary()], binary()) ->
1306 {ok, {integer(), binary()}} | {error, binary()}.
1307run_cmd(Command, Args, Stdin) when is_binary(Command), is_list(Args), is_binary(Stdin) ->
1308 case resolve_cmd(Command, Args) of
1309 {error, _} = E ->
1310 E;
1311 {ok, Path, PortArgs} ->
1312 try
1313 run_cmd_capture(Path, PortArgs, Stdin)
1314 catch
1315 _:Reason ->
1316 {error, reason_to_bin(Reason)}
1317 end
1318 end.
1319
1320%% Foreground interactive: inherit TTY when possible.
1321%% Non-empty Stdin is still fed (temp file + redirect) so `cat f | less` works.
1322%%
1323%% While the REPL uses OTP `{noshell, raw}`, prim_tty leaves termios with
1324%% OPOST/ONLCR off so a bare LF does not return the cursor to column 0.
1325%% Tools that write LF-only lines (fastfetch, many TUIs) look staircased if
1326%% they inherit that TTY. Wrap inherit/PTY runs in cooked mode and restore
1327%% raw afterwards (same idea as println/1 converting to CRLF for shell text).
1328-spec run_cmd_tty(binary(), [binary()], binary()) ->
1329 {ok, {integer(), binary()}} | {error, binary()}.
1330run_cmd_tty(Command, Args, Stdin) when is_binary(Command), is_list(Args), is_binary(Stdin) ->
1331 case resolve_cmd(Command, Args) of
1332 {error, _} = E ->
1333 E;
1334 {ok, Path, PortArgs} ->
1335 try
1336 case stdout_isatty() of
1337 false ->
1338 run_cmd_capture(Path, PortArgs, Stdin);
1339 true ->
1340 with_cooked_tty(fun() ->
1341 %% PTY for all interactive when possible: key relay
1342 %% sees Ctrl+C and can SIGINT the child process group.
1343 case {os:find_executable("script"), find_tty_path()} of
1344 {Script, {ok, Tty}} when is_list(Script) ->
1345 run_cmd_pty(Script, Path, PortArgs, Tty, Stdin);
1346 _ ->
1347 run_cmd_inherit(Path, PortArgs, Stdin)
1348 end
1349 end)
1350 end
1351 catch
1352 _:Reason ->
1353 {error, reason_to_bin(Reason)}
1354 end
1355 end.
1356
1357%% Temporarily put the controlling TTY into cooked output + non-canonical
1358%% input for an external child, then restore previous termios (raw REPL).
1359%%
1360%% Applied whenever stdout is a TTY (not only raw REPL): -c under a terminal
1361%% still needs -isig/-icanon so Ctrl+C is a readable byte for the interrupt
1362%% path. No-op when stty/TTY is unavailable.
1363with_cooked_tty(Fun) when is_function(Fun, 0) ->
1364 case stdout_isatty() of
1365 false ->
1366 Fun();
1367 true ->
1368 case stty_save() of
1369 undefined ->
1370 %% Still try to apply host flags; restore is best-effort.
1371 stty_sane(),
1372 try
1373 Fun()
1374 after
1375 ok
1376 end;
1377 Saved ->
1378 stty_sane(),
1379 try
1380 Fun()
1381 after
1382 stty_restore(Saved)
1383 end
1384 end
1385 end.
1386
1387stty_save() ->
1388 case stty_run(["-g"]) of
1389 {ok, Out} ->
1390 case string:trim(Out, both, [$\s, $\t, $\n, $\r]) of
1391 "" ->
1392 undefined;
1393 Settings ->
1394 %% stty -g is a single token of colon-separated hex flags.
1395 Settings
1396 end;
1397 _ ->
1398 undefined
1399 end.
1400
1401stty_sane() ->
1402 %% Host TTY while an external runs under the raw REPL:
1403 %% - sane / opost / onlcr: LF→CRLF so children don't staircase
1404 %% - -isig: Ctrl+C is byte 3 (not kernel SIGINT → BEAM BREAK menu)
1405 %% - -icanon min 1 time 0: deliver each byte immediately — with ICANON
1406 %% left on, Ctrl+C sits in the line buffer until Enter and our key
1407 %% relay never sees it (external freezes; second ^C looks wedged)
1408 %% - -echo: host must not echo keys we relay into the child PTY
1409 _ = stty_run([
1410 "sane",
1411 "-isig",
1412 "-icanon",
1413 "min",
1414 "1",
1415 "time",
1416 "0",
1417 "-echo"
1418 ]),
1419 ok.
1420
1421stty_restore(Settings) when is_list(Settings) ->
1422 _ = stty_run([Settings]),
1423 ok;
1424stty_restore(_) ->
1425 ok.
1426
1427%% Run stty against the real terminal device (not a pipe). Prefer the pts
1428%% path from /proc (same as sudo/PTY path), then `/dev/tty`.
1429%%
1430%% NOTE: do not use filelib:is_file/1 for `/dev/tty` — it is a device node,
1431%% so is_file returns false and would skip stty entirely (fastfetch staircase).
1432stty_run(Args) when is_list(Args) ->
1433 case os:find_executable("stty") of
1434 false ->
1435 {error, no_stty};
1436 Stty when is_list(Stty) ->
1437 stty_run_on(Stty, Args, stty_devices())
1438 end.
1439
1440stty_devices() ->
1441 case find_tty_path() of
1442 {ok, Path} ->
1443 %% Prefer the concrete pts; /dev/tty is a fallback alias.
1444 [Path, "/dev/tty"];
1445 _ ->
1446 ["/dev/tty"]
1447 end.
1448
1449stty_run_on(_Stty, _Args, []) ->
1450 {error, no_tty};
1451stty_run_on(Stty, Args, [Dev | Rest]) ->
1452 case stty_on_device(Stty, Dev, Args) of
1453 {ok, _} = Ok ->
1454 Ok;
1455 _ ->
1456 stty_run_on(Stty, Args, Rest)
1457 end.
1458
1459stty_on_device(Stty, Dev, Args) when is_list(Stty), is_list(Dev), is_list(Args) ->
1460 try
1461 Port = open_port(
1462 {spawn_executable, Stty},
1463 [
1464 binary,
1465 exit_status,
1466 use_stdio,
1467 stderr_to_stdout,
1468 {args, ["-F", Dev | Args]}
1469 ]
1470 ),
1471 %% No interrupt watch — internal helper, must not steal TTY input.
1472 case collect_output_quiet(Port, <<>>, 5000) of
1473 {ok, {0, Bin}} ->
1474 {ok, unicode:characters_to_list(Bin)};
1475 {ok, {Status, Bin}} ->
1476 {error, {Status, Bin}};
1477 {error, _} = E ->
1478 E
1479 end
1480 catch
1481 _:_ ->
1482 {error, stty_failed}
1483 end.
1484
1485resolve_cmd(Command, Args) ->
1486 case os:find_executable(unicode:characters_to_list(Command)) of
1487 false ->
1488 {error, <<"command not found: ", Command/binary>>};
1489 Path ->
1490 PortArgs = [unicode:characters_to_list(A) || A <- Args],
1491 {ok, Path, PortArgs}
1492 end.
1493
1494%% Capture mode: pipes, no TTY. `child_env` forces color when the shell wants
1495%% it so tools like `jj` still embed ANSI we can pass through on display.
1496%%
1497%% Stdin is always redirected via `sh -c` + `$GLESHELL_STDIN` (either a temp
1498%% file with pipeline bytes, or `/dev/null`) so programs never hang on an
1499%% open-but-never-written Erlang port pipe.
1500run_cmd_capture(Path, PortArgs, Stdin) when is_binary(Stdin) ->
1501 with_stdin_file(Stdin, fun(StdinPath) ->
1502 sh_exec(Path, PortArgs, StdinPath, capture)
1503 end).
1504
1505%% Inherit real stdio — pagers/editors talk to the terminal directly.
1506%% LESS=FRX (via child_env) lets less pass ANSI colors from jj/git.
1507%%
1508%% Empty stdin: pure inherit (bare `less` reads the TTY).
1509%% Non-empty stdin: still inherit stdout/stderr TTY, but redirect stdin from
1510%% a temp file so `cat file | less` pages the pipeline data.
1511%%
1512%% Ctrl+C: prefer the PTY path (key relay). Inherit is a fallback when
1513%% `script` is missing — host is -isig/-icanon so Ctrl+C is byte 3; a
1514%% watcher SIGINTs the child's process group (setsid means kernel SIGINT
1515%% never reaches the child even with ISIG on).
1516run_cmd_inherit(Path, PortArgs, Stdin) when is_binary(Stdin) ->
1517 case Stdin of
1518 <<>> ->
1519 Port = open_port(
1520 {spawn_executable, Path},
1521 [
1522 exit_status,
1523 nouse_stdio,
1524 {env, child_env()},
1525 {args, PortArgs}
1526 ]
1527 ),
1528 put(gleshell_output_shown, true),
1529 await_port_exit_interruptible(Port);
1530 _ ->
1531 with_stdin_file(Stdin, fun(StdinPath) ->
1532 sh_exec(Path, PortArgs, StdinPath, inherit)
1533 end)
1534 end.
1535
1536%% PTY + key relay (interactive TTY, sudo/run0, …).
1537%%
1538%% util-linux `script` does NOT exec the argv after `--` directly. It runs
1539%% `$SHELL -c "<joined args>"` (see script(1)). Nested
1540%% `sh -c 'exec "$0" …' path` therefore becomes one mangled shell string and
1541%% the real binary never runs (fastfetch → empty output, exit 0).
1542%%
1543%% Empty stdin: pass Path/args through as simple tokens (`script -- cmd args`).
1544%% Non-empty stdin (pipeline → less): write a one-shot runner script that
1545%% redirects and execs, then `script -- /tmp/runner` (single path token).
1546run_cmd_pty(Script, Path, PortArgs, TtyPath, <<>>) ->
1547 run_cmd_pty_argv(Script, [Path | PortArgs], TtyPath, Path, PortArgs, <<>>);
1548run_cmd_pty(Script, Path, PortArgs, TtyPath, Stdin) when is_binary(Stdin) ->
1549 with_stdin_file(Stdin, fun(StdinPath) ->
1550 with_exec_runner(Path, PortArgs, StdinPath, fun(Runner) ->
1551 run_cmd_pty_argv(Script, [Runner], TtyPath, Path, PortArgs, Stdin)
1552 end)
1553 end).
1554
1555run_cmd_pty_argv(Script, Argv, TtyPath, Path, PortArgs, Stdin) when is_list(Argv) ->
1556 with_trapped_exits(fun() ->
1557 Port = open_port(
1558 {spawn_executable, Script},
1559 [
1560 binary,
1561 exit_status,
1562 stderr_to_stdout,
1563 use_stdio,
1564 stream,
1565 {env, child_env()},
1566 {args, ["-q", "-e", "/dev/null", "--" | Argv]}
1567 ]
1568 ),
1569 case file:open(TtyPath, [write, raw, binary]) of
1570 {ok, TtyOut} ->
1571 GL = group_leader(),
1572 Reader = spawn(fun() ->
1573 group_leader(GL, self()),
1574 io_to_port(Port)
1575 end),
1576 put(gleshell_output_shown, true),
1577 try
1578 collect_output_relay(Port, TtyOut, <<>>)
1579 after
1580 exit(Reader, kill),
1581 catch file:close(TtyOut),
1582 catch port_close(Port)
1583 end;
1584 {error, _} ->
1585 catch port_close(Port),
1586 put(gleshell_output_shown, false),
1587 run_cmd_inherit(Path, PortArgs, Stdin)
1588 end
1589 end).
1590
1591%% One-shot `#!/bin/sh` runner: exec Path with PortArgs, stdin from StdinPath.
1592%% Needed because `script` flattens argv into `$SHELL -c` (no real multi-arg exec).
1593with_exec_runner(Path, PortArgs, StdinPath, Fun) when is_function(Fun, 1) ->
1594 case write_runner_script(Path, PortArgs, StdinPath) of
1595 {ok, Runner} ->
1596 try
1597 Fun(Runner)
1598 after
1599 _ = file:delete(Runner)
1600 end;
1601 {error, Reason} ->
1602 {error, reason_to_bin({runner_script, Reason})}
1603 end.
1604
1605write_runner_script(Path, PortArgs, StdinPath) ->
1606 Dir =
1607 case os:getenv("TMPDIR") of
1608 false ->
1609 "/tmp";
1610 "" ->
1611 "/tmp";
1612 D ->
1613 D
1614 end,
1615 Name =
1616 filename:join(
1617 Dir,
1618 "gleshell-run-" ++ integer_to_list(erlang:unique_integer([positive]))
1619 ),
1620 Body = runner_script_body(Path, PortArgs, StdinPath),
1621 case file:write_file(Name, Body) of
1622 ok ->
1623 case file:change_mode(Name, 8#755) of
1624 ok ->
1625 {ok, Name};
1626 {error, _} = E ->
1627 _ = file:delete(Name),
1628 E
1629 end;
1630 {error, _} = E ->
1631 E
1632 end.
1633
1634runner_script_body(Path, PortArgs, StdinPath) ->
1635 ArgsQ = [[$\s, shell_single_quote(A)] || A <- PortArgs],
1636 [
1637 "#!/bin/sh\n",
1638 "exec ",
1639 shell_single_quote(Path),
1640 ArgsQ,
1641 " < ",
1642 shell_single_quote(StdinPath),
1643 "\n"
1644 ].
1645
1646%% Safe single-quoted shell token (`foo'bar` → `'foo'\''bar'`).
1647shell_single_quote(S) when is_list(S) ->
1648 [$' | shell_single_quote_chars(S) ++ "'"];
1649shell_single_quote(B) when is_binary(B) ->
1650 shell_single_quote(unicode:characters_to_list(B)).
1651
1652shell_single_quote_chars([]) ->
1653 [];
1654shell_single_quote_chars([$' | Rest]) ->
1655 "'\\''" ++ shell_single_quote_chars(Rest);
1656shell_single_quote_chars([C | Rest]) ->
1657 [C | shell_single_quote_chars(Rest)].
1658
1659find_sh() ->
1660 case os:find_executable("sh") of
1661 false ->
1662 "/bin/sh";
1663 S ->
1664 S
1665 end.
1666
1667%% Run Path with stdin redirected from StdinPath.
1668%% Mode `capture` uses pipes; `inherit` uses nouse_stdio (real TTY for out/err).
1669sh_exec(Path, PortArgs, StdinPath, capture) ->
1670 Port = open_port(
1671 {spawn_executable, find_sh()},
1672 [
1673 binary,
1674 exit_status,
1675 stderr_to_stdout,
1676 use_stdio,
1677 stream,
1678 {env, [{"GLESHELL_STDIN", StdinPath} | child_env()]},
1679 {args, ["-c", "exec \"$0\" \"$@\" < \"$GLESHELL_STDIN\"", Path | PortArgs]}
1680 ]
1681 ),
1682 put(gleshell_output_shown, false),
1683 collect_output(Port, <<>>);
1684sh_exec(Path, PortArgs, StdinPath, inherit) ->
1685 Port = open_port(
1686 {spawn_executable, find_sh()},
1687 [
1688 exit_status,
1689 nouse_stdio,
1690 {env, [{"GLESHELL_STDIN", StdinPath} | child_env()]},
1691 {args, ["-c", "exec \"$0\" \"$@\" < \"$GLESHELL_STDIN\"", Path | PortArgs]}
1692 ]
1693 ),
1694 put(gleshell_output_shown, true),
1695 await_port_exit_interruptible(Port).
1696
1697%% Provide a filesystem path for stdin bytes; clean up temp files afterwards.
1698with_stdin_file(<<>>, Fun) when is_function(Fun, 1) ->
1699 Fun("/dev/null");
1700with_stdin_file(Data, Fun) when is_binary(Data), is_function(Fun, 1) ->
1701 case write_stdin_tmp(Data) of
1702 {ok, Path} ->
1703 try
1704 Fun(Path)
1705 after
1706 _ = file:delete(Path)
1707 end;
1708 {error, Reason} ->
1709 {error, reason_to_bin({stdin_tmp, Reason})}
1710 end.
1711
1712write_stdin_tmp(Data) when is_binary(Data) ->
1713 Dir =
1714 case os:getenv("TMPDIR") of
1715 false ->
1716 "/tmp";
1717 "" ->
1718 "/tmp";
1719 D ->
1720 D
1721 end,
1722 Name =
1723 filename:join(
1724 Dir,
1725 "gleshell-stdin-" ++ integer_to_list(erlang:unique_integer([positive]))
1726 ),
1727 case file:write_file(Name, Data) of
1728 ok ->
1729 {ok, Name};
1730 {error, _} = E ->
1731 E
1732 end.
1733
1734%% Relay keypresses from the group leader to the child's PTY (script stdin).
1735%% Ctrl+C (ETX / byte 3): SIGINT the child process group, and still write the
1736%% byte so the PTY line discipline can deliver SIGINT on the slave side too.
1737io_to_port(Port) ->
1738 case io:get_chars("", 1) of
1739 eof ->
1740 ok;
1741 {error, _} ->
1742 ok;
1743 Data ->
1744 case io_data_to_bin(Data) of
1745 <<>> ->
1746 io_to_port(Port);
1747 <<3>> = Bin ->
1748 signal_port_group(Port, int),
1749 catch port_command(Port, Bin),
1750 io_to_port(Port);
1751 Bin ->
1752 catch port_command(Port, Bin),
1753 io_to_port(Port)
1754 end
1755 end.
1756
1757io_data_to_bin(Bin) when is_binary(Bin) ->
1758 Bin;
1759io_data_to_bin([C]) when is_integer(C), C >= 0, C =< 16#7F ->
1760 <<C>>;
1761io_data_to_bin(List) when is_list(List) ->
1762 case unicode:characters_to_binary(List) of
1763 Bin when is_binary(Bin) ->
1764 Bin;
1765 _ ->
1766 <<>>
1767 end;
1768io_data_to_bin(_) ->
1769 <<>>.
1770
1771%% When a port's OS process is killed (Ctrl+C → SIGINT), the linked port may
1772%% exit with `epipe` / signal reasons. Without trap_exit the shell process
1773%% dies with "Erlang exit: Epipe" instead of returning to the prompt.
1774with_trapped_exits(Fun) when is_function(Fun, 0) ->
1775 Old = process_flag(trap_exit, true),
1776 try
1777 Fun()
1778 after
1779 process_flag(trap_exit, Old),
1780 drain_exit_msgs()
1781 end.
1782
1783drain_exit_msgs() ->
1784 receive
1785 {'EXIT', _, _} ->
1786 drain_exit_msgs()
1787 after 0 ->
1788 ok
1789 end.
1790
1791%% Inherit path: watch for Ctrl+C (byte 3) and SIGINT the child group.
1792%% Used when `script`/PTY is unavailable; same kill path as the PTY relay.
1793await_port_exit_interruptible(Port) ->
1794 with_trapped_exits(fun() ->
1795 with_interrupt_watch(Port, fun() ->
1796 await_port_exit_interruptible_loop(Port)
1797 end)
1798 end).
1799
1800await_port_exit_interruptible_loop(Port) ->
1801 receive
1802 {Port, {exit_status, Status}} ->
1803 {ok, {Status, <<>>}};
1804 {'EXIT', Port, _Reason} ->
1805 {ok, {130, <<>>}};
1806 {gleshell_interrupt, _} ->
1807 signal_port_group(Port, int),
1808 await_port_exit_after_interrupt(Port, 2000)
1809 end.
1810
1811await_port_exit_after_interrupt(Port, GraceMs) ->
1812 receive
1813 {Port, {exit_status, Status}} ->
1814 {ok, {Status, <<>>}};
1815 {'EXIT', Port, _Reason} ->
1816 {ok, {130, <<>>}};
1817 {gleshell_interrupt, _} ->
1818 signal_port_group(Port, kill),
1819 await_port_exit_after_interrupt(Port, 1000)
1820 after GraceMs ->
1821 signal_port_group(Port, kill),
1822 catch port_close(Port),
1823 receive
1824 {Port, {exit_status, Status}} ->
1825 {ok, {Status, <<>>}};
1826 {'EXIT', Port, _} ->
1827 {ok, {130, <<>>}}
1828 after 1000 ->
1829 {ok, {130, <<>>}}
1830 end
1831 end.
1832
1833collect_output(Port, Acc) ->
1834 with_trapped_exits(fun() ->
1835 with_interrupt_watch(Port, fun() ->
1836 collect_output_loop(Port, Acc, 120_000)
1837 end)
1838 end).
1839
1840collect_output_loop(Port, Acc, Timeout) ->
1841 receive
1842 {Port, {data, Data}} when is_binary(Data) ->
1843 collect_output_loop(Port, <<Acc/binary, Data/binary>>, Timeout);
1844 {Port, {data, Data}} when is_list(Data) ->
1845 Bin = unicode:characters_to_binary(Data),
1846 collect_output_loop(Port, <<Acc/binary, Bin/binary>>, Timeout);
1847 {Port, {exit_status, Status}} ->
1848 {ok, {Status, Acc}};
1849 {'EXIT', Port, _Reason} ->
1850 {ok, {130, Acc}};
1851 {gleshell_interrupt, _} ->
1852 signal_port_group(Port, int),
1853 collect_output_after_interrupt(Port, Acc, 2000)
1854 after Timeout ->
1855 signal_port_group(Port, term),
1856 catch port_close(Port),
1857 {error, <<"command timed out after 120s">>}
1858 end.
1859
1860collect_output_after_interrupt(Port, Acc, GraceMs) ->
1861 receive
1862 {Port, {data, Data}} when is_binary(Data) ->
1863 collect_output_after_interrupt(
1864 Port, <<Acc/binary, Data/binary>>, GraceMs
1865 );
1866 {Port, {data, Data}} when is_list(Data) ->
1867 Bin = unicode:characters_to_binary(Data),
1868 collect_output_after_interrupt(
1869 Port, <<Acc/binary, Bin/binary>>, GraceMs
1870 );
1871 {Port, {exit_status, Status}} ->
1872 {ok, {Status, Acc}};
1873 {'EXIT', Port, _Reason} ->
1874 {ok, {130, Acc}};
1875 {gleshell_interrupt, _} ->
1876 signal_port_group(Port, kill),
1877 collect_output_after_interrupt(Port, Acc, 1000)
1878 after GraceMs ->
1879 signal_port_group(Port, kill),
1880 catch port_close(Port),
1881 receive
1882 {Port, {data, Data}} when is_binary(Data) ->
1883 collect_output_after_interrupt(
1884 Port, <<Acc/binary, Data/binary>>, 500
1885 );
1886 {Port, {data, Data}} when is_list(Data) ->
1887 Bin = unicode:characters_to_binary(Data),
1888 collect_output_after_interrupt(
1889 Port, <<Acc/binary, Bin/binary>>, 500
1890 );
1891 {Port, {exit_status, Status}} ->
1892 {ok, {Status, Acc}};
1893 {'EXIT', Port, _} ->
1894 {ok, {130, Acc}}
1895 after 1000 ->
1896 {ok, {130, Acc}}
1897 end
1898 end.
1899
1900%% PTY session: no timeout (password prompts, long pagers, etc.).
1901%% Ctrl+C is handled in io_to_port/1 (SIGINT); also accept interrupt msgs.
1902collect_output_relay(Port, Tty, Acc) ->
1903 receive
1904 {Port, {data, Data}} when is_binary(Data) ->
1905 _ = file:write(Tty, Data),
1906 collect_output_relay(Port, Tty, <<Acc/binary, Data/binary>>);
1907 {Port, {data, Data}} when is_list(Data) ->
1908 Bin = unicode:characters_to_binary(Data),
1909 _ = file:write(Tty, Bin),
1910 collect_output_relay(Port, Tty, <<Acc/binary, Bin/binary>>);
1911 {Port, {exit_status, Status}} ->
1912 {ok, {Status, normalize_pty_output(Acc)}};
1913 {gleshell_interrupt, _} ->
1914 signal_port_group(Port, int),
1915 collect_output_relay_after_interrupt(Port, Tty, Acc, 2000)
1916 end.
1917
1918collect_output_relay_after_interrupt(Port, Tty, Acc, GraceMs) ->
1919 receive
1920 {Port, {data, Data}} when is_binary(Data) ->
1921 _ = file:write(Tty, Data),
1922 collect_output_relay_after_interrupt(
1923 Port, Tty, <<Acc/binary, Data/binary>>, GraceMs
1924 );
1925 {Port, {data, Data}} when is_list(Data) ->
1926 Bin = unicode:characters_to_binary(Data),
1927 _ = file:write(Tty, Bin),
1928 collect_output_relay_after_interrupt(
1929 Port, Tty, <<Acc/binary, Bin/binary>>, GraceMs
1930 );
1931 {Port, {exit_status, Status}} ->
1932 {ok, {Status, normalize_pty_output(Acc)}};
1933 {gleshell_interrupt, _} ->
1934 signal_port_group(Port, kill),
1935 collect_output_relay_after_interrupt(Port, Tty, Acc, 1000)
1936 after GraceMs ->
1937 signal_port_group(Port, kill),
1938 catch port_close(Port),
1939 receive
1940 {Port, {exit_status, Status}} ->
1941 {ok, {Status, normalize_pty_output(Acc)}}
1942 after 1000 ->
1943 {ok, {130, normalize_pty_output(Acc)}}
1944 end
1945 end.
1946
1947%% ---------------------------------------------------------------------------
1948%% Ctrl+C / SIGINT forwarding
1949%%
1950%% BEAM's open_port → erl_child_setup → setsid, so the child is not in the
1951%% terminal foreground group. Host ISIG is left off while a child runs; we
1952%% watch for byte 3 (ETX) and kill(-pid, SIGINT) on the child's process group.
1953%% ---------------------------------------------------------------------------
1954
1955with_interrupt_watch(_Port, Fun) when is_function(Fun, 0) ->
1956 Parent = self(),
1957 GL = group_leader(),
1958 Watcher =
1959 case can_watch_interrupt() of
1960 true ->
1961 spawn(fun() ->
1962 group_leader(GL, self()),
1963 interrupt_watch_loop(Parent)
1964 end);
1965 false ->
1966 undefined
1967 end,
1968 try
1969 Fun()
1970 after
1971 case Watcher of
1972 undefined ->
1973 ok;
1974 W ->
1975 exit(W, kill),
1976 drain_interrupt_msgs()
1977 end
1978 end.
1979
1980%% Collect port output without Ctrl+C watching (stty and other helpers).
1981collect_output_quiet(Port, Acc, Timeout) ->
1982 receive
1983 {Port, {data, Data}} when is_binary(Data) ->
1984 collect_output_quiet(Port, <<Acc/binary, Data/binary>>, Timeout);
1985 {Port, {data, Data}} when is_list(Data) ->
1986 Bin = unicode:characters_to_binary(Data),
1987 collect_output_quiet(Port, <<Acc/binary, Bin/binary>>, Timeout);
1988 {Port, {exit_status, Status}} ->
1989 {ok, {Status, Acc}}
1990 after Timeout ->
1991 catch port_close(Port),
1992 {error, <<"command timed out">>}
1993 end.
1994
1995%% Watch when the REPL owns the TTY (raw mode) or stdin is a terminal.
1996can_watch_interrupt() ->
1997 case get(gleshell_raw) of
1998 true ->
1999 true;
2000 _ ->
2001 stdout_isatty()
2002 end.
2003
2004interrupt_watch_loop(Parent) when is_pid(Parent) ->
2005 case catch io:get_chars("", 1) of
2006 eof ->
2007 ok;
2008 {error, _} ->
2009 ok;
2010 {'EXIT', _} ->
2011 ok;
2012 Data ->
2013 case io_data_to_bin(Data) of
2014 <<3>> ->
2015 Parent ! {gleshell_interrupt, self()},
2016 interrupt_watch_loop(Parent);
2017 _ ->
2018 %% Non-Ctrl+C: discard here (capture mode). Interactive
2019 %% PTY uses io_to_port instead; inherit prefers PTY.
2020 interrupt_watch_loop(Parent)
2021 end
2022 end.
2023
2024drain_interrupt_msgs() ->
2025 receive
2026 {gleshell_interrupt, _} ->
2027 drain_interrupt_msgs()
2028 after 0 ->
2029 ok
2030 end.
2031
2032%% SIGINT/SIGTERM/SIGKILL the port's OS process group (setsid → pgid = pid).
2033signal_port_group(Port, Sig) when is_port(Port) ->
2034 case erlang:port_info(Port, os_pid) of
2035 {os_pid, Pid} when is_integer(Pid), Pid > 0 ->
2036 kill_os_group(Pid, Sig);
2037 _ ->
2038 ok
2039 end.
2040
2041kill_os_group(Pid, Sig) when is_integer(Pid) ->
2042 Kill =
2043 case os:find_executable("kill") of
2044 false ->
2045 "kill";
2046 K ->
2047 K
2048 end,
2049 SigArg =
2050 case Sig of
2051 int ->
2052 "-INT";
2053 term ->
2054 "-TERM";
2055 kill ->
2056 "-KILL"
2057 end,
2058 %% Negative pid → process group (child is session/group leader after setsid).
2059 Pg = "-" ++ integer_to_list(Pid),
2060 Single = integer_to_list(Pid),
2061 _ = kill_once(Kill, [SigArg, Pg]),
2062 _ = kill_once(Kill, [SigArg, Single]),
2063 ok.
2064
2065kill_once(Kill, Args) ->
2066 try
2067 Port = open_port(
2068 {spawn_executable, Kill},
2069 [exit_status, nouse_stdio, {args, Args}]
2070 ),
2071 receive
2072 {Port, {exit_status, _}} ->
2073 ok
2074 after 1000 ->
2075 catch port_close(Port),
2076 ok
2077 end
2078 catch
2079 _:_ ->
2080 ok
2081 end.
2082
2083%% PTY line discipline often emits CR-LF; normalize to LF for structured use.
2084normalize_pty_output(Bin) when is_binary(Bin) ->
2085 binary:replace(Bin, <<"\r\n">>, <<"\n">>, [global]).
2086
2087%% Extra env for external commands (merged into the process environment).
2088%%
2089%% - SHELL=/bin/sh: `script` invokes $SHELL; nu/fish break `script -c`.
2090%% - LESS=FRX when unset: pagers (jj/git → less) pass through ANSI colors (-R)
2091%% and exit on short output (-F) without clearing the screen (-X).
2092%% - FORCE_COLOR / CLICOLOR_FORCE when the shell itself wants color and the
2093%% child has no TTY (direct path): tools like jj/git/ripgrep emit ANSI so
2094%% we can show their colors when re-printing the captured string.
2095child_env() ->
2096 Env0 = [{"SHELL", "/bin/sh"}],
2097 Env1 =
2098 case os:getenv("LESS") of
2099 false ->
2100 [{"LESS", "FRX"} | Env0];
2101 "" ->
2102 [{"LESS", "FRX"} | Env0];
2103 _ ->
2104 Env0
2105 end,
2106 case want_child_color() of
2107 false ->
2108 Env1;
2109 true ->
2110 Env2 =
2111 case os:getenv("FORCE_COLOR") of
2112 false ->
2113 [{"FORCE_COLOR", "1"} | Env1];
2114 "0" ->
2115 Env1;
2116 _ ->
2117 Env1
2118 end,
2119 case os:getenv("CLICOLOR_FORCE") of
2120 false ->
2121 [{"CLICOLOR_FORCE", "1"} | Env2];
2122 "0" ->
2123 Env2;
2124 _ ->
2125 Env2
2126 end
2127 end.
2128
2129%% Match gleshell color policy: off under NO_COLOR; otherwise on for a TTY
2130%% or when the parent already forces color.
2131want_child_color() ->
2132 case os:getenv("NO_COLOR") of
2133 L when is_list(L), L =/= "" ->
2134 false;
2135 _ ->
2136 case stdout_isatty() of
2137 true ->
2138 true;
2139 false ->
2140 force_color_set()
2141 end
2142 end.
2143
2144force_color_set() ->
2145 case os:getenv("FORCE_COLOR") of
2146 L when is_list(L), L =/= "", L =/= "0" ->
2147 true;
2148 _ ->
2149 case os:getenv("CLICOLOR_FORCE") of
2150 L when is_list(L), L =/= "", L =/= "0" ->
2151 true;
2152 _ ->
2153 false
2154 end
2155 end.
2156
2157%% True when the last external command already streamed output to the TTY
2158%% (PTY relay). Cleared after being read so the REPL does not double-print.
2159-spec take_output_shown() -> boolean().
2160take_output_shown() ->
2161 case erase(gleshell_output_shown) of
2162 true -> true;
2163 _ -> false
2164 end.
2165
2166-spec clear_output_shown() -> nil.
2167clear_output_shown() ->
2168 erase(gleshell_output_shown),
2169 nil.
2170
2171%% Find a terminal device attached to this BEAM (or an ancestor).
2172%% Note: os:getpid() returns a string, not an integer.
2173find_tty_path() ->
2174 case catch list_to_integer(os:getpid()) of
2175 Pid when is_integer(Pid) ->
2176 case tty_path_for_pid(Pid) of
2177 {ok, _} = Ok ->
2178 Ok;
2179 error ->
2180 walk_parent_tty(Pid, 12)
2181 end;
2182 _ ->
2183 error
2184 end.
2185
2186walk_parent_tty(_Pid, 0) ->
2187 error;
2188walk_parent_tty(Pid, N) when is_integer(Pid), Pid > 1 ->
2189 case parent_pid(Pid) of
2190 {ok, Parent} when Parent > 1, Parent =/= Pid ->
2191 case tty_path_for_pid(Parent) of
2192 {ok, _} = Ok ->
2193 Ok;
2194 error ->
2195 walk_parent_tty(Parent, N - 1)
2196 end;
2197 _ ->
2198 error
2199 end;
2200walk_parent_tty(_, _) ->
2201 error.
2202
2203tty_path_for_pid(Pid) when is_integer(Pid) ->
2204 case read_tty_nr(Pid) of
2205 {ok, 0} ->
2206 error;
2207 {ok, TtyNr} ->
2208 tty_nr_to_path(TtyNr);
2209 error ->
2210 error
2211 end.
2212
2213read_tty_nr(Pid) when is_integer(Pid) ->
2214 case file:read_file("/proc/" ++ integer_to_list(Pid) ++ "/stat") of
2215 {ok, Bin} ->
2216 case parse_stat_tty_nr(binary_to_list(Bin)) of
2217 {ok, N} -> {ok, N};
2218 error -> error
2219 end;
2220 _ ->
2221 error
2222 end.
2223%% /proc/pid/stat: "pid (comm) state ppid pgrp session tty_nr ..."
2224parse_stat_tty_nr(List) ->
2225 case lists:splitwith(fun(C) -> C =/= $) end, List) of
2226 {_, [$) | Rest0]} ->
2227 Rest = string:trim(Rest0, leading),
2228 Fields = string:tokens(Rest, " "),
2229 %% After ')': state, ppid, pgrp, session, tty_nr → index 5
2230 case length(Fields) >= 5 of
2231 true ->
2232 try
2233 {ok, list_to_integer(lists:nth(5, Fields))}
2234 catch
2235 _:_ -> error
2236 end;
2237 false ->
2238 error
2239 end;
2240 _ ->
2241 error
2242 end.
2243
2244parent_pid(Pid) ->
2245 case file:read_file("/proc/" ++ integer_to_list(Pid) ++ "/stat") of
2246 {ok, Bin} ->
2247 case parse_stat_ppid(binary_to_list(Bin)) of
2248 {ok, P} -> {ok, P};
2249 error -> error
2250 end;
2251 _ ->
2252 error
2253 end.
2254
2255parse_stat_ppid(List) ->
2256 case lists:splitwith(fun(C) -> C =/= $) end, List) of
2257 {_, [$) | Rest0]} ->
2258 Rest = string:trim(Rest0, leading),
2259 Fields = string:tokens(Rest, " "),
2260 %% After ')': state, ppid → index 2
2261 case length(Fields) >= 2 of
2262 true ->
2263 try
2264 {ok, list_to_integer(lists:nth(2, Fields))}
2265 catch
2266 _:_ -> error
2267 end;
2268 false ->
2269 error
2270 end;
2271 _ ->
2272 error
2273 end.
2274
2275%% Decode Linux tty_nr (see drivers/tty/tty_io.c / procfs) to a device path.
2276tty_nr_to_path(0) ->
2277 error;
2278tty_nr_to_path(TtyNr) when is_integer(TtyNr) ->
2279 Major = (TtyNr bsr 8) band 16#ff,
2280 Minor = (TtyNr band 16#ff) bor (((TtyNr bsr 20) band 16#fff) bsl 8),
2281 Path =
2282 case Major of
2283 136 ->
2284 "/dev/pts/" ++ integer_to_list(Minor);
2285 4 when Minor >= 64 ->
2286 "/dev/ttyS" ++ integer_to_list(Minor - 64);
2287 4 ->
2288 "/dev/tty" ++ integer_to_list(Minor);
2289 _ ->
2290 undefined
2291 end,
2292 case Path of
2293 undefined ->
2294 error;
2295 _ ->
2296 case file:read_file_info(Path) of
2297 {ok, _} -> {ok, Path};
2298 _ -> error
2299 end
2300 end.
2301
2302reason_to_bin(Reason) when is_atom(Reason) ->
2303 atom_to_binary(Reason, utf8);
2304reason_to_bin(Reason) when is_binary(Reason) ->
2305 Reason;
2306reason_to_bin(Reason) ->
2307 iolist_to_binary(io_lib:format("~p", [Reason])).