Files
jeffryandClaude Opus 5 da6e8b9b51 loomctl orient: one table of contents, restating no rule
Generates .loom/orientation.md — what this repository depends on, where each copy
came from, and which facets sit beside it — for whoever arrives next, of any make.

One file rather than two, decided by loom's own rule. Generate what varies, adopt
what does not: the publishing half varies not at all and is already adopted, so
publication.md appears in the index like any other adopted document, in exactly the
repositories that adopted it. A second command would emit a file whose whole content
is a pointer to a file already in the tree, and would revive a noun declined when
published check went.

Restates no rule. The three moves are phrased as operations — what to run, what to
write, where — and every rule stays in the document that owns it. The .usages.md is
pointed at rather than summarised, because what depends on a document is free prose
and anything extracting a claim from prose eventually extracts it wrong. It says
nothing recorded when a document has no usages file, which is a finding rather than
an omission and is currently true of six of nine.

Byte-deterministic, pinned by a test that inserts records out of order and
generates three times. Written to .loom/orientation.md rather than inside
.loom/externals/, where check would report it as an unlocked external in every
repository using the feature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
2026-09-08 09:11:04 -04:00

227 lines
6.8 KiB
Go

// loomctl fetches documents this repository depends on, and finds out when they
// change.
//
// It reports and never repairs. Everything it writes, it writes to the working
// tree — committing and pushing are yours, because the consequences of a push
// land on people a tool cannot experience.
package main
import (
"flag"
"fmt"
"io"
"os"
"sort"
"strings"
"git.hypertheory-labs.dev/loom/loom-cli/internal/config"
"git.hypertheory-labs.dev/loom/loom-cli/internal/external"
"git.hypertheory-labs.dev/loom/loom-cli/internal/orient"
)
const usage = `loomctl — fetch what you depend on, and find out when it changed.
loomctl external list <owner/repo> what a repository publishes
loomctl external add <owner/repo> <doc> adopt one document and lock it
loomctl external add <url> [--path p] ... or by its full raw URL
loomctl external check ask every publisher whether theirs moved
loomctl external apply [path...] move a staged polad into place, lock and all
check reports and does not fix. A document that moved is staged as a polad in
.loom/cart/current/polad/ — a candidate shaped exactly like what it would
become — and somebody decides. Its exits are apply or discard; nothing there
drifts into being kept. With no cart open, check says what moved and stages
nothing, because opening a round is somebody's act and not a side effect.
loomctl grants no access. It reads what your credentials already let you read,
and records where the copy came from — which is the part copy-and-paste loses.
It warns rather than refuses: adopting from a source less readable than the
repository you are adopting into is yours to decide, and the check only tells
public from not-public, so it cannot see two repositories private to different
people.
add adopts what is not here yet, and refuses what is already adopted. Given a
document that is present but unlocked — fetched by hand before this existed —
it supplies the missing origin: identical bytes lock it, differing bytes are
staged, and the local copy is never overwritten, because a copy that differs is
the only evidence that anything moved while nothing was watching.
Adopted documents live in .loom/externals/<host>/<owner>/<repo>/<name>.md, and
their origins in .loom/externals/.locks. The path is for a person to read; the
lock is what a machine uses, because the path does not round-trip to a URL.
loomctl orient [--out path] write .loom/orientation.md for whoever arrives next
loomctl config which context is current, and from where
A bare owner/repo is resolved against the current context in
~/.config/loomctl/config.json, which holds a host, its flavor and a read-only
token. The host's raw-file route lives there so that nobody has to type it, and
LOOMCTL_TOKEN_<HOST> overrides the file for one invocation. loomctl never writes
over the network, so a token it is given should never carry write scope.
{
"current-context": "hypertheory",
"contexts": {
"hypertheory": {
"host": "git.hypertheory-labs.dev",
"flavor": "gitea",
"token": "..."
}
}
}
Requires git on PATH, for list only.
`
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "loomctl: "+err.Error())
os.Exit(1)
}
}
func run(args []string) error {
if len(args) == 0 || args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
fmt.Print(usage)
return nil
}
switch args[0] {
case "external":
return runExternal(args[1:])
case "orient":
fs := flag.NewFlagSet("orient", flag.ContinueOnError)
out := fs.String("out", "", "write here instead of "+orient.File)
if err := fs.Parse(args[1:]); err != nil {
return err
}
root, err := root()
if err != nil {
return err
}
dest, err := orient.Generate(root, *out)
if err != nil {
return err
}
fmt.Fprintf(os.Stdout, "wrote %s\n", dest)
return nil
case "config":
return showConfig(os.Stdout)
default:
return fmt.Errorf("unknown command %q\n\n%s", args[0], usage)
}
}
func runExternal(args []string) error {
if len(args) == 0 {
return fmt.Errorf("external needs a subcommand: list, add, check, apply")
}
switch args[0] {
case "list":
if len(args) != 2 {
return fmt.Errorf("usage: loomctl external list <owner/repo>")
}
if strings.Contains(args[1], "://") {
return external.List(args[1], os.Stdout)
}
return external.ListByName(args[1], os.Stdout)
case "add":
fs := flag.NewFlagSet("add", flag.ContinueOnError)
path := fs.String("path", "", "where to keep it, relative to .loom/externals (default: derived from the URL)")
if err := fs.Parse(args[1:]); err != nil {
return err
}
root, err := root()
if err != nil {
return err
}
switch fs.NArg() {
case 1:
if !strings.Contains(fs.Arg(0), "://") {
return fmt.Errorf("adopting by name needs the document too: " +
"loomctl external add <owner/repo> <doc>")
}
return external.Add(root, fs.Arg(0), *path, os.Stdout)
case 2:
return external.AddByName(root, fs.Arg(0), fs.Arg(1), os.Stdout)
default:
return fmt.Errorf("usage: loomctl external add <owner/repo> <doc>, " +
"or loomctl external add <url> [--path p]")
}
case "check":
root, err := root()
if err != nil {
return err
}
return external.Check(root, os.Stdout)
case "apply":
root, err := root()
if err != nil {
return err
}
return external.Apply(root, args[1:], os.Stdout)
default:
return fmt.Errorf("unknown external subcommand %q", args[0])
}
}
// showConfig says which context is current and where it came from, so that "my
// token is not being used" is answerable without printing the token.
func showConfig(out io.Writer) error {
cfg, err := config.Load()
if err != nil {
return err
}
fmt.Fprintf(out, "config %s\n", config.Path())
if len(cfg.Contexts) == 0 {
fmt.Fprintln(out, " (none — public repositories still work by full URL)")
return nil
}
names := make([]string, 0, len(cfg.Contexts))
for n := range cfg.Contexts {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
c := cfg.Contexts[n]
marker := " "
if n == cfg.CurrentContext {
marker = "*"
}
flavor := c.Flavor
if flavor == "" {
flavor = "gitea (default)"
}
fmt.Fprintf(out, "%s %-14s %-32s %-16s %s\n", marker, n, c.Host, flavor, credential(cfg, c))
}
if _, err := cfg.Current(); err != nil {
fmt.Fprintf(out, "\n%v\n", err)
}
return nil
}
func credential(cfg *config.Config, c config.Context) string {
if os.Getenv("LOOMCTL_TOKEN") != "" {
return "token from LOOMCTL_TOKEN"
}
if cfg.TokenFor(c.Host) != "" {
if c.Token == "" {
return "token from the environment"
}
return "token set"
}
return "no token (anonymous)"
}
func root() (string, error) {
wd, err := os.Getwd()
if err != nil {
return "", err
}
return external.FindRoot(wd)
}