Utility: dir - list a directory
Status: Built + QEMU-verified (osdev test files 222/0, osdev test shell 174/0) - a
shell built-in over the fs LIST_DIR API, on hierarchical GSFS (docs/persistence.md). Read-only.
Trails CLAUDE.md; does not amend it. Shared addressing/current-location rules live in 17_cd.md
and docs/drives.md §4.1.
Renamed from ls (2026-09-17). Typing ls is not an error you have to decode: the shell names
the word for you, once, and does not run anything (FOREIGN_HINTS in services/shell).
1. What it is
dir lists the entries of a directory - each name, whether it is a file or a directory,
and (for files) the size. With no argument it lists the current directory (17_cd.md);
with a path it lists that directory. dir is read-only: it never changes the disk.
Why dir and not ls, and not list
ls is an abbreviation of "list", chosen for a teletype. It came to Unix from Multics, and the
reason it is two letters is that early Unix was typed on a Model 33 at ten characters a second,
where every keystroke was physical work - the same pressure that produced cp, mv, rm and
creat. It was never chosen because it described the operation well, so replacing it is not
departing from a well-chosen name; it is supplying precision that never existed.
It was also the last POSIX abbreviation in this vocabulary. Everything around it is already its own
word - read, write, move, rename, delete, copy, find, tree, match, count,
first, last - where POSIX would say cat, mv, rm, cp, grep, wc, head, tail. ls
was the odd one out, inconsistent with its siblings rather than merely with the model.
list was rejected, and the reason is specific to this shell. "List" is a generic verb, and
this shell lists drives, caps, cores and events. A bare list would be genuinely ambiguous here in
a way it would not be on a system that only ever lists one thing. The original name's vagueness was
inherited, not a reason to keep it.
dir names its subject rather than the operation performed on it, and the argument for it owes
nothing to DOS: mkdir already teaches the word. Somebody who has typed mkdir /docs has been
told that the word for this thing is "dir"; dir /docs then shows the thing mkdir made. The pair
is coherent on its own terms. And unlike ls, it is transparent - a reader who has never seen it
reads "directory", where ls is opaque unless you already know Unix. That is the actual
discriminator: not length, but whether the name explains itself.
It can be concrete because GSFS has exactly one kind of container. PowerShell could not be - having
to cover a filesystem, a registry and a certificate store, its noun had to generalise, which is how
a rename aimed at clarity arrived at Get-ChildItem. There are no providers here, so there is no
abstraction to leak into the name.
So the three short names kept are dir, cd and mkdir, and each is kept for the same reason
rather than for brevity: it says what it does.
Four columns, and why there is no fifth
dir shows name, type, size and when it changed. There are no words to turn any of it on; that
is the listing.
This is not ls -l made default. ls -l is opt-in on Unix because it adds mode bits, link
count, owner and group - four columns of POSIX bookkeeping. None of them exist here, so removing
them is not a choice this made: there is nothing to remove. What is left is four columns, and four
columns is not a wall.
There is no permissions column because permissions are not a property of a file. Authority here
is a capability, and a capability is held by a HOLDER - so "who can read this?" has no per-file
answer to put in a column. The question does not live on the file; it lives on whoever holds a cap
to it, and caps is what answers it. This is the one place the capability model visibly changes
what a familiar command can even mean.
Sealing is the exception that proves it, and why seal sits in the TYPE column rather than in a
column of its own: a seal genuinely IS a property of the file, recorded on disk, constraining what
anyone may do to it regardless of what they hold. It also costs no width, because sealing is
file-only (fs refuses "only a file can be sealed"), so TYPE stays single-valued.
A directory shows - for size. A directory occupies blocks, but reporting those answers a
question nobody asked and would not equal the sum of what is inside it.
unknown in MODIFIED is a real answer, not a missing one - see below.
2. Usage
dir 0.4.0 - list a directory (records when piped)
usage:
dir list the current directory
dir <path> list the directory at <path>
dir bytes sizes as an exact byte count, not KiB/MiB/GiB
dir [path] | <verb> piped: emits records name/type/size
dir | select … / sort … project / order the listing
dir version print the version
dir help print this message
<path> = [index:]label/path | /abs | rel (see docs/drives.md §4.1)
The word mixes with a path in either order: dir bytes /projects and dir /projects bytes are the
same command. Words, not flags (0_conventions.md rule 4) -
there is no -lh, because a flag is a thing you have to have been told.
Example:
gsh> dir /
/
NAME TYPE SIZE MODIFIED
canary.txt file 41 B unknown
a.[2Jb.txt file 49 B unknown
.gsh_history file 664 B 2026-09-17 14:09
fz dir - unknown
clock.last file 10 B unknown
5 entries
gsh> dir bytes /
/
NAME TYPE SIZE MODIFIED
canary.txt file 41 unknown
a.[2Jb.txt file 49 unknown
.gsh_history file 651 2026-09-17 14:09
fz dir - unknown
clock.last file 10 unknown
5 entries
Both captured from one boot of osdev test fs-fuzz, not written by hand, on 2026-09-17 - before the
layout moved the shell's history and the clock floor off the root into their folders
(docs/filesystem-layout.md); the root of a disk flashed since holds system and users. Three things
in them are worth pointing at:
The count is LAST, and it is the number of entries actually printed. It used to lead, next to the
path. A directory larger than one reply block arrives in several pages, so the total is not known
until the walk ends - and the obvious fix, walking once to count and again to print, produces two
answers that can disagree, because the directory may change between them. A header contradicting the
rows beneath it is precisely the wrong answer this format exists to avoid. Counting what was rendered
cannot disagree with itself, and it puts dir in line with find and tree, which have always
summarised at the end.
a.[2Jb.txt is a filename holding a raw ESC [ 2J - a clear-screen sequence - baked onto the disk
by that suite. It renders with . where the control bytes are, so listing it cannot scroll itself
off the screen. That sanitising is why the name column can be trusted, and it is a reason not to
decorate names with markers.
.gsh_history differs between the two listings because the shell appended the command in between.
That is the file honestly changing, not the two renderings disagreeing.
The size column is right-aligned INCLUDING its unit
8 B and 1.4 MiB end at the same column. Aligning the digits and letting the units straggle still
reads as ragged, which is the subtler half of the problem; the blunter half is that a Rust Display
implementation silently ignores the width it is given unless it calls f.pad(), so {:>9} did
nothing at all until the renderer was changed to render into a buffer and pad it.
unknown is a real answer, not a missing one
A MODIFIED column reads unknown when the filesystem genuinely records no time for that entry -
a file written before the volume recorded times at all, or one written while the machine had no
idea what time it was.
It is never shown as 1970, because a wrong date is worse than an absent one: a date you can see
is a date you will act on. See docs/gsfs-next.md §3.
Sizes line up as a column, which needed more care than it looks: a Rust Display implementation
silently ignores the width it is given unless it asks for it, so {:>10} did nothing until the
renderer was changed to pad explicitly. A column of ragged numbers is not a column.
2a. As a record producer (typed pipes)
dir is a record producer (docs/records.md, utilities/31_records.md): bare it prints
the text listing above, but in a pipe it emits a typed table with columns
name / type / size / sealed (type is file/dir; size is the byte count for files
and empty for directories; sealed is true/false, so a sealed file stays type=file in a query
rather than reading seal as the text listing does). The table holds at most 64 rows
(REC_MAX_ROWS). So the structured verbs operate on real fields instead of re-parsing text:
dir | where type=file only files
dir /docs | where size>0 non-empty files
dir | select name size keep two columns
dir | where type=file | sort reverse size biggest files first
dir | to json the listing as JSON
This is the structured replacement for the old plan to bolt name/size sorting onto dir itself
(§5) - sorting and filtering are the pipe's job (where/select/sort <col>), not flags on
dir. A text filter (match/count/first/last) on dir output is a loud, guided error
(it is records, not text - use where, or to json to get text first).
3. Addressing & current location
<path> is the standard file address: a [index:]label/path on any present drive, a
/absolute path on the current drive, or a relative path from the current directory.
The current location is one drive+directory pointer moved by cd (17_cd.md).
4. Implementation
Read-only, so dir is a shell built-in that sends ListDir to fs (op 14) over a
narrow ipc_send=["fs"] cap and formats the reply. fs enforces; the shell's authority is
not widened beyond reading (0_conventions.md §2). Two code paths share that one fs call:
cmd_dir formats the text listing (bare dir), and build_dir_table parses the same reply into
the name/type/size/sealed table for the record pipe (is_record_producer routes a piped
dir to it before the text producers are consulted).
Both walk the reply through a DirCursor, because one reply block carries only about twenty
entries: the request says which entry to resume at and the reply says where to continue, so a
directory of any size is listed in full (backlog/fixed/33). The count dir prints is therefore the
number of entries it actually rendered, and it is printed LAST - the total is not known until the
walk ends, and a header that can disagree with the rows under it is the wrong answer this whole
mechanism exists to remove. An empty directory prints (empty). The walk is bounded at
gs::fs::DIR_PAGE_MAX (512) pages; a directory larger than that ends with an INCOMPLETE - ...
line under the count rather than a count that reads as the whole truth.
(The paging now lives in the library: both paths call gs::fs::Fs::list_dir, which drives the
cursor. The shell's own DirCursor remains only for copy ... recursive.)
5. Later (separate doc so it can grow)
dir extents(a word, never-x-0_conventions.md§4): the on-disk placement - first block, block count, and whether the file is fragmented (ITYPE_FILE_FRAG). Deliberately NOT calleddir raw, which fails the same testlsfailed: raw what? Not built - nothing has asked for it outside a test harness, and §26.2 says a feature is pulled into existence rather than anticipated.- A recursive form is already
tree, which exists. (Sorting and filtering by name/size are done - the record pipe's job, §2a, not words ondir.)
6. Conformance
Conforms: dir help (usage with a real example per row) and dir version (number +
creator credit) per 0_conventions.md (the shared help_block helper).
Also conforms to rule 10 (0_conventions.md §1.10): the listing goes through a gs::fs::Fs
handle lent the command's watch (gs::call::Watch, since 2026-10-13, backlog/fixed/89), so once the COMMAND
has run 2 s (gs::call::NOTICE_AFTER_SECS) it prints [q] quit, and q/Q/ESC returns to the prompt
with no error (a fast listing prints nothing). This replaced a bare request_with_reply, which rule 10
forbids for an interactive command. The piped form (build_dir_table) uses the same handle, so
dir | ... says [q] quit too.