// Command harness is the terminal front end for the Paperclip coding factory. // // One screen per run. Each screen is meant to run in its own TUIOS pane, on a // workspace named after it; the layout script in bin/ opens and adopts them. // This file only wires flags. The screens live in model.go and reply.go. package main import ( "flag" "fmt" "os" tea "github.com/charmbracelet/bubbletea" "paperclip-harness/tui/paperclip" ) // defaultAgent is Builder, the agent this project assigns work to. const defaultAgent = "403de1f7-645c-4d35-bc7e-19b01d77bacc" func main() { screen := flag.String("screen", "board", "which screen to run: board, work, reply, new, agents, project, repair") folder := flag.String("folder", "", "project folder, for the board screen (defaults to the current directory)") light := flag.String("theme", "", "colour variant: dark, light, or empty to follow the terminal") ascii := flag.Bool("ascii", false, "plain characters instead of Nerd Font icons") agent := flag.String("agent", defaultAgent, "agent id to assign to, default Builder") project := flag.String("project", "", "project id or url key, for the tree screen") issue := flag.String("issue", "", "task id or identifier such as AUD-160, for the reply screen") dump := flag.Bool("dump", false, "print the tree as plain text and exit (no full-screen interface)") flag.Parse() choosePalette(*light) chooseGlyphs(*ascii) client, err := paperclip.New() if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } if !client.UsingUserKey() { fmt.Fprintln(os.Stderr, "warning: PAPERCLIP_BOARD_KEY is not set, so replies and status changes will be refused") } var m tea.Model switch *screen { case "board": proj, err := client.ResolveProject(*project) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } dir := *folder if dir == "" { dir, _ = os.Getwd() } m = newBoardModel(client, proj, dir) case "agents": dir := *folder if dir == "" { dir, _ = os.Getwd() } m = newAgentsModel(client, dir) case "project": dir := *folder if dir == "" { dir, _ = os.Getwd() } m = newProjectModel(client, dir) case "repair": dir := *folder if dir == "" { dir, _ = os.Getwd() } m = newRepairModel(client, dir) case "work": dir := *folder if dir == "" { dir, _ = os.Getwd() } issue := *issue if issue == "" { issue = watchedTask(dir) } m = newWorkModel(client, dir, issue) case "tree": proj, err := client.ResolveProject(*project) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } if *dump { if err := dumpTree(client, proj); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } return } m = newModel(client, proj, *agent) case "new": proj, err := client.ResolveProject(*project) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } m = newTaskForm(client, proj, *agent) case "log": task, err := resolveIssue(client, *issue) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } m = newLogModel(client, task.ID) case "reply": dir := *folder if dir == "" { dir, _ = os.Getwd() } id := *issue if id != "" { task, err := resolveIssue(client, id) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } id = task.ID } m = newReplyModel(client, dir, id) default: fmt.Fprintf(os.Stderr, "unknown screen %q: use board, work, reply, new, agents, project or repair\n", *screen) os.Exit(1) } // Alternate screen: TUIOS keeps its own scrollback, and a full-screen // program that redraws in place must not fight it. p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) if _, err := p.Run(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } // resolveIssue accepts a task id or an identifier such as AUD-160. func resolveIssue(c *paperclip.Client, ref string) (paperclip.Issue, error) { if ref == "" { return paperclip.Issue{}, fmt.Errorf("no task given: use -issue AUD-160 or a task id") } if len(ref) == 36 { // looks like a uuid; ask directly if is, err := c.Issue(ref); err == nil && is.ID != "" { return is, nil } } issues, err := c.Issues() if err != nil { return paperclip.Issue{}, err } for _, is := range issues { if is.Identifier == ref || is.ID == ref { return is, nil } } return paperclip.Issue{}, fmt.Errorf("no task matches %q", ref) } // dumpTree prints the tree without starting the interface. It is how the data // path is checked from a script or over ssh. func dumpTree(c *paperclip.Client, proj paperclip.Project) error { rows, err := loadRows(c, proj) if err != nil { return err } fmt.Printf("%s (%s)\n\n", proj.Name, proj.Status) for _, g := range groupOrder { any := false for _, r := range rows { if r.Group != g { continue } if !any { fmt.Printf("%s\n", g) any = true } fmt.Printf(" %-8s %-12s %-14s %s\n", r.Issue.Identifier, r.Issue.Status, r.Who, r.Issue.Title) if r.Blocker != nil && r.Blocker.UnresolvedCount > 0 { fmt.Printf(" blocked by %v\n", r.Blocker.BlockingIdentities) } } if any { fmt.Println() } } return nil }