A structured-data shell in Gleam, inspired by Nushell
0

Configure Feed

Select the types of activity you want to include in your feed.

Add Nushell-style ps builtin for process tables.

List Linux /proc processes as a pipelineable table (cpu, mem, and optional --long columns), with display formatting for memory and start times.

author
nandi
date (Jul 26, 2026, 2:55 AM -0700) commit e41d2d90 parent 5117c3b6 change-id xpxuwnrw
+533 -5
+5
README.md
··· 118 118 which ls 119 119 which -a ls 120 120 which -f sh 121 + 122 + # processes (Nushell-style table) 123 + ps 124 + ps | sort-by mem | last 5 125 + ps --long | where name == beam.smp 121 126 ``` 122 127 123 128 ## Language sketch
+71
src/gleshell/builtins.gleam
··· 88 88 #("values", cmd_values), 89 89 #("keys", cmd_keys), 90 90 #("sys", cmd_sys), 91 + #("ps", cmd_ps), 91 92 #("about", cmd_about), 92 93 #("less", cmd_less), 93 94 ]) ··· 275 276 ], 276 277 "\n", 277 278 )) 279 + "ps" -> 280 + Ok(string.join( 281 + [ 282 + "ps [-l|--long] — view system processes as a table", 283 + "", 284 + "Inspired by Nushell `ps`. Default columns: pid, ppid, name, status,", 285 + "cpu, mem, virtual. With --long, also: command, start_time, user_id,", 286 + "process_group_id, session_id, priority, process_threads, working,", 287 + "paged, cwd.", 288 + "", 289 + "Flags:", 290 + " -l, --long include all available columns", 291 + "", 292 + "Examples:", 293 + " ps", 294 + " ps | sort-by mem | last 5", 295 + " ps | sort-by cpu | last 3", 296 + " ps --long | where name == beam.smp", 297 + " ps | where pid == 1 | get name", 298 + ], 299 + "\n", 300 + )) 278 301 _ -> help_line(name) 279 302 } 280 303 } ··· 364 387 #("values", "values — list of values from a record"), 365 388 #("keys", "keys — list of keys from a record"), 366 389 #("sys", "sys — host info record (cwd, home, shell, last_exit)"), 390 + #( 391 + "ps", 392 + "ps [-l|--long] — system processes table (pid, name, cpu, mem, …)", 393 + ), 367 394 #("about", "about — authorship, ATProto handle, and a little sparkle"), 368 395 #( 369 396 "less", ··· 2326 2353 #("last_exit", Int(env.last_exit)), 2327 2354 ]), 2328 2355 ) 2356 + } 2357 + 2358 + // --- ps (Nushell-style process table) --- 2359 + 2360 + fn cmd_ps( 2361 + env: Env, 2362 + _input: Value, 2363 + _args: List(Value), 2364 + flags: dict.Dict(String, Value), 2365 + ) -> BuiltinResult { 2366 + let #(long, _) = find_bool_flag(flags, ["l", "long"]) 2367 + let records = 2368 + list.map(sys.list_processes(), fn(p) { process_to_record(p, long) }) 2369 + ok(env, value.table_from_records(records)) 2370 + } 2371 + 2372 + fn process_to_record(p: sys.ProcessInfo, long: Bool) -> Value { 2373 + let base = [ 2374 + #("pid", Int(p.pid)), 2375 + #("ppid", Int(p.ppid)), 2376 + #("name", String(p.name)), 2377 + #("status", String(p.status)), 2378 + #("cpu", Float(p.cpu)), 2379 + #("mem", Int(p.mem)), 2380 + #("virtual", Int(p.virtual)), 2381 + ] 2382 + case long { 2383 + False -> Record(base) 2384 + True -> 2385 + Record( 2386 + list.append(base, [ 2387 + #("command", String(p.command)), 2388 + #("start_time", Int(p.start_time)), 2389 + #("user_id", Int(p.user_id)), 2390 + #("process_group_id", Int(p.process_group_id)), 2391 + #("session_id", Int(p.session_id)), 2392 + #("priority", Int(p.priority)), 2393 + #("process_threads", Int(p.process_threads)), 2394 + #("working", Int(p.working)), 2395 + #("paged", Int(p.paged)), 2396 + #("cwd", String(p.cwd)), 2397 + ]), 2398 + ) 2399 + } 2329 2400 } 2330 2401 2331 2402 // --- less (color-aware pager) ---
+8 -2
src/gleshell/display.gleam
··· 261 261 fn cell_plain(col: String, value: Value) -> String { 262 262 case col, value { 263 263 "size", Int(n) -> format_filesize(n) 264 + "mem", Int(n) -> format_filesize(n) 265 + "virtual", Int(n) -> format_filesize(n) 266 + "working", Int(n) -> format_filesize(n) 267 + "paged", Int(n) -> format_filesize(n) 264 268 "modified", Int(n) -> format_datetime(n) 269 + "start_time", Int(n) -> format_datetime(n) 265 270 _, _ -> value.cell_string(value) 266 271 } 267 272 } ··· 322 327 case col { 323 328 "name" -> color_path_name(on, plain, type_hint) 324 329 "type" -> color_entry_type(on, plain) 325 - "size" -> color.filesize(on, plain) 326 - "modified" -> color.datetime(on, plain) 330 + "size" | "mem" | "virtual" | "working" | "paged" -> 331 + color.filesize(on, plain) 332 + "modified" | "start_time" -> color.datetime(on, plain) 327 333 _ -> color_by_value(on, value, plain) 328 334 } 329 335 }
+29
src/gleshell/sys.gleam
··· 128 128 /// Format Unix epoch seconds as local `Jul 3 2026 9:39:40 PM` (12-hour). 129 129 @external(erlang, "gleshell_ffi", "format_unix_local") 130 130 pub fn format_unix_local(seconds: Int) -> String 131 + 132 + /// One process row from `list_processes` (Nushell `ps` columns). 133 + /// Memory fields are bytes; `start_time` is Unix epoch seconds (0 if unknown). 134 + pub type ProcessInfo { 135 + ProcessInfo( 136 + pid: Int, 137 + ppid: Int, 138 + name: String, 139 + status: String, 140 + cpu: Float, 141 + mem: Int, 142 + virtual: Int, 143 + command: String, 144 + start_time: Int, 145 + user_id: Int, 146 + process_group_id: Int, 147 + session_id: Int, 148 + priority: Int, 149 + process_threads: Int, 150 + working: Int, 151 + paged: Int, 152 + cwd: String, 153 + ) 154 + } 155 + 156 + /// System processes (Linux `/proc`; empty list on unsupported OS). 157 + /// Samples CPU over ~100ms like Nushell `ps`. 158 + @external(erlang, "gleshell_ffi", "list_processes") 159 + pub fn list_processes() -> List(ProcessInfo)
+383 -3
src/gleshell_ffi.erl
··· 29 29 history_hint/2, 30 30 history_search/2, 31 31 re_contains/3, 32 - format_unix_local/1 32 + format_unix_local/1, 33 + list_processes/0 33 34 ]). 34 35 35 36 -define(ESC, 16#1b). ··· 745 746 "about", "append", "cat", "cd", "columns", "count", "describe", "echo", 746 747 "env", "exit", "filter", "find", "first", "flatten", "from", "get", 747 748 "help", "identity", "ignore", "is-empty", "is_empty", "keys", "last", 748 - "length", "less", "lines", "ls", "open", "prepend", "print", "pwd", 749 - "quit", "range", "reverse", "save", "select", "skip", "sort-by", 749 + "length", "less", "lines", "ls", "open", "prepend", "print", "ps", 750 + "pwd", "quit", "range", "reverse", "save", "select", "skip", "sort-by", 750 751 "sort_by", "sys", "table", "take", "to", "type", "typeof", "uniq", 751 752 "unwrap", "values", "where", "which", "wrap" 752 753 ]. ··· 3231 3232 month_abbr(11) -> "Nov"; 3232 3233 month_abbr(12) -> "Dec"; 3233 3234 month_abbr(_) -> "???". 3235 + 3236 + %% --------------------------------------------------------------------------- 3237 + %% ps: list system processes (Nushell-compatible columns, Linux /proc) 3238 + %% --------------------------------------------------------------------------- 3239 + %% 3240 + %% Returns a list of 17-tuples, one per process (numeric /proc entries that 3241 + %% can still be read after a short CPU sample interval): 3242 + %% 3243 + %% {Pid, Ppid, Name, Status, Cpu, Mem, Virtual, Command, StartTime, 3244 + %% UserId, ProcessGroupId, SessionId, Priority, ProcessThreads, 3245 + %% Working, Paged, Cwd} 3246 + %% 3247 + %% Integers are bytes for mem/virtual/working/paged; StartTime is Unix 3248 + %% seconds (0 if unknown). Cpu is percent of one core over ~100ms, like Nu. 3249 + %% 3250 + -spec list_processes() -> 3251 + list({ 3252 + integer(), 3253 + integer(), 3254 + binary(), 3255 + binary(), 3256 + float(), 3257 + integer(), 3258 + integer(), 3259 + binary(), 3260 + integer(), 3261 + integer(), 3262 + integer(), 3263 + integer(), 3264 + integer(), 3265 + integer(), 3266 + integer(), 3267 + integer(), 3268 + binary() 3269 + }). 3270 + list_processes() -> 3271 + case os:type() of 3272 + {unix, linux} -> 3273 + list_processes_linux(); 3274 + _ -> 3275 + %% Other OSes: empty table rather than crash; caller still gets 3276 + %% a valid table shape from the Gleam side when needed. 3277 + [] 3278 + end. 3279 + 3280 + list_processes_linux() -> 3281 + Ticks = clk_tck(), 3282 + PageSize = page_size(), 3283 + Boot = boot_time_seconds(), 3284 + Base = snapshot_cpu_times(), 3285 + timer:sleep(100), 3286 + IntervalMs = 100.0, 3287 + case file:list_dir("/proc") of 3288 + {ok, Entries} -> 3289 + lists:filtermap( 3290 + fun(Entry) -> 3291 + case is_pid_name(Entry) of 3292 + false -> 3293 + false; 3294 + true -> 3295 + Pid = list_to_integer(Entry), 3296 + case read_process(Pid, Ticks, PageSize, Boot, Base, IntervalMs) of 3297 + {ok, Row} -> 3298 + {true, Row}; 3299 + error -> 3300 + false 3301 + end 3302 + end 3303 + end, 3304 + Entries 3305 + ); 3306 + {error, _} -> 3307 + [] 3308 + end. 3309 + 3310 + is_pid_name([]) -> 3311 + false; 3312 + is_pid_name(Name) -> 3313 + lists:all(fun(C) -> C >= $0 andalso C =< $9 end, Name). 3314 + 3315 + %% Map pid -> total jiffies (utime+stime) from first snapshot. 3316 + snapshot_cpu_times() -> 3317 + case file:list_dir("/proc") of 3318 + {ok, Entries} -> 3319 + lists:foldl( 3320 + fun(Entry, Acc) -> 3321 + case is_pid_name(Entry) of 3322 + false -> 3323 + Acc; 3324 + true -> 3325 + Pid = list_to_integer(Entry), 3326 + case read_stat_cpu(Pid) of 3327 + {ok, Total} -> 3328 + Acc#{Pid => Total}; 3329 + error -> 3330 + Acc 3331 + end 3332 + end 3333 + end, 3334 + #{}, 3335 + Entries 3336 + ); 3337 + {error, _} -> 3338 + #{} 3339 + end. 3340 + 3341 + read_stat_cpu(Pid) -> 3342 + case read_stat_fields(Pid) of 3343 + {ok, Fields} -> 3344 + U = maps:get(utime, Fields, 0), 3345 + S = maps:get(stime, Fields, 0), 3346 + {ok, U + S}; 3347 + error -> 3348 + error 3349 + end. 3350 + 3351 + read_process(Pid, Ticks, PageSize, Boot, Base, IntervalMs) -> 3352 + case read_stat_fields(Pid) of 3353 + {ok, Fields} -> 3354 + U = maps:get(utime, Fields, 0), 3355 + S = maps:get(stime, Fields, 0), 3356 + Total = U + S, 3357 + Prev = maps:get(Pid, Base, Total), 3358 + Delta = max(0, Total - Prev), 3359 + %% usage_ms = delta_jiffies * 1000 / ticks; percent of one core 3360 + UsageMs = 3361 + case Ticks > 0 of 3362 + true -> 3363 + Delta * 1000 / Ticks; 3364 + false -> 3365 + 0.0 3366 + end, 3367 + Cpu = 3368 + case IntervalMs > 0.0 of 3369 + true -> 3370 + UsageMs * 100.0 / IntervalMs; 3371 + false -> 3372 + 0.0 3373 + end, 3374 + Status = status_name(maps:get(state, Fields, $?)), 3375 + Name = maps:get(comm, Fields, <<>>), 3376 + Ppid = maps:get(ppid, Fields, 0), 3377 + Pgrp = maps:get(pgrp, Fields, 0), 3378 + Session = maps:get(session, Fields, 0), 3379 + Priority = maps:get(priority, Fields, 0), 3380 + Threads = maps:get(num_threads, Fields, 0), 3381 + Vsize = maps:get(vsize, Fields, 0), 3382 + RssPages = maps:get(rss, Fields, 0), 3383 + MemFromStat = RssPages * PageSize, 3384 + StartJiffies = maps:get(starttime, Fields, 0), 3385 + StartTime = 3386 + case Boot > 0 andalso Ticks > 0 of 3387 + true -> 3388 + Boot + StartJiffies div Ticks; 3389 + false -> 3390 + 0 3391 + end, 3392 + {Mem, Working, Paged, Virtual, UserId} = read_status_mem(Pid, MemFromStat, Vsize), 3393 + Command = read_cmdline(Pid, Name), 3394 + Cwd = read_cwd(Pid), 3395 + %% Gleam `ProcessInfo` constructor → Erlang `{process_info, ...}`. 3396 + {ok, 3397 + {process_info, Pid, Ppid, Name, Status, float(Cpu), Mem, Virtual, 3398 + Command, StartTime, UserId, Pgrp, Session, Priority, Threads, 3399 + Working, Paged, Cwd}}; 3400 + error -> 3401 + error 3402 + end. 3403 + 3404 + %% Parse /proc/<pid>/stat. Comm is between the first '(' and the matching ") ". 3405 + read_stat_fields(Pid) -> 3406 + Path = "/proc/" ++ integer_to_list(Pid) ++ "/stat", 3407 + case file:read_file(Path) of 3408 + {ok, Bin0} -> 3409 + Bin = string:trim(Bin0, trailing, [$\n]), 3410 + case binary:split(Bin, <<"(">>) of 3411 + [_PidBin, Rest] -> 3412 + case binary:match(Rest, <<") ">>) of 3413 + {Pos, 2} -> 3414 + Comm = binary:part(Rest, 0, Pos), 3415 + After = binary:part(Rest, Pos + 2, byte_size(Rest) - Pos - 2), 3416 + Fs = binary:split(After, <<" ">>, [global]), 3417 + %% Indices after state (0-based): see proc(5) 3418 + %% 0 state, 1 ppid, 2 pgrp, 3 session, 3419 + %% 11 utime, 12 stime, 15 priority, 17 num_threads, 3420 + %% 19 starttime, 20 vsize, 21 rss 3421 + try 3422 + StateBin = nth_bin(Fs, 1), 3423 + State = 3424 + case StateBin of 3425 + <<C, _/binary>> -> 3426 + C; 3427 + <<>> -> 3428 + $?; 3429 + _ -> 3430 + $? 3431 + end, 3432 + {ok, #{ 3433 + comm => Comm, 3434 + state => State, 3435 + ppid => nth_int(Fs, 2), 3436 + pgrp => nth_int(Fs, 3), 3437 + session => nth_int(Fs, 4), 3438 + utime => nth_int(Fs, 12), 3439 + stime => nth_int(Fs, 13), 3440 + priority => nth_int(Fs, 16), 3441 + num_threads => nth_int(Fs, 18), 3442 + starttime => nth_int(Fs, 20), 3443 + vsize => nth_int(Fs, 21), 3444 + rss => nth_int(Fs, 22) 3445 + }} 3446 + catch 3447 + _:_ -> 3448 + error 3449 + end; 3450 + nomatch -> 3451 + error 3452 + end; 3453 + _ -> 3454 + error 3455 + end; 3456 + {error, _} -> 3457 + error 3458 + end. 3459 + 3460 + nth_bin(List, N) when N >= 1 -> 3461 + case length(List) >= N of 3462 + true -> 3463 + lists:nth(N, List); 3464 + false -> 3465 + <<>> 3466 + end. 3467 + 3468 + nth_int(List, N) -> 3469 + case nth_bin(List, N) of 3470 + <<>> -> 3471 + 0; 3472 + Bin -> 3473 + try 3474 + binary_to_integer(Bin) 3475 + catch 3476 + _:_ -> 3477 + 0 3478 + end 3479 + end. 3480 + 3481 + status_name($S) -> <<"Sleeping">>; 3482 + status_name($R) -> <<"Running">>; 3483 + status_name($D) -> <<"Disk sleep">>; 3484 + status_name($Z) -> <<"Zombie">>; 3485 + status_name($T) -> <<"Stopped">>; 3486 + status_name($t) -> <<"Tracing">>; 3487 + status_name($X) -> <<"Dead">>; 3488 + status_name($x) -> <<"Dead">>; 3489 + status_name($K) -> <<"Wakekill">>; 3490 + status_name($W) -> <<"Waking">>; 3491 + status_name($P) -> <<"Parked">>; 3492 + status_name($I) -> <<"Idle">>; 3493 + status_name(_) -> <<"Unknown">>. 3494 + 3495 + %% Prefer VmRSS / VmSize / VmSwap (kB) and Uid from status; fall back to stat. 3496 + read_status_mem(Pid, MemFromStat, VsizeFromStat) -> 3497 + Path = "/proc/" ++ integer_to_list(Pid) ++ "/status", 3498 + case file:read_file(Path) of 3499 + {ok, Bin} -> 3500 + Lines = binary:split(Bin, <<"\n">>, [global]), 3501 + Mem = kb_field(Lines, <<"VmRSS:">>, MemFromStat), 3502 + Virtual = kb_field(Lines, <<"VmSize:">>, VsizeFromStat), 3503 + Paged = kb_field(Lines, <<"VmSwap:">>, 0), 3504 + UserId = uid_field(Lines), 3505 + {Mem, Mem, Paged, Virtual, UserId}; 3506 + {error, _} -> 3507 + {MemFromStat, MemFromStat, 0, VsizeFromStat, 0} 3508 + end. 3509 + 3510 + kb_field(Lines, Key, Default) -> 3511 + case find_status_line(Lines, Key) of 3512 + {ok, Rest} -> 3513 + %% " 1234 kB" 3514 + case re:run(Rest, <<"([0-9]+)">>, [{capture, all_but_first, binary}]) of 3515 + {match, [Num]} -> 3516 + binary_to_integer(Num) * 1024; 3517 + _ -> 3518 + Default 3519 + end; 3520 + error -> 3521 + Default 3522 + end. 3523 + 3524 + uid_field(Lines) -> 3525 + case find_status_line(Lines, <<"Uid:">>) of 3526 + {ok, Rest} -> 3527 + case re:run(Rest, <<"([0-9]+)">>, [{capture, all_but_first, binary}]) of 3528 + {match, [Num]} -> 3529 + binary_to_integer(Num); 3530 + _ -> 3531 + 0 3532 + end; 3533 + error -> 3534 + 0 3535 + end. 3536 + 3537 + find_status_line([], _Key) -> 3538 + error; 3539 + find_status_line([Line | Rest], Key) -> 3540 + Klen = byte_size(Key), 3541 + case Line of 3542 + <<Key:Klen/binary, RestLine/binary>> -> 3543 + {ok, RestLine}; 3544 + _ -> 3545 + find_status_line(Rest, Key) 3546 + end. 3547 + 3548 + read_cmdline(Pid, FallbackName) -> 3549 + Path = "/proc/" ++ integer_to_list(Pid) ++ "/cmdline", 3550 + case file:read_file(Path) of 3551 + {ok, <<>>} -> 3552 + FallbackName; 3553 + {ok, Bin} -> 3554 + Parts = [P || P <- binary:split(Bin, <<0>>, [global]), P =/= <<>>], 3555 + case Parts of 3556 + [] -> 3557 + FallbackName; 3558 + _ -> 3559 + Joined = iolist_to_binary(lists:join(<<" ">>, Parts)), 3560 + %% Nu collapses newlines/tabs in the display command. 3561 + re:replace(Joined, <<"[\n\t]">>, <<" ">>, [global, {return, binary}]) 3562 + end; 3563 + {error, _} -> 3564 + FallbackName 3565 + end. 3566 + 3567 + read_cwd(Pid) -> 3568 + Path = "/proc/" ++ integer_to_list(Pid) ++ "/cwd", 3569 + case file:read_link(Path) of 3570 + {ok, Target} -> 3571 + unicode:characters_to_binary(Target); 3572 + {error, _} -> 3573 + <<>> 3574 + end. 3575 + 3576 + clk_tck() -> 3577 + case getconf_int("CLK_TCK") of 3578 + {ok, N} when N > 0 -> 3579 + N; 3580 + _ -> 3581 + 100 3582 + end. 3583 + 3584 + page_size() -> 3585 + case getconf_int("PAGE_SIZE") of 3586 + {ok, N} when N > 0 -> 3587 + N; 3588 + _ -> 3589 + 4096 3590 + end. 3591 + 3592 + getconf_int(Name) -> 3593 + Cmd = "getconf " ++ Name, 3594 + try 3595 + Out = string:trim(os:cmd(Cmd)), 3596 + {ok, list_to_integer(Out)} 3597 + catch 3598 + _:_ -> 3599 + error 3600 + end. 3601 + 3602 + boot_time_seconds() -> 3603 + case file:read_file("/proc/stat") of 3604 + {ok, Bin} -> 3605 + case re:run(Bin, <<"btime ([0-9]+)">>, [{capture, all_but_first, binary}]) of 3606 + {match, [Num]} -> 3607 + binary_to_integer(Num); 3608 + _ -> 3609 + 0 3610 + end; 3611 + {error, _} -> 3612 + 0 3613 + end.
+37
test/gleshell_test.gleam
··· 450 450 Nil 451 451 } 452 452 453 + pub fn eval_ps_test() { 454 + let env = env.new() 455 + // Default columns match Nushell short `ps`. 456 + let assert eval.Continue(_, Table(cols, rows)) = eval.eval_source(env, "ps") 457 + let assert [ 458 + "pid", 459 + "ppid", 460 + "name", 461 + "status", 462 + "cpu", 463 + "mem", 464 + "virtual", 465 + ] = cols 466 + let assert True = rows != [] 467 + 468 + // pid 1 exists on Linux 469 + let assert eval.Continue(_, Table(_, pid1_rows)) = 470 + eval.eval_source(env, "ps | where pid == 1") 471 + let assert True = pid1_rows != [] 472 + 473 + // --long adds the extra Nu columns 474 + let assert eval.Continue(_, Table(long_cols, _)) = 475 + eval.eval_source(env, "ps --long") 476 + let assert True = list.contains(long_cols, "command") 477 + let assert True = list.contains(long_cols, "start_time") 478 + let assert True = list.contains(long_cols, "cwd") 479 + let assert True = list.length(long_cols) > list.length(cols) 480 + 481 + // which / help 482 + let assert eval.Continue(_, String("builtin: ps")) = 483 + eval.eval_source(env, "which ps") 484 + let assert eval.Continue(_, String(help_out)) = 485 + eval.eval_source(env, "help ps") 486 + let assert True = string.contains(help_out, "--long") 487 + Nil 488 + } 489 + 453 490 pub fn http_get_live_test() { 454 491 // Live request against postman-echo (JSON). Skip gracefully if offline. 455 492 let env = env.new()