A terminal client for Tangled.
tui go tangled cli
5

Configure Feed

Select the types of activity you want to include in your feed.

internal/cli: add json output support via generic output function

Aly Raffauf (Jul 8, 2026, 3:11 PM EDT) d6cba46f df6bc2a2

+315 -95
+15 -4
internal/cli/auth_status.go
··· 10 10 Use: "status", 11 11 Short: "Show authentication status", 12 12 RunE: func(cmd *cobra.Command, args []string) error { 13 + ctx := cmd.Context() 14 + 13 15 if auth == nil || !auth.IsAuthenticated() { 14 - fmt.Println("Not logged in.") 15 - return nil 16 + return output(authStatusResult{}, func(_ authStatusResult) { 17 + fmt.Println("Not logged in.") 18 + }) 19 + } 20 + 21 + author := resolveAuthor(ctx, auth.CurrentDID().String()) 22 + result := authStatusResult{ 23 + Authenticated: true, 24 + DID: author.DID, 25 + Handle: author.Handle, 16 26 } 17 - fmt.Printf("Logged in as %s\n", auth.CurrentDID()) 18 - return nil 27 + return output(result, func(status authStatusResult) { 28 + fmt.Printf("Logged in as %s\n", status.Handle) 29 + }) 19 30 }, 20 31 }
+27 -13
internal/cli/issue_list.go
··· 39 39 return fmt.Errorf("list issues for %q: %w", repo, err) 40 40 } 41 41 42 - rows := buildIssueRows(ctx, issues.Items) 43 - renderRows(rows, "No issues found.") 44 - return nil 42 + items := buildIssueItems(ctx, issues.Items) 43 + return output(items, renderIssueList) 45 44 }, 46 45 } 47 46 ··· 93 92 return "", fmt.Errorf("repo %q not found for handle %q", repo, handle) 94 93 } 95 94 96 - // buildIssueRows resolves each issue author's DID to a handle, falling 97 - // back to the raw DID on resolution failure. 98 - func buildIssueRows(ctx context.Context, items []tangled.IssueListItem) []listRow { 99 - rows := make([]listRow, 0, len(items)) 95 + func buildIssueItems(ctx context.Context, items []tangled.IssueListItem) []issueItem { 96 + result := make([]issueItem, 0, len(items)) 100 97 101 98 for _, item := range items { 102 99 var record tangled.IssueRecord ··· 114 111 title = "(no title)" 115 112 } 116 113 114 + result = append(result, issueItem{ 115 + Rkey: extractRKey(item.URI), 116 + URI: item.URI, 117 + Title: title, 118 + State: item.State, 119 + Author: resolveAuthor(ctx, extractDID(item.URI)), 120 + CreatedAt: record.CreatedAt, 121 + UpdatedAt: updated, 122 + CommentCount: item.CommentCount, 123 + }) 124 + } 125 + 126 + return result 127 + } 128 + 129 + func renderIssueList(items []issueItem) { 130 + rows := make([]listRow, 0, len(items)) 131 + for _, item := range items { 117 132 rows = append(rows, listRow{ 118 - rkey: extractRKey(item.URI), 119 - title: title, 133 + rkey: item.Rkey, 134 + title: item.Title, 120 135 state: item.State, 121 - author: resolveAuthor(ctx, extractDID(item.URI)), 122 - updated: shortDate(updated), 136 + author: item.Author.Handle, 137 + updated: shortDate(item.UpdatedAt), 123 138 }) 124 139 } 125 - 126 - return rows 140 + renderRows(rows, "No issues found.") 127 141 }
+14 -6
internal/cli/issue_view.go
··· 49 49 return err 50 50 } 51 51 52 - fmt.Printf("Title: %s\n", issue.Title) 53 - fmt.Printf("Author: %s\n", resolveAuthor(ctx, authorDID)) 54 - fmt.Printf("Created: %s\n", issue.CreatedAt) 55 - if issue.Body != "" { 56 - fmt.Printf("\n%s\n", issue.Body) 52 + result := issueViewResult{ 53 + Rkey: rkey, 54 + Title: issue.Title, 55 + Body: issue.Body, 56 + Author: resolveAuthor(ctx, authorDID), 57 + CreatedAt: issue.CreatedAt, 57 58 } 58 - return nil 59 + return output(result, func(view issueViewResult) { 60 + fmt.Printf("Title: %s\n", view.Title) 61 + fmt.Printf("Author: %s\n", view.Author.Handle) 62 + fmt.Printf("Created: %s\n", view.CreatedAt) 63 + if view.Body != "" { 64 + fmt.Printf("\n%s\n", view.Body) 65 + } 66 + }) 59 67 }, 60 68 } 61 69
+113
internal/cli/output.go
··· 1 + package cli 2 + 3 + import ( 4 + "encoding/json" 5 + "os" 6 + ) 7 + 8 + // output dispatches structured data to JSON (when --json is set) or to 9 + // a human-readable renderer. 10 + func output[T any](data T, human func(T)) error { 11 + if jsonOutput { 12 + enc := json.NewEncoder(os.Stdout) 13 + enc.SetIndent("", " ") 14 + return enc.Encode(data) 15 + } 16 + human(data) 17 + return nil 18 + } 19 + 20 + type author struct { 21 + DID string `json:"did"` 22 + Handle string `json:"handle"` 23 + } 24 + 25 + type issueItem struct { 26 + Rkey string `json:"rkey"` 27 + URI string `json:"uri"` 28 + Title string `json:"title"` 29 + State string `json:"state"` 30 + Author author `json:"author"` 31 + CreatedAt string `json:"createdAt"` 32 + UpdatedAt string `json:"updatedAt,omitempty"` 33 + CommentCount int64 `json:"commentCount"` 34 + } 35 + 36 + type pullItem struct { 37 + Rkey string `json:"rkey"` 38 + URI string `json:"uri"` 39 + Title string `json:"title"` 40 + State string `json:"state"` 41 + Author author `json:"author"` 42 + CreatedAt string `json:"createdAt"` 43 + UpdatedAt string `json:"updatedAt,omitempty"` 44 + CommentCount int64 `json:"commentCount"` 45 + SourceBranch string `json:"sourceBranch,omitempty"` 46 + TargetBranch string `json:"targetBranch"` 47 + } 48 + 49 + type repoItem struct { 50 + Name string `json:"name"` 51 + URI string `json:"uri"` 52 + Knot string `json:"knot"` 53 + Description string `json:"description,omitempty"` 54 + CreatedAt string `json:"createdAt"` 55 + RepoDid string `json:"repoDid,omitempty"` 56 + } 57 + 58 + type sshKeyItem struct { 59 + Name string `json:"name"` 60 + Key string `json:"key"` 61 + CreatedAt string `json:"createdAt"` 62 + URI string `json:"uri"` 63 + } 64 + 65 + type issueViewResult struct { 66 + Rkey string `json:"rkey"` 67 + Title string `json:"title"` 68 + Body string `json:"body,omitempty"` 69 + Author author `json:"author"` 70 + CreatedAt string `json:"createdAt"` 71 + } 72 + 73 + type prViewResult struct { 74 + Rkey string `json:"rkey"` 75 + Title string `json:"title"` 76 + Body string `json:"body,omitempty"` 77 + Author author `json:"author"` 78 + CreatedAt string `json:"createdAt"` 79 + SourceBranch string `json:"sourceBranch,omitempty"` 80 + TargetBranch string `json:"targetBranch"` 81 + } 82 + 83 + type repoCreateResult struct { 84 + Handle string `json:"handle"` 85 + Name string `json:"name"` 86 + URI string `json:"uri"` 87 + Knot string `json:"knot"` 88 + Cloned bool `json:"cloned"` 89 + Pushed bool `json:"pushed"` 90 + } 91 + 92 + type repoCloneResult struct { 93 + Handle string `json:"handle"` 94 + Repo string `json:"repo"` 95 + Destination string `json:"destination"` 96 + } 97 + 98 + type sshKeyAddResult struct { 99 + Name string `json:"name"` 100 + URI string `json:"uri"` 101 + } 102 + 103 + type prCheckoutResult struct { 104 + Rkey string `json:"rkey"` 105 + Branch string `json:"branch"` 106 + Directory string `json:"directory"` 107 + } 108 + 109 + type authStatusResult struct { 110 + Authenticated bool `json:"authenticated"` 111 + DID string `json:"did,omitempty"` 112 + Handle string `json:"handle,omitempty"` 113 + }
+8 -2
internal/cli/pr_checkout.go
··· 77 77 return fmt.Errorf("checkout pull %q: %w", prRKey, err) 78 78 } 79 79 80 - fmt.Printf("Checked out PR %s as detached HEAD in %s\n", prRKey, repoDir) 81 - return nil 80 + result := prCheckoutResult{ 81 + Rkey: prRKey, 82 + Branch: pr.Target.Branch, 83 + Directory: repoDir, 84 + } 85 + return output(result, func(checkout prCheckoutResult) { 86 + fmt.Printf("Checked out PR %s as detached HEAD in %s\n", checkout.Rkey, checkout.Directory) 87 + }) 82 88 }, 83 89 } 84 90
+29 -13
internal/cli/pr_list.go
··· 37 37 return fmt.Errorf("list PRs for %q: %w", repo, err) 38 38 } 39 39 40 - rows := buildPullRows(ctx, pulls.Items) 41 - renderRows(rows, "No pull requests found.") 42 - return nil 40 + items := buildPullItems(ctx, pulls.Items) 41 + return output(items, renderPullList) 43 42 }, 44 43 } 45 44 46 - // buildPullRows resolves each PR author's DID to a handle, falling back 47 - // to the raw DID on resolution failure. 48 - func buildPullRows(ctx context.Context, items []tangled.PullListItem) []listRow { 49 - rows := make([]listRow, 0, len(items)) 45 + func buildPullItems(ctx context.Context, items []tangled.PullListItem) []pullItem { 46 + result := make([]pullItem, 0, len(items)) 50 47 51 48 for _, item := range items { 52 49 var record tangled.PullRecord ··· 64 61 title = "(no title)" 65 62 } 66 63 64 + result = append(result, pullItem{ 65 + Rkey: extractRKey(item.URI), 66 + URI: item.URI, 67 + Title: title, 68 + State: item.State, 69 + Author: resolveAuthor(ctx, extractDID(item.URI)), 70 + CreatedAt: record.CreatedAt, 71 + UpdatedAt: updated, 72 + CommentCount: item.CommentCount, 73 + SourceBranch: record.Source.Branch, 74 + TargetBranch: record.Target.Branch, 75 + }) 76 + } 77 + 78 + return result 79 + } 80 + 81 + func renderPullList(items []pullItem) { 82 + rows := make([]listRow, 0, len(items)) 83 + for _, item := range items { 67 84 rows = append(rows, listRow{ 68 - rkey: extractRKey(item.URI), 69 - title: title, 85 + rkey: item.Rkey, 86 + title: item.Title, 70 87 state: item.State, 71 - author: resolveAuthor(ctx, extractDID(item.URI)), 72 - updated: shortDate(updated), 88 + author: item.Author.Handle, 89 + updated: shortDate(item.UpdatedAt), 73 90 }) 74 91 } 75 - 76 - return rows 92 + renderRows(rows, "No pull requests found.") 77 93 }
+17 -7
internal/cli/pr_view.go
··· 47 47 return err 48 48 } 49 49 50 - fmt.Printf("Title: %s\n", pr.Title) 51 - fmt.Printf("Author: %s\n", resolveAuthor(ctx, authorDID)) 52 - fmt.Printf("Created: %s\n", pr.CreatedAt) 53 - fmt.Printf("Branch: %s → %s\n", pr.Source.Branch, pr.Target.Branch) 54 - if pr.Body != "" { 55 - fmt.Printf("\n%s\n", pr.Body) 50 + result := prViewResult{ 51 + Rkey: rkey, 52 + Title: pr.Title, 53 + Body: pr.Body, 54 + Author: resolveAuthor(ctx, authorDID), 55 + CreatedAt: pr.CreatedAt, 56 + SourceBranch: pr.Source.Branch, 57 + TargetBranch: pr.Target.Branch, 56 58 } 57 - return nil 59 + return output(result, func(view prViewResult) { 60 + fmt.Printf("Title: %s\n", view.Title) 61 + fmt.Printf("Author: %s\n", view.Author.Handle) 62 + fmt.Printf("Created: %s\n", view.CreatedAt) 63 + fmt.Printf("Branch: %s → %s\n", view.SourceBranch, view.TargetBranch) 64 + if view.Body != "" { 65 + fmt.Printf("\n%s\n", view.Body) 66 + } 67 + }) 58 68 }, 59 69 } 60 70
+11 -2
internal/cli/repo_clone.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 + "os" 5 6 6 7 "github.com/alyraffauf/tg/internal/gitutil" 7 8 "github.com/spf13/cobra" ··· 27 28 dest = args[1] 28 29 } 29 30 30 - fmt.Printf("Cloning %s/%s into %s...\n", handle, repo, dest) 31 + fmt.Fprintf(os.Stderr, "Cloning %s/%s into %s...\n", handle, repo, dest) 31 32 if err := gitutil.CloneRepo(ctx, gitutil.CloneRepoParams{ 32 33 Handle: handle, 33 34 Repo: repo, ··· 35 36 }); err != nil { 36 37 return fmt.Errorf("clone %q: %w", args[0], err) 37 38 } 38 - return nil 39 + 40 + result := repoCloneResult{ 41 + Handle: handle, 42 + Repo: repo, 43 + Destination: dest, 44 + } 45 + return output(result, func(clone repoCloneResult) { 46 + fmt.Printf("Cloned %s/%s into %s\n", clone.Handle, clone.Repo, clone.Destination) 47 + }) 39 48 }, 40 49 }
+19 -3
internal/cli/repo_create.go
··· 66 66 } 67 67 68 68 handle := ownerHandle(ctx, did) 69 - fmt.Printf("Created repository %s/%s\n", handle, args[0]) 69 + result := repoCreateResult{ 70 + Handle: handle, 71 + Name: args[0], 72 + URI: uri, 73 + Knot: knotHost, 74 + } 70 75 71 76 if repoCreateClone { 72 77 if err := gitutil.CloneRepo(ctx, gitutil.CloneRepoParams{ ··· 76 81 }); err != nil { 77 82 return fmt.Errorf("clone new repository: %w", err) 78 83 } 84 + result.Cloned = true 79 85 } 80 86 if repoCreatePushPath != "" { 81 87 if err := pushToNewRepo(ctx, atClient, pushToNewRepoInput{ ··· 88 94 }); err != nil { 89 95 return err 90 96 } 97 + result.Pushed = true 91 98 } 92 - return nil 99 + 100 + return output(result, func(repo repoCreateResult) { 101 + fmt.Printf("Created repository %s/%s\n", repo.Handle, repo.Name) 102 + if repo.Cloned { 103 + fmt.Printf("Cloned into %s\n", repo.Name) 104 + } 105 + if repo.Pushed { 106 + fmt.Printf("Pushed to %s\n", repo.Name) 107 + } 108 + }) 93 109 }, 94 110 } 95 111 ··· 163 179 if err != nil { 164 180 fmt.Fprintf(os.Stderr, "warning: could not set default branch: %v\n", err) 165 181 } else { 166 - fmt.Printf("Set default branch to %s\n", branch) 182 + fmt.Fprintf(os.Stderr, "Set default branch to %s\n", branch) 167 183 } 168 184 if err := gitutil.PushNewRepo(ctx, gitutil.PushNewRepoParams{ 169 185 Dir: in.PushPath,
+17 -22
internal/cli/repo_list.go
··· 38 38 return fmt.Errorf("list repos for %q: %w", handle, err) 39 39 } 40 40 41 - renderRepos(buildRepoRows(repos.Items)) 42 - return nil 41 + items := buildRepoItems(repos.Items) 42 + return output(items, renderRepoList) 43 43 }, 44 44 } 45 45 ··· 73 73 return ident.Handle.String(), nil 74 74 } 75 75 76 - type repoRow struct { 77 - name string 78 - knot string 79 - description string 80 - created string 81 - } 82 - 83 - func buildRepoRows(items []tangled.Repo) []repoRow { 84 - rows := make([]repoRow, 0, len(items)) 76 + func buildRepoItems(items []tangled.Repo) []repoItem { 77 + result := make([]repoItem, 0, len(items)) 85 78 86 79 for _, item := range items { 87 80 name := item.Value.Name 88 81 if name == "" { 89 - // Fall back to the rkey from the at:// URI. 82 + // Fall back to the rkey segment of the at:// URI. 90 83 if idx := strings.LastIndex(item.URI, "/"); idx != -1 { 91 84 name = item.URI[idx+1:] 92 85 } 93 86 } 94 87 95 - rows = append(rows, repoRow{ 96 - name: name, 97 - knot: item.Value.Knot, 98 - description: item.Value.Description, 99 - created: shortDate(item.Value.CreatedAt), 88 + result = append(result, repoItem{ 89 + Name: name, 90 + URI: item.URI, 91 + Knot: item.Value.Knot, 92 + Description: item.Value.Description, 93 + CreatedAt: item.Value.CreatedAt, 94 + RepoDid: item.Value.RepoDid, 100 95 }) 101 96 } 102 97 103 - return rows 98 + return result 104 99 } 105 100 106 - func renderRepos(rows []repoRow) { 107 - if len(rows) == 0 { 101 + func renderRepoList(items []repoItem) { 102 + if len(items) == 0 { 108 103 fmt.Println("No repositories found.") 109 104 return 110 105 } ··· 112 107 tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', tabwriter.TabIndent) 113 108 fmt.Fprintln(tw, "NAME\tKNOT\tDESCRIPTION\tCREATED") 114 109 115 - for _, row := range rows { 116 - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", row.name, row.knot, row.description, row.created) 110 + for _, item := range items { 111 + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", item.Name, item.Knot, item.Description, shortDate(item.CreatedAt)) 117 112 } 118 113 tw.Flush() 119 114 }
+4
internal/cli/root.go
··· 24 24 Logger: slog.Default(), 25 25 } 26 26 auth *atproto.AuthManager 27 + 28 + jsonOutput bool 27 29 ) 28 30 29 31 var rootCmd = &cobra.Command{ ··· 36 38 } 37 39 38 40 func init() { 41 + rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") 42 + 39 43 initAuth() 40 44 41 45 rootCmd.AddCommand(authCmd)
+12 -9
internal/cli/rows.go
··· 19 19 updated string 20 20 } 21 21 22 - // resolveAuthor returns the handle for didStr, falling back to the 23 - // raw DID string on resolution failure. 24 - func resolveAuthor(ctx context.Context, didStr string) string { 25 - if ident, err := resolver.ResolveDID(ctx, didStr); err == nil { 26 - return ident.Handle.String() 27 - } 28 - return didStr 29 - } 30 - 31 22 // shortDate trims an ISO 8601 timestamp to its YYYY-MM-DD prefix. 32 23 func shortDate(timestamp string) string { 33 24 if len(timestamp) > 10 { ··· 65 56 } 66 57 return uri 67 58 } 59 + 60 + // resolveAuthor resolves a DID to an author, falling back to the raw 61 + // DID string for Handle if resolution fails. 62 + func resolveAuthor(ctx context.Context, did string) author { 63 + result := author{DID: did} 64 + if ident, err := resolver.ResolveDID(ctx, did); err == nil { 65 + result.Handle = ident.Handle.String() 66 + } else { 67 + result.Handle = did 68 + } 69 + return result 70 + }
+4 -2
internal/cli/ssh_key_add.go
··· 77 77 return fmt.Errorf("add SSH key: %w", err) 78 78 } 79 79 80 - fmt.Printf("Added SSH key %q (%s)\n", title, uri) 81 - return nil 80 + result := sshKeyAddResult{Name: title, URI: uri} 81 + return output(result, func(added sshKeyAddResult) { 82 + fmt.Printf("Added SSH key %q (%s)\n", added.Name, added.URI) 83 + }) 82 84 }, 83 85 } 84 86
+25 -12
internal/cli/ssh_key_list.go
··· 43 43 return fmt.Errorf("list SSH keys for %q: %w", handle, err) 44 44 } 45 45 46 - renderSSHKeys(out.Records) 47 - return nil 46 + items := buildSSHKeyItems(out.Records) 47 + return output(items, renderSSHKeyList) 48 48 }, 49 49 } 50 50 51 - func renderSSHKeys(items []atproto.RecordItem) { 51 + func buildSSHKeyItems(records []atproto.RecordItem) []sshKeyItem { 52 + items := make([]sshKeyItem, 0, len(records)) 53 + for _, rec := range records { 54 + var key sshKeyRecord 55 + data, err := json.Marshal(rec.Value) 56 + if err != nil { 57 + continue 58 + } 59 + if err := json.Unmarshal(data, &key); err != nil { 60 + continue 61 + } 62 + items = append(items, sshKeyItem{ 63 + Name: key.Name, 64 + Key: key.Key, 65 + CreatedAt: key.CreatedAt, 66 + URI: rec.URI, 67 + }) 68 + } 69 + return items 70 + } 71 + 72 + func renderSSHKeyList(items []sshKeyItem) { 52 73 if len(items) == 0 { 53 74 fmt.Println("No SSH keys found.") 54 75 return ··· 58 79 fmt.Fprintln(tw, "NAME\tKEY\tADDED") 59 80 60 81 for _, item := range items { 61 - var rec sshKeyRecord 62 - data, err := json.Marshal(item.Value) 63 - if err != nil { 64 - continue 65 - } 66 - if err := json.Unmarshal(data, &rec); err != nil { 67 - continue 68 - } 69 - fmt.Fprintf(tw, "%s\t%s\t%s\n", rec.Name, rec.Key, shortDate(rec.CreatedAt)) 82 + fmt.Fprintf(tw, "%s\t%s\t%s\n", item.Name, item.Key, shortDate(item.CreatedAt)) 70 83 } 71 84 tw.Flush() 72 85 }