bluesky viewer in the terminal
1package main
2
3import (
4 "context"
5 "fmt"
6 "regexp"
7 "strings"
8
9 "github.com/google/uuid"
10 "github.com/stormlightlabs/skypanel/cli/internal/registry"
11 "github.com/stormlightlabs/skypanel/cli/internal/setup"
12 "github.com/stormlightlabs/skypanel/cli/internal/store"
13 "github.com/stormlightlabs/skypanel/cli/internal/ui"
14 "github.com/urfave/cli/v3"
15)
16
17// ViewFeedAction views posts from a feed (fetches from API)
18func ViewFeedAction(ctx context.Context, cmd *cli.Command) error {
19 if err := setup.EnsurePersistenceReady(ctx); err != nil {
20 return fmt.Errorf("persistence layer not ready: %w", err)
21 }
22
23 reg := registry.Get()
24
25 if cmd.Args().Len() == 0 {
26 return fmt.Errorf("feed URI or local feed ID required")
27 }
28
29 feedIdentifier := cmd.Args().First()
30 limit := cmd.Int("limit")
31 cursor := cmd.String("cursor")
32 asJSON := cmd.Bool("json")
33
34 service, err := reg.GetService()
35 if err != nil {
36 return fmt.Errorf("failed to get service: %w", err)
37 }
38
39 if !service.Authenticated() {
40 return fmt.Errorf("not authenticated: run 'skycli login' first")
41 }
42
43 feedRepo, err := reg.GetFeedRepo()
44 if err != nil {
45 return fmt.Errorf("failed to get feed repository: %w", err)
46 }
47
48 var feedURI string
49
50 if _, err := uuid.Parse(feedIdentifier); err == nil {
51 feed, err := feedRepo.Get(ctx, feedIdentifier)
52 if err != nil {
53 return fmt.Errorf("failed to get local feed: %w", err)
54 }
55 if feedModel, ok := feed.(*store.FeedModel); ok {
56 feedURI = feedModel.Source
57 logger.Debug("Resolved local feed ID to URI", "id", feedIdentifier, "uri", feedURI)
58 }
59 } else {
60 feedURI = feedIdentifier
61 }
62
63 logger.Debug("Fetching feed from API", "uri", feedURI, "limit", limit, "cursor", cursor)
64
65 response, err := service.GetAuthorFeed(ctx, feedURI, limit, cursor)
66 if err != nil {
67 return fmt.Errorf("failed to fetch feed: %w", err)
68 }
69
70 if asJSON {
71 return ui.DisplayJSON(response)
72 }
73
74 ui.Titleln("Feed: %s", feedURI)
75 ui.DisplayFeed(response.Feed, response.Cursor)
76 return nil
77}
78
79// ViewPostAction views a single post by URI or URL
80func ViewPostAction(ctx context.Context, cmd *cli.Command) error {
81 if err := setup.EnsurePersistenceReady(ctx); err != nil {
82 return fmt.Errorf("persistence layer not ready: %w", err)
83 }
84
85 reg := registry.Get()
86
87 if cmd.Args().Len() == 0 {
88 return fmt.Errorf("post URI or URL required")
89 }
90
91 postIdentifier := cmd.Args().First()
92 asJSON := cmd.Bool("json")
93
94 service, err := reg.GetService()
95 if err != nil {
96 return fmt.Errorf("failed to get service: %w", err)
97 }
98
99 if !service.Authenticated() {
100 return fmt.Errorf("not authenticated: run 'skycli login' first")
101 }
102
103 postURI, err := parsePostIdentifier(postIdentifier)
104 if err != nil {
105 return fmt.Errorf("failed to parse post identifier: %w", err)
106 }
107
108 logger.Debug("Fetching post", "uri", postURI)
109
110 response, err := service.GetPosts(ctx, []string{postURI})
111 if err != nil {
112 return fmt.Errorf("failed to fetch post: %w", err)
113 }
114
115 if len(response.Posts) == 0 {
116 return fmt.Errorf("post not found: %s", postURI)
117 }
118
119 if asJSON {
120 return ui.DisplayJSON(response.Posts[0])
121 }
122
123 ui.Titleln("Post View")
124 ui.DisplayFeed([]store.FeedViewPost{response.Posts[0]}, "")
125
126 return nil
127}
128
129// ViewProfileAction views an actor's profile with stats
130func ViewProfileAction(ctx context.Context, cmd *cli.Command) error {
131 if err := setup.EnsurePersistenceReady(ctx); err != nil {
132 return fmt.Errorf("persistence layer not ready: %w", err)
133 }
134
135 reg := registry.Get()
136
137 if cmd.Args().Len() == 0 {
138 return fmt.Errorf("actor handle or DID required")
139 }
140
141 actor := cmd.Args().First()
142 showPosts := cmd.Bool("with-posts")
143 asJSON := cmd.Bool("json")
144
145 service, err := reg.GetService()
146 if err != nil {
147 return fmt.Errorf("failed to get service: %w", err)
148 }
149
150 if !service.Authenticated() {
151 return fmt.Errorf("not authenticated: run 'skycli login' first")
152 }
153
154 logger.Debug("Fetching profile", "actor", actor)
155
156 profile, err := service.GetProfile(ctx, actor)
157 if err != nil {
158 return fmt.Errorf("failed to fetch profile: %w", err)
159 }
160
161 if asJSON {
162 return ui.DisplayJSON(profile)
163 }
164
165 ui.DisplayProfileHeader(profile)
166
167 if showPosts {
168 logger.Debug("Fetching recent posts", "actor", actor)
169 feed, err := service.GetAuthorFeed(ctx, actor, 10, "")
170 if err != nil {
171 ui.Warningln("Failed to fetch recent posts: %v", err)
172 } else {
173 fmt.Println()
174 ui.Subtitleln("Recent Posts")
175 ui.DisplayFeed(feed.Feed, "")
176 }
177 }
178
179 return nil
180}
181
182// ViewCommand returns the view command with subcommands for feed, post, and profile
183func ViewCommand() *cli.Command {
184 return &cli.Command{
185 Name: "view",
186 Usage: "View feeds, posts, or profiles",
187 Commands: []*cli.Command{
188 {
189 Name: "feed",
190 Usage: "View posts from a feed by URI or local feed ID",
191 ArgsUsage: "<feed-uri-or-id>",
192 Flags: []cli.Flag{
193 &cli.IntFlag{
194 Name: "limit",
195 Aliases: []string{"l"},
196 Usage: "Maximum number of posts to display",
197 Value: 25,
198 },
199 &cli.StringFlag{
200 Name: "cursor",
201 Aliases: []string{"c"},
202 Usage: "Pagination cursor",
203 },
204 &cli.BoolFlag{
205 Name: "json",
206 Aliases: []string{"j"},
207 Usage: "Output raw JSON response",
208 },
209 },
210 Action: ViewFeedAction,
211 },
212 {
213 Name: "post",
214 Usage: "View a single post by URI or bsky.app URL",
215 ArgsUsage: "<post-uri-or-url>",
216 Flags: []cli.Flag{
217 &cli.BoolFlag{
218 Name: "json",
219 Aliases: []string{"j"},
220 Usage: "Output raw JSON response",
221 },
222 },
223 Action: ViewPostAction,
224 },
225 {
226 Name: "profile",
227 Usage: "View an actor's profile",
228 ArgsUsage: "<actor-handle-or-did>",
229 Flags: []cli.Flag{
230 &cli.BoolFlag{
231 Name: "with-posts",
232 Aliases: []string{"p"},
233 Usage: "Also display recent posts from this profile",
234 },
235 &cli.BoolFlag{
236 Name: "json",
237 Aliases: []string{"j"},
238 Usage: "Output raw JSON response",
239 },
240 },
241 Action: ViewProfileAction,
242 },
243 },
244 }
245}
246
247// parsePostIdentifier converts a bsky.app URL or AT URI to an AT URI
248// Examples:
249// - https://bsky.app/profile/alice.bsky.social/post/abc123
250// - at://did:plc:xyz/app.bsky.feed.post/abc123
251func parsePostIdentifier(identifier string) (string, error) {
252 if strings.HasPrefix(identifier, "at://") {
253 return identifier, nil
254 }
255
256 if strings.HasPrefix(identifier, "https://bsky.app/profile/") ||
257 strings.HasPrefix(identifier, "http://bsky.app/profile/") {
258 re := regexp.MustCompile(`^https?://bsky\.app/profile/([^/]+)/post/([^/]+)`)
259 matches := re.FindStringSubmatch(identifier)
260 if len(matches) != 3 {
261 return "", fmt.Errorf("invalid bsky.app URL format")
262 }
263
264 handle := matches[1]
265 rkey := matches[2]
266
267 return fmt.Sprintf("at://%s/app.bsky.feed.post/%s", handle, rkey), nil
268 }
269
270 return "", fmt.Errorf("identifier must be an AT URI (at://...) or bsky.app URL")
271}