package main import ( "bufio" "context" "encoding/json" "fmt" "os" "strings" "github.com/urfave/cli/v3" ) var Version = "dev" func main() { cmd := &cli.Command{ Name: "record-deduper", Version: Version, Usage: "Tool for listing and deduplicating atproto records", Commands: []*cli.Command{ { Name: "list", Usage: "List atproto records for a specific handle", Flags: []cli.Flag{ &cli.StringFlag{ Name: "handle", Aliases: []string{"h"}, Usage: "The atproto handle or DID", Required: true, }, &cli.StringFlag{ Name: "collection", Aliases: []string{"c"}, Usage: "The record collection (e.g., app.bsky.feed.post)", Required: true, }, &cli.IntFlag{ Name: "limit", Aliases: []string{"l"}, Usage: "Maximum number of records to fetch per page", Value: 50, }, &cli.StringFlag{ Name: "cursor", Aliases: []string{"k"}, Usage: "Cursor to resume from", }, }, Action: listRecords, }, { Name: "delete", Usage: "Delete atproto records", Flags: []cli.Flag{ &cli.StringSliceFlag{ Name: "uri", Aliases: []string{"u"}, Usage: "The full record URIs (e.g., at://did:plc:xxx/collection/rkey)", }, &cli.StringFlag{ Name: "handle", Aliases: []string{"h"}, Usage: "Handle or DID for authentication", Required: true, }, &cli.StringFlag{ Name: "password", Aliases: []string{"p"}, Usage: "App password", Required: true, }, &cli.BoolFlag{ Name: "dry-run", Aliases: []string{"d"}, Usage: "Print what would be deleted without actually deleting", Value: true, }, }, Action: deleteRecord, }, }, } if err := cmd.Run(context.Background(), os.Args); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } } func listRecords(ctx context.Context, cmd *cli.Command) error { handle := cmd.String("handle") collection := cmd.String("collection") limit := cmd.Int("limit") cursor := cmd.String("cursor") recordCh, errCh := fetchRecords(ctx, handle, collection, limit, cursor) for { select { case record, ok := <-recordCh: if !ok { return nil } line, err := json.Marshal(record) if err != nil { return fmt.Errorf("failed to marshal record: %w", err) } fmt.Println(string(line)) case err := <-errCh: return err } } } func deleteRecord(ctx context.Context, cmd *cli.Command) error { uris := cmd.StringSlice("uri") handle := cmd.String("handle") password := cmd.String("password") dryRun := cmd.Bool("dry-run") stat, _ := os.Stdin.Stat() if stat != nil && (stat.Mode()&os.ModeCharDevice) == 0 { scanner := bufio.NewScanner(os.Stdin) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line != "" { uris = append(uris, line) } } if err := scanner.Err(); err != nil { return fmt.Errorf("failed to read stdin: %w", err) } } if len(uris) == 0 { return fmt.Errorf("no URIs provided (use --uri or pipe via stdin)") } if dryRun { for _, uri := range uris { fmt.Printf("[DRY RUN] Would delete: %s\n", uri) } return nil } client, err := createAuthenticatedClient(ctx, handle, password) if err != nil { return fmt.Errorf("failed to create client: %w", err) } return deleteRecords(ctx, client.client, uris) }