workspace #
Once you have a workspace you can find code, change one exact line, and run a command without quoting anything for a shell.
The tree verbs are bound in every cell, so ordinary work starts with no import line:
print(await search("TODO", "klbr-runtime/src", match="*.rs", max_results=20))
note = await open_file("docs/note.md") # a handle, not a read
print(note.relative, (await note.text()).splitlines()[0])
await note.replace("old sentence", "new sentence")
print((await run(("cargo", "check", "-p", "klbr-runtime"), timeout=120)).exit_code)
They act on the source tree this session is already working in. await workspace.current() returns
that tree as an object when a cell wants to hold one, and a different tree is a different target
(remote), not a second argument. A file is the same idea one level down: open it once and work
through the handle.
the shapes #
ls(path) is one directory, the way a shell's ls is one level, and a file lists itself rather
than erroring. ls(path, recursive=True, match=glob) is the recursive one. Either way you get a Listing,
which is iterable:
Listing(root, path, entries=(Entry(path, relative, is_dir, size), ...), pruned, truncated)
Entry.size is None for a directory, because a directory's size is not one number.
sizes = sorted((e for e in await ls("klbr-runtime/src", recursive=True, match="*.rs")),
key=lambda e: e.size)
print(len(sizes), sizes[-1].relative)
search(needle, path, match=glob) is literal, so you can paste text you just read instead of
escaping it as a regex, and it returns a SearchResult:
SearchResult(matches=(TextMatch(path, relative, line, column, text), ...),
truncated, files_searched, files_unreadable, pruned)
One file, opened once. open_file(path) gives a handle, not a read: it resolves the path and
nothing else, so a handle for a file that does not exist yet is a real handle - write creates it -
and every call through it sees the file as it is at that moment. exists() is where you ask the
question opening deliberately does not answer.
page = await open_file("docs/note.md") # File: .relative. Nothing read yet.
text = await page.text() # decoded, UTF-8 by default; raises on binary
raw = await page.read() # bytes, exactly as stored
await page.write(text + "\nmore\n") # the whole file, str or bytes; creates parents
await page.replace("old sentence", "new sentence") # one occurrence, or raises
await delete("docs/scratch.md", force=True) # a file, or an empty directory; gone is fine
await delete("build/", recursive=True) # a directory and everything in it
There are two ways to change part of a file and they are different tools. replace takes literal
text and refuses zero or many matches, so an ambiguous replacement is not applied somewhere you did
not look - both arguments are required, because a caller that left new out means something and
guessing which is how text disappears. edit takes an anchor from hashlines() and names the line
instead of reproducing it, so nothing you type has to match character for character:
await page.hashlines() # 1:a3|# title
# 2:f1|some text
await page.edit("2:f1", "other text") # replace line 2
await page.edit("1:a3..2:f1", "# new title") # replace a range
await page.edit("1:a3", "> note", after=True) # insert after line 1
await page.edit("2:f1") # delete that line
Both ends of a range are verified against the file as it is now, so an edit aimed at a file that
moved under you is refused rather than landing on whatever that line number means today. In edit
one argument is optional and it has one meaning: no new deletes. Prefer the anchor form once you
have the lines on screen: it does not spend tokens restating them, and a hash that no longer
matches fails loudly instead of silently.
the trail #
workspace.current() asks the host where this session's source tree is. It is not your
process's current directory, and it refuses to guess: a cell that silently edits the wrong
checkout is worse than a cell that errors. The verbs above are bound in the cell; the same
functions live in klbr.workspace, and await klbr.workspace.current() is the object form.
Build and version-control directories are not walked. pruned names the ones skipped, so "not
found" is a claim about where the walk went rather than about the tree - include_pruned=True
walks them anyway. truncated is the same kind of honesty for a cap: a hit list that stopped
early is a different answer from a short one.
run is not a shell and not a subprocess you started. The host owns the source root, the process
group, the deadline and the output bound, and it answers with a RunResult whose completed is
true even when exit_code is not zero: finishing and succeeding are separate facts. A build
started around that path is invisible to the host and survives the cell that started it.