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.