Small tool to make it convenient to deduplicate records in collections, use goat instead.
cli
atproto
go
1package main
2
3import (
4 "bufio"
5 "context"
6 "encoding/json"
7 "fmt"
8 "os"
9 "strings"
10
11 "github.com/urfave/cli/v3"
12)
13
14var Version = "dev"
15
16func main() {
17 cmd := &cli.Command{
18 Name: "record-deduper",
19 Version: Version,
20 Usage: "Tool for listing and deduplicating atproto records",
21 Commands: []*cli.Command{
22 {
23 Name: "list",
24 Usage: "List atproto records for a specific handle",
25 Flags: []cli.Flag{
26 &cli.StringFlag{
27 Name: "handle",
28 Aliases: []string{"h"},
29 Usage: "The atproto handle or DID",
30 Required: true,
31 },
32 &cli.StringFlag{
33 Name: "collection",
34 Aliases: []string{"c"},
35 Usage: "The record collection (e.g., app.bsky.feed.post)",
36 Required: true,
37 },
38 &cli.IntFlag{
39 Name: "limit",
40 Aliases: []string{"l"},
41 Usage: "Maximum number of records to fetch per page",
42 Value: 50,
43 },
44 &cli.StringFlag{
45 Name: "cursor",
46 Aliases: []string{"k"},
47 Usage: "Cursor to resume from",
48 },
49 },
50 Action: listRecords,
51 },
52 {
53 Name: "delete",
54 Usage: "Delete atproto records",
55 Flags: []cli.Flag{
56 &cli.StringSliceFlag{
57 Name: "uri",
58 Aliases: []string{"u"},
59 Usage: "The full record URIs (e.g., at://did:plc:xxx/collection/rkey)",
60 },
61 &cli.StringFlag{
62 Name: "handle",
63 Aliases: []string{"h"},
64 Usage: "Handle or DID for authentication",
65 Required: true,
66 },
67 &cli.StringFlag{
68 Name: "password",
69 Aliases: []string{"p"},
70 Usage: "App password",
71 Required: true,
72 },
73 &cli.BoolFlag{
74 Name: "dry-run",
75 Aliases: []string{"d"},
76 Usage: "Print what would be deleted without actually deleting",
77 Value: true,
78 },
79 },
80 Action: deleteRecord,
81 },
82 },
83 }
84
85 if err := cmd.Run(context.Background(), os.Args); err != nil {
86 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
87 os.Exit(1)
88 }
89}
90
91func listRecords(ctx context.Context, cmd *cli.Command) error {
92 handle := cmd.String("handle")
93 collection := cmd.String("collection")
94 limit := cmd.Int("limit")
95 cursor := cmd.String("cursor")
96
97 recordCh, errCh := fetchRecords(ctx, handle, collection, limit, cursor)
98
99 for {
100 select {
101 case record, ok := <-recordCh:
102 if !ok {
103 return nil
104 }
105 line, err := json.Marshal(record)
106 if err != nil {
107 return fmt.Errorf("failed to marshal record: %w", err)
108 }
109 fmt.Println(string(line))
110 case err := <-errCh:
111 return err
112 }
113 }
114}
115
116func deleteRecord(ctx context.Context, cmd *cli.Command) error {
117 uris := cmd.StringSlice("uri")
118 handle := cmd.String("handle")
119 password := cmd.String("password")
120 dryRun := cmd.Bool("dry-run")
121
122 stat, _ := os.Stdin.Stat()
123 if stat != nil && (stat.Mode()&os.ModeCharDevice) == 0 {
124 scanner := bufio.NewScanner(os.Stdin)
125 for scanner.Scan() {
126 line := strings.TrimSpace(scanner.Text())
127 if line != "" {
128 uris = append(uris, line)
129 }
130 }
131 if err := scanner.Err(); err != nil {
132 return fmt.Errorf("failed to read stdin: %w", err)
133 }
134 }
135
136 if len(uris) == 0 {
137 return fmt.Errorf("no URIs provided (use --uri or pipe via stdin)")
138 }
139
140 if dryRun {
141 for _, uri := range uris {
142 fmt.Printf("[DRY RUN] Would delete: %s\n", uri)
143 }
144 return nil
145 }
146
147 client, err := createAuthenticatedClient(ctx, handle, password)
148 if err != nil {
149 return fmt.Errorf("failed to create client: %w", err)
150 }
151
152 return deleteRecords(ctx, client.client, uris)
153}