#!/usr/bin/env python3 """Open a folder as a project: adopt it, remember it, build its pages. bin/office [--project ] [--session NAME] [--dry] bin/office . # the folder you are in bin/office --new "Name" # create the Paperclip project too bin/office --remote --project "Name" # the folder is on another machine Adopting writes /.harness/ (the project file, check.sh, NOTES.md) and nothing else. An existing file is never overwritten. With --new, Paperclip gets a project for the folder, with the per-task worktree policy set; without it, --project adopts a project that already exists. A session is created on the daemon of the machine that runs this command, and the panes run on that machine. A program cannot move your client to the new session: TUIOS refuses that from a pane. Switch with TUIOS's session switcher, or `tuios attach --host `. The machine is remembered, so the flag is stated once. Running the same command on a machine that is not the recorded one is refused rather than silently producing a second copy of the same project. """ import json import os import shlex import socket import subprocess import sys import urllib.parse HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(HERE, "lib")) import harness as h # noqa: E402 import project as pj # noqa: E402 import repo as rp # noqa: E402 import tuios # noqa: E402 def parse(argv): """Read the flags, then take what is left as the folder. A flag's value is not a folder. The first version of this treated the value of --remote as the folder, which is exactly the kind of bug that only shows up when someone uses the flag. """ opts = {"folder": ".", "folder_given": False, "project": "", "session": "", "dry": False, "host": "", "no_switch": False, "new": "", "remote": "", "git_init": "--git-init" in argv, "anywhere": "--anywhere" in argv} positional = [] i = 0 while i < len(argv): a = argv[i] if a in ("--project", "--session", "--host", "--new", "--remote"): if i + 1 < len(argv): opts[a[2:]] = argv[i + 1] i += 2 continue i += 1 continue if a == "--dry": opts["dry"] = True elif a == "--no-switch": opts["no_switch"] = True elif not a.startswith("-"): positional.append(a) i += 1 if positional: opts["folder"] = positional[0] opts["folder_given"] = True return opts def create_project(name, folder, dry): """Create a Paperclip project for a folder, with the worktree policy set. Adopting a folder that Paperclip has never heard of needed two steps before: make the project in the browser, then run this command. One command is enough. """ # A folder that is a git repository gets a branch per task in its own # worktree. A folder that is not cannot: asking for a worktree would create # a project whose every run fails. Those keep the folder in place instead. is_repo = rp.is_repo(folder) and rp.has_commit(folder) body = { "name": name, "description": "Adopted from %s by bin/office." % folder, "status": "backlog", "workspace": { "name": name, "sourceType": "git_repo" if is_repo else "local_path", "cwd": folder, "isPrimary": True, }, } if is_repo: body["executionWorkspacePolicy"] = { "enabled": True, "defaultMode": "isolated_workspace", "allowIssueOverride": True, "workspaceStrategy": {"type": "git_worktree", "baseRef": rp.branch(folder), "branchTemplate": "{{issue.identifier}}-{{slug}}"}, } else: h.say(" not a git repository: the project keeps the folder in place,") h.say(" so a task changes these files directly. No branch, no worktree.") if dry: h.say("would create Paperclip project %r for %s" % (name, folder)) return "(dry run)" try: created = h.api("POST", "/api/companies/%s/projects" % h.COMPANY, body) except h.Refused as exc: # A project with that name may already exist; adopting it is the right # thing to do then, not a failure. try: for p in h.api("GET", "/api/companies/%s/projects" % h.COMPANY): if p.get("name") == name: h.say("project: %s (already in Paperclip)" % name) return p["id"] except h.Refused: pass raise exc h.say("project: %s created (%s)" % (name, created.get("id"))) return created.get("id") def ensure_session(session, folder): """Make the session exist. It cannot be shown from here, and that is fine. Moving a person's TUIOS client to another session is refused when the call comes from a pane — the daemon will not let a program change what the person is looking at. Creating the session and building its panes works, so the only step left for the person is switching to it, which TUIOS has a key for. """ if session in tuios.sessions(): h.say("session: %s (already there)" % session) return params = {"name": session, "cwd": folder, "window": False} out = subprocess.run(["tuios", "call", "new-session", json.dumps(params)], capture_output=True, text=True, timeout=60) if out.returncode != 0 and "session_exists" not in (out.stderr or out.stdout): raise h.Refused(3, "could not create session %r: %s" % (session, (out.stderr or out.stdout).strip()[:300])) h.say("session: %s (created; switch to it with TUIOS's session switcher)" % session) def scaffold(folder, name): """Write the files this project keeps, and only the ones that are missing. Anything already there is left exactly as it is: adopting a folder must never overwrite a person's own notes or scripts. """ written = [] harness = os.path.join(folder, ".harness") os.makedirs(harness, exist_ok=True) check = os.path.join(harness, "check.sh") if not os.path.exists(check): with open(check, "w") as f: f.write("""#!/bin/sh # This project's own check, run by bin/merge before and after it puts a task's # work on the base branch. Exit 0 means "the project still passes". # # It does not decide that a task is finished: an agent sets a task's status, and # the review decides whether the change is good. This is the regression check, # the same thing a CI pipeline would run. # # Leave it as it is while the project has no tests. bin/merge will say there is # no check and let the review decide. Replace the body when tests exist: # # exec go test ./... # exec make test echo "this project has no check yet" exit 0 """) os.chmod(check, 0o755) written.append(check) notes = os.path.join(harness, "NOTES.md") if not os.path.exists(notes): with open(notes, "w") as f: f.write("# %s\n\nWhat this project is, what finished looks like, and anything a person\n" "picking it up next week needs to know. Notes only: tasks live in Paperclip.\n" % name) written.append(notes) return written # Where the commands live on a machine that is driven over ssh. REMOTE_BIN = "~/.local/share/paperclip-harness/bin" HOSTS = { "nixos-desktop": "192.168.20.13", ".13": "192.168.20.13", "13": "192.168.20.13", "sam-4screen-desktop": "192.168.20.27", "sam-4screen-desktop-1": "192.168.20.27", ".27": "192.168.20.27", "27": "192.168.20.27", "sam-thinkpad": "192.168.20.51", ".51": "192.168.20.51", "51": "192.168.20.51", } def local_addresses(): """The addresses and names that mean this machine.""" names = {socket.gethostname(), socket.gethostname().split(".")[0]} try: out = subprocess.run(["ip", "-4", "-o", "addr", "show", "scope", "global"], capture_output=True, text=True, timeout=10).stdout for line in out.splitlines(): parts = line.split() if len(parts) >= 4: names.add(parts[3].split("/")[0]) except Exception: pass return names def resolve_host(name): """A host name, or the address ssh can actually reach for it.""" if not name: return "" return HOSTS.get(name, name) def is_this_machine(name): addr = resolve_host(name) return addr in local_addresses() def install_wrapper(addr, remote_bin): """Leave a short `office` on the other machine's PATH, pointing at the copy.""" script = ("#!/bin/sh\n" "# Written by office on another machine. Runs the copy it keeps here.\n" 'exec "$HOME/.local/share/paperclip-harness/bin/office" "$@"\n') subprocess.run(["ssh", "-o", "BatchMode=yes", addr, "mkdir -p \"$HOME/.local/bin\" && cat > \"$HOME/.local/bin/office\" && chmod +x \"$HOME/.local/bin/office\""], input=script, text=True, capture_output=True, timeout=60) def run_on_remote(host, folder, opts): """Adopt a folder that lives on another machine, from where you are sitting. The session has to be made by the daemon that will hold the panes, and the panes have to run on the machine that owns the folder. So the work is done over ssh on that machine, and only the last step — showing the session — is the client's own business. """ addr = resolve_host(host) probe = subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", addr, "true"], capture_output=True, text=True, timeout=30) if probe.returncode != 0: raise h.Refused(3, "cannot log in to %s (%s). Set up a key first: ssh-copy-id %s" % (host, addr, addr)) # The other machine needs the commands. They are copied to one fixed place # there rather than assumed to be in the same folder: a machine that has # never seen this repository still gets a working `office`. Copying every # time is what keeps the two in step, and it is one small folder. here = os.path.dirname(os.path.realpath(__file__)) remote_bin = REMOTE_BIN # rsync will not make two levels of directory, so make the parent first. subprocess.run(["ssh", "-o", "BatchMode=yes", addr, "mkdir -p \"$HOME/.local/share/paperclip-harness\""], capture_output=True, text=True, timeout=60) copy = subprocess.run( ["rsync", "-a", "--delete", "--exclude", "__pycache__", "--exclude", "*.pyc", here + "/", "%s:%s/" % (addr, remote_bin)], capture_output=True, text=True, timeout=300) if copy.returncode != 0: raise h.Refused(3, "could not copy the commands to %s: %s" % (host, (copy.stderr or copy.stdout).strip()[:300])) install_wrapper(addr, remote_bin) inner = [folder, "--no-switch"] for flag, value in (("--project", opts["project"]), ("--session", opts["session"]), ("--new", opts["new"])): if value: inner += [flag, value] guard_machine(folder, opts) ready = prepare_git(folder, opts) if opts["dry"]: inner.append("--dry") # "$HOME" must stay unquoted so the other machine's shell expands it; every # argument after it is quoted, so a folder with a space still arrives whole. cmd = 'exec "$HOME/.local/share/paperclip-harness/bin/office" ' + \ " ".join(shlex.quote(a) for a in inner) h.say("on %s: %s" % (host, cmd)) out = subprocess.run(["ssh", "-o", "StrictHostKeyChecking=accept-new", addr, cmd], capture_output=True, text=True, timeout=600) sys.stdout.write(out.stdout) if out.returncode != 0: raise h.Refused(3, "the other machine refused: %s" % (out.stderr or out.stdout).strip()[:300]) h.say("") h.say("switch to it: tuios attach --host %s %s" % (host, opts["session"] or os.path.basename(folder))) return 0 USAGE = """office — open a folder as a project, and say what else can be run THE RULE The folder and the session live on the same machine. If you are typing inside the session, on the machine that owns the folder, it is just: office Use --host only when the folder is on a different machine from your shell. WHAT IT DOES 1. reads the folder 2. if a machine is named, checks whether that is this machine, and runs locally if so 3. if it really is another machine, checks the login and runs the same command there 4. adopts the Paperclip project, or creates it with --new 5. writes what is missing in /.harness/ and never overwrites 6. records the project in ~/.config/paperclip-harness/projects.toml 7. creates the session on that machine's daemon, then builds eight panes COMMANDS office --project "" adopt a project Paperclip already has office --new "" create the Paperclip project as well office . adopt the folder you are standing in office --session name the session instead of the folder office --project "" --host the folder is on that machine office --new "" --dry say what would happen, write nothing office --new "" --git-init make the folder a repository first office --new "" --anywhere allow it although agents cannot see it board create any missing pane in this project's session board --folder the same, for another project board --replace close this project's panes, then build all eight again board --reset close every pane in the session, then build project-reset forget the session and start this project again merge rebase, check, review and merge one task (asks first) review [--post] run ocr over one task's diff, and record the verdict triage [--ask next|failure] ask Mouse-1 one question about a task MACHINES (name, or the address ssh reaches) nixos-desktop .13 192.168.20.13 hosts Paperclip, the daemon, the main session sam-4screen-desktop .27 192.168.20.27 your working desktop sam-thinkpad .51 192.168.20.51 the laptop EVERY MACHINE, FROM THE TERMINAL On .13, a folder that is on .13: office ~/paseo/projects/test-alpha --project "test-alpha" On .27, adopting a folder that lives on .13: office /home/sam/paseo/projects/email_agent --project "Email Agent" --host nixos-desktop On .27, a folder that is on .27 (stays local, no --host): office ~/src/thing --new "thing" On .51, the same folder on .51: office ~/src/thing --project "thing" --host sam-thinkpad Then attach. Nothing may move your client from inside a pane, so the last step is yours: tuios attach on this machine tuios attach --host nixos-desktop to a session on another machine WORKTREES, AND WHY GIT MATTERS A new project gets a worktree policy: Paperclip makes a branch and a separate directory per task, so two tasks never change the same files, and there is a diff to review and a merge to approve. That needs a repository with at least one commit. When the folder is not one, office says so and asks before touching anything: making a repository means committing every file in the folder, which is a decision about your own files. Without it the project is created in place (local_path): a task edits the folder directly, there is no diff and nothing to merge. The base branch is read from the folder, not assumed to be main. WHERE THINGS RUN Panes run on the machine that owns the folder. Agents run on the machine that serves Paperclip, because Paperclip starts them itself. If those differ, the panes work and the agents cannot see the files, so --new refuses unless --anywhere is given. ON ANOTHER MACHINE --host copies this folder of commands to the other machine, at ~/.local/share/paperclip-harness/bin/ and leaves a short ~/.local/bin/office there, so it can be run from that machine too. Nothing has to be installed by hand. On .27 and .51 ~/.local/bin is not on PATH yet, so there you call it by its full path: ~/.local/share/paperclip-harness/bin/office AFTER A REBOOT TUIOS restores a session with a fresh shell in every pane and keeps the pane names. Run board once for that project: it checks what each pane is actually running and replaces any pane that is only a prompt. KEYS INSIDE THE PANES ( ) change tab tab change panel enter open / choose r re-read Paperclip R rebuild all panes ? list the keys h j k l z n and digits belong to TUIOS and never reach the program. MORE docs/STARTUP.md these commands, the rule, and the three rebuild levels docs/WORKSPACES.md what each of the eight panes is for docs/CLEAN-TESTS.md the two end-to-end tests and what they measured """ def paperclip_machine(): """The machine that serves Paperclip, as a name we know, or the address.""" addr = urllib.parse.urlparse(h.API).hostname or "" if addr in ("127.0.0.1", "localhost", "::1"): return pj.this_machine() for name, known in HOSTS.items(): if known == addr: return name return addr def guard_machine(folder, opts): """Say plainly when the panes and the agents would be on different machines. Panes run where the folder is. Agents run where Paperclip is, because Paperclip starts them itself. A folder on the laptop therefore gives panes that work and agents that cannot find the files. That is worth stopping for when a project is being created; an existing project may still be worth opening just to read it. """ if not opts["new"] and not opts["anywhere"]: # Adopting: a note is enough, and only when it is true. pass mine = HOSTS.get(pj.this_machine(), pj.this_machine()) theirs = HOSTS.get(paperclip_machine(), paperclip_machine()) if mine == theirs: return text = [ "this folder is on %s, but Paperclip and its agents run on %s" % (pj.this_machine(), paperclip_machine()), "the panes will open here and will read this folder correctly", "the agents will not: they start on %s, where %s does not exist" % (paperclip_machine(), folder), "a task you assign will fail, or work on a different folder", "move the folder to %s, mount it there, or pass --anywhere to go ahead anyway" % paperclip_machine(), ] if opts["new"] and not opts["anywhere"]: raise h.Refused(2, "\n".join(text)) for i, line in enumerate(text): h.say(("warning: " if i == 0 else " ") + line) def prepare_git(folder, opts): """Make the folder able to hold a worktree per task, and say what it did. A worktree needs a repository and at least one commit. Neither is created silently: a first commit is a decision about a person's own files, and for a folder of notes it may include things that should not be in a history at all. Returns True when the folder is ready for worktrees. """ if rp.is_repo(folder) and rp.has_commit(folder): return True if rp.is_repo(folder) and not rp.has_commit(folder): what = "the repository at %s has no commit yet" % folder else: what = "%s is not a git repository" % folder if opts["dry"]: return False h.say("%s, so each task cannot have its own branch and worktree." % what) h.say(" without them, two tasks can change the same files at once, and there") h.say(" is no diff to review and nothing to merge.") if opts["git_init"]: go = True elif sys.stdin.isatty(): h.say("") h.say("make it a repository now? This runs: git init -b main, git add -A,") h.say("and one commit of EVERY file in the folder, including anything you") h.say("have not noticed. Add a .gitignore first if that is not what you want.") answer = input(" type yes to do it: ").strip().lower() go = answer in ("y", "yes") else: go = False h.say("") h.say("not a terminal, so nothing was changed. Either run this again and") h.say("answer the question, or do it yourself:") h.say(" cd %s" % folder) if rp.is_repo(folder): h.say(" git add -A && git commit -m 'Initial commit'") else: h.say(" git init -b main && git add -A && git commit -m 'Initial commit'") h.say("or pass --git-init to let this command do it.") if not go: return False ok, why = rp.make_repo(folder) if not ok: h.say(" could not: %s" % why) return False return True def main(): argv = sys.argv[1:] if not argv or "-h" in argv or "--help" in argv: h.say(USAGE.strip()) return 0 opts = parse(argv) if not opts["folder_given"]: # "." is a real folder: `office . --project ` adopts the folder you # are standing in. Only a missing argument is an error. raise h.Refused(2, "give the folder: office --project \"\" (office --help)") folder = os.path.abspath(os.path.expanduser(opts["folder"])) # The folder is only checked here when it is this machine's folder. A folder # on another machine is checked there: asking this machine about it says "no # folder", which is true and useless. wanted = opts["host"] or opts["remote"] if not (wanted and not is_this_machine(wanted)) and not os.path.isdir(folder): raise h.Refused(2, "no folder at %s" % folder) host = opts["host"] or pj.this_machine() if opts["host"] and not is_this_machine(opts["host"]): return run_on_remote(opts["host"], folder, opts) if opts["host"]: h.say("host: %s (this machine)" % opts["host"]) # An adopted project already knows its session. Only a new one needs a # default, and a default that names a session nobody made is worse than an # error, so the session is checked before anything is written. existing = {} if pj.exists(folder): existing = pj.load(folder) session = ( opts["session"] or existing.get("session") or os.path.basename(folder) ) # The session is created if it does not exist and the client is moved to it, # so one command takes a person from a folder to a working layout. TUIOS has # the verb for exactly this; asking the person to run `tuios new` first was # the step everyone forgot. if opts["remote"]: if is_this_machine(opts["remote"]): h.say("host: %s (this machine)" % opts["remote"]) opts["remote"] = "" else: return run_on_remote(opts["remote"], folder, opts) guard_machine(folder, opts) ready = prepare_git(folder, opts) if opts["dry"]: if opts["new"]: h.say("would create a Paperclip project %r for %s" % (opts["new"], folder)) else: h.say("would adopt the Paperclip project %r for %s" % (opts["project"] or "", folder)) if ready: h.say("worktrees: yes — a repository on branch %r, so each task gets its own" % rp.branch(folder)) else: h.say("worktrees: no — %s" % ("this is not a repository" if not rp.is_repo(folder) else "the repository has no commit")) h.say("would write %s" % os.path.join(folder, ".harness", "harness.toml")) h.say("would create or reuse session %r and build eight panes there" % session) h.say("dry run: nothing was written") return 0 if opts["host"] and opts["host"] not in (pj.this_machine(), pj.this_machine().split(".")[0]): # Only a note, because the panes are built by the machine that owns them. h.say("host: %s (build the pages there)" % host) if opts["new"]: ref = create_project(opts["new"], folder, opts["dry"]) if not ref: return 1 opts["project"] = ref ref = opts["project"] if not ref and pj.exists(folder): ref = pj.load(folder)["paperclip"]["project"] if not ref: raise h.Refused( 2, "which Paperclip project is %s? pass --project " % folder, ) proj = pj.resolve_project(ref) h.say("project: %s (%s)" % (proj["name"], proj["id"])) h.say("folder: %s" % folder) h.say("session: %s" % session) h.say("machine: %s" % host) tasks = pj.active_tasks(proj["id"]) watching = next((t["identifier"] for t in tasks if t["status"] not in ("done", "cancelled")), tasks[0]["identifier"] if tasks else "") cfg = { "session": session, "paperclip": {"company": h.COMPANY, "project": proj["id"]}, "machine": {"host": host, "folder": folder}, "watching": watching, } guard_machine(folder, opts) ready = prepare_git(folder, opts) if opts["dry"]: h.say("would write %s" % pj.path(folder)) h.say("would build pages: shell, tree, reply, log, new") return 0 pj.save(folder, cfg) h.say("wrote %s" % pj.path(folder)) if not opts["no_switch"]: ensure_session(session, folder) pj.register(cfg, proj["name"]) h.say("recorded in %s" % pj.REGISTRY) written = scaffold(folder, proj["name"]) for path in written: h.say("wrote %s" % path) # The pages are built by this machine, from this folder, in this session. code = os.spawnv( os.P_WAIT, sys.executable, [sys.executable, os.path.join(HERE, "board"), "--folder", folder], ) return code if __name__ == "__main__": h.main_guard(main)