package cmd import ( "flag" "fmt" "os" "regexp" "strconv" "strings" "git.noodles.cam/claude-skills/marketplace/cli/internal/issue" ) var numberRe = regexp.MustCompile(`^\d+$`) func init() { register(&Command{ Name: "ac", Group: GroupIssue, Args: "", Short: "list and tick an issue's checkboxes", Long: `A checkbox is the one part of a body that is *state* and not prose. Everything else is written once; boxes get ticked as the work goes, and the only other ways to tick one are a human with an editor or a model rewriting the whole body — the second worse than the first, because the rewrite re-flows the text and the issue's diff swells around a change of one character. This changes that one character and nothing else. Named after ` + "`## Acceptance criteria`" + `, where most boxes live, but every checkbox in the body is listed and tickable: a type/feature keeps its children under ` + "`## Issues`" + `, and binding this to one heading would silently lose half of them. A substring picks an item only when it picks exactly one. Two matches is an error listing both — a coin flip would tick the wrong box and look like it worked. Delivering the changed body to a tracker is not part of this; that is ` + "`kettle push --update`" + `.`, Examples: []Example{ {"kettle ac wire-sqlc-appclick", "numbered list with state"}, {"kettle ac wire-sqlc-appclick --check 3", "tick by number"}, {"kettle ac wire-sqlc-appclick --check регресс", "tick by substring"}, {"kettle ac wire-sqlc-appclick --uncheck 3", "untick it again"}, }, Setup: func(fs *flag.FlagSet) func([]string) error { check := fs.String("check", "", "tick one item: number or substring") uncheck := fs.String("uncheck", "", "untick one item: number or substring") out := storeFlag(fs) return func(args []string) error { if len(args) != 1 { return Fail("give exactly one issue id") } checking, unchecking := wasSet(fs, "check"), wasSet(fs, "uncheck") if checking && unchecking { return Fail("--check and --uncheck are mutually exclusive") } id := args[0] root, err := storeRoot(*out) if err != nil { return err } path := issue.PathOf(root, id) // Raw bytes in and raw bytes out: byte-for-byte means the line // endings too. Reading a CRLF file with translation and writing // it back would rewrite every line while claiming to have // changed one character. raw, err := os.ReadFile(path) if err != nil { return Fail("no issue %q in %s", id, root) } text := string(raw) // The whole file, not just the body: line numbers then point at // the file, and the metadata block is rewritten by nobody. // Round-tripping through the parser would re-render metadata and // re-strip the body, which is exactly the churn this avoids. items := issue.Checkboxes(text) needle, checked := *check, true if unchecking { needle, checked = *uncheck, false } selecting := checking || unchecking if len(items) == 0 { if selecting { return Fail("%s has no checkboxes", id) } fmt.Printf("%s — no checkboxes\n", id) return nil } if !selecting { done, total := issue.CheckboxProgress(text) fmt.Printf("%s — %d/%d %s\n", id, done, total, path) fmt.Println(strings.Join(listing(items), "\n")) return nil } item, err := selectItem(items, needle) if err != nil { return err } updated, err := issue.SetCheckbox(text, item.Line, checked) if err != nil { return err } if updated == text { fmt.Printf("unchanged %2d %s %s\n", item.Index, box(item.Checked), item.Text) return nil } if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { return err } if _, _, err := issue.BuildIndex(root); err != nil { return err } verb := "checked" if !checked { verb = "unchecked" } done, total := issue.CheckboxProgress(updated) fmt.Printf("%s %2d %s %s\n", verb, item.Index, box(checked), item.Text) fmt.Printf("%s — %d/%d %s:%d\n", id, done, total, path, item.Line) return nil } }, }) } func box(checked bool) string { if checked { return "[x]" } return "[ ]" } // listing is the numbered list, grouped by the heading each item sits under. func listing(items []issue.Checkbox) []string { var out []string section := "\x00" // no heading can equal this, so the first item opens a group for _, c := range items { if c.Section != section { section = c.Section head := section if head == "" { head = "(above the first heading)" } out = append(out, "", head) } out = append(out, fmt.Sprintf(" %2d %s %s", c.Index, box(c.Checked), c.Text)) } return out } // selectItem resolves a --check/--uncheck argument to exactly one item. func selectItem(items []issue.Checkbox, needle string) (issue.Checkbox, error) { needle = strings.TrimSpace(needle) if needle == "" { return issue.Checkbox{}, Fail("empty selector — give an item number or a substring") } if numberRe.MatchString(needle) { n, _ := strconv.Atoi(needle) if n < 1 || n > len(items) { return issue.Checkbox{}, Fail("no item %d — the issue has %d", n, len(items)) } return items[n-1], nil } var hits []issue.Checkbox for _, c := range items { if strings.Contains(strings.ToLower(c.Text), strings.ToLower(needle)) { hits = append(hits, c) } } switch len(hits) { case 0: return issue.Checkbox{}, Fail("nothing matches %q", needle) case 1: return hits[0], nil } lines := []string{fmt.Sprintf("%q matches %d items — narrow it down, or use a number:", needle, len(hits))} for _, c := range hits { lines = append(lines, fmt.Sprintf(" %2d %s %s", c.Index, box(c.Checked), c.Text)) } return issue.Checkbox{}, Fail("%s", strings.Join(lines, "\n")) }