From d6cba46f0c41e577c04d3549ea69b4429ee38686 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Wed, 08 Jul 2026 19:11:36 +0000 Subject: [PATCH] internal/cli: add json output support via generic output function --- internal/cli/auth_status.go | 19 +++++++++++++++---- internal/cli/issue_list.go | 42 ++++++++++++++++++++++++++++-------------- internal/cli/issue_view.go | 20 ++++++++++++++------ internal/cli/output.go | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ internal/cli/pr_checkout.go | 10 ++++++++-- internal/cli/pr_list.go | 44 ++++++++++++++++++++++++++++++-------------- internal/cli/pr_view.go | 24 +++++++++++++++++------- internal/cli/repo_clone.go | 13 +++++++++++-- internal/cli/repo_create.go | 22 +++++++++++++++++++--- internal/cli/repo_list.go | 39 +++++++++++++++++---------------------- internal/cli/root.go | 4 ++++ internal/cli/rows.go | 21 ++++++++++++--------- internal/cli/ssh_key_add.go | 6 ++++-- internal/cli/ssh_key_list.go | 37 +++++++++++++++++++++++++------------ 14 file(s) changed, 317 insertion(s)(+), 97 deletion(s)(-) diff --git a/internal/cli/auth_status.go b/internal/cli/auth_status.go --- a/internal/cli/auth_status.go +++ b/internal/cli/auth_status.go @@ -10,11 +10,22 @@ Use: "status", Short: "Show authentication status", RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + if auth == nil || !auth.IsAuthenticated() { - fmt.Println("Not logged in.") - return nil + return output(authStatusResult{}, func(_ authStatusResult) { + fmt.Println("Not logged in.") + }) } - fmt.Printf("Logged in as %s\n", auth.CurrentDID()) - return nil + + author := resolveAuthor(ctx, auth.CurrentDID().String()) + result := authStatusResult{ + Authenticated: true, + DID: author.DID, + Handle: author.Handle, + } + return output(result, func(status authStatusResult) { + fmt.Printf("Logged in as %s\n", status.Handle) + }) }, } diff --git a/internal/cli/issue_list.go b/internal/cli/issue_list.go --- a/internal/cli/issue_list.go +++ b/internal/cli/issue_list.go @@ -39,9 +39,8 @@ return fmt.Errorf("list issues for %q: %w", repo, err) } - rows := buildIssueRows(ctx, issues.Items) - renderRows(rows, "No issues found.") - return nil + items := buildIssueItems(ctx, issues.Items) + return output(items, renderIssueList) }, } @@ -93,10 +92,8 @@ return "", fmt.Errorf("repo %q not found for handle %q", repo, handle) } -// buildIssueRows resolves each issue author's DID to a handle, falling -// back to the raw DID on resolution failure. -func buildIssueRows(ctx context.Context, items []tangled.IssueListItem) []listRow { - rows := make([]listRow, 0, len(items)) +func buildIssueItems(ctx context.Context, items []tangled.IssueListItem) []issueItem { + result := make([]issueItem, 0, len(items)) for _, item := range items { var record tangled.IssueRecord @@ -114,14 +111,31 @@ title = "(no title)" } - rows = append(rows, listRow{ - rkey: extractRKey(item.URI), - title: title, - state: item.State, - author: resolveAuthor(ctx, extractDID(item.URI)), - updated: shortDate(updated), + result = append(result, issueItem{ + Rkey: extractRKey(item.URI), + URI: item.URI, + Title: title, + State: item.State, + Author: resolveAuthor(ctx, extractDID(item.URI)), + CreatedAt: record.CreatedAt, + UpdatedAt: updated, + CommentCount: item.CommentCount, }) } - return rows + return result +} + +func renderIssueList(items []issueItem) { + rows := make([]listRow, 0, len(items)) + for _, item := range items { + rows = append(rows, listRow{ + rkey: item.Rkey, + title: item.Title, + state: item.State, + author: item.Author.Handle, + updated: shortDate(item.UpdatedAt), + }) + } + renderRows(rows, "No issues found.") } diff --git a/internal/cli/issue_view.go b/internal/cli/issue_view.go --- a/internal/cli/issue_view.go +++ b/internal/cli/issue_view.go @@ -49,13 +49,21 @@ return err } - fmt.Printf("Title: %s\n", issue.Title) - fmt.Printf("Author: %s\n", resolveAuthor(ctx, authorDID)) - fmt.Printf("Created: %s\n", issue.CreatedAt) - if issue.Body != "" { - fmt.Printf("\n%s\n", issue.Body) + result := issueViewResult{ + Rkey: rkey, + Title: issue.Title, + Body: issue.Body, + Author: resolveAuthor(ctx, authorDID), + CreatedAt: issue.CreatedAt, } - return nil + return output(result, func(view issueViewResult) { + fmt.Printf("Title: %s\n", view.Title) + fmt.Printf("Author: %s\n", view.Author.Handle) + fmt.Printf("Created: %s\n", view.CreatedAt) + if view.Body != "" { + fmt.Printf("\n%s\n", view.Body) + } + }) }, } diff --git a/internal/cli/output.go b/internal/cli/output.go new file mode 100644 --- /dev/null +++ b/internal/cli/output.go @@ -0,0 +1,113 @@ +package cli + +import ( + "encoding/json" + "os" +) + +// output dispatches structured data to JSON (when --json is set) or to +// a human-readable renderer. +func output[T any](data T, human func(T)) error { + if jsonOutput { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(data) + } + human(data) + return nil +} + +type author struct { + DID string `json:"did"` + Handle string `json:"handle"` +} + +type issueItem struct { + Rkey string `json:"rkey"` + URI string `json:"uri"` + Title string `json:"title"` + State string `json:"state"` + Author author `json:"author"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt,omitempty"` + CommentCount int64 `json:"commentCount"` +} + +type pullItem struct { + Rkey string `json:"rkey"` + URI string `json:"uri"` + Title string `json:"title"` + State string `json:"state"` + Author author `json:"author"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt,omitempty"` + CommentCount int64 `json:"commentCount"` + SourceBranch string `json:"sourceBranch,omitempty"` + TargetBranch string `json:"targetBranch"` +} + +type repoItem struct { + Name string `json:"name"` + URI string `json:"uri"` + Knot string `json:"knot"` + Description string `json:"description,omitempty"` + CreatedAt string `json:"createdAt"` + RepoDid string `json:"repoDid,omitempty"` +} + +type sshKeyItem struct { + Name string `json:"name"` + Key string `json:"key"` + CreatedAt string `json:"createdAt"` + URI string `json:"uri"` +} + +type issueViewResult struct { + Rkey string `json:"rkey"` + Title string `json:"title"` + Body string `json:"body,omitempty"` + Author author `json:"author"` + CreatedAt string `json:"createdAt"` +} + +type prViewResult struct { + Rkey string `json:"rkey"` + Title string `json:"title"` + Body string `json:"body,omitempty"` + Author author `json:"author"` + CreatedAt string `json:"createdAt"` + SourceBranch string `json:"sourceBranch,omitempty"` + TargetBranch string `json:"targetBranch"` +} + +type repoCreateResult struct { + Handle string `json:"handle"` + Name string `json:"name"` + URI string `json:"uri"` + Knot string `json:"knot"` + Cloned bool `json:"cloned"` + Pushed bool `json:"pushed"` +} + +type repoCloneResult struct { + Handle string `json:"handle"` + Repo string `json:"repo"` + Destination string `json:"destination"` +} + +type sshKeyAddResult struct { + Name string `json:"name"` + URI string `json:"uri"` +} + +type prCheckoutResult struct { + Rkey string `json:"rkey"` + Branch string `json:"branch"` + Directory string `json:"directory"` +} + +type authStatusResult struct { + Authenticated bool `json:"authenticated"` + DID string `json:"did,omitempty"` + Handle string `json:"handle,omitempty"` +} diff --git a/internal/cli/pr_checkout.go b/internal/cli/pr_checkout.go --- a/internal/cli/pr_checkout.go +++ b/internal/cli/pr_checkout.go @@ -77,8 +77,14 @@ return fmt.Errorf("checkout pull %q: %w", prRKey, err) } - fmt.Printf("Checked out PR %s as detached HEAD in %s\n", prRKey, repoDir) - return nil + result := prCheckoutResult{ + Rkey: prRKey, + Branch: pr.Target.Branch, + Directory: repoDir, + } + return output(result, func(checkout prCheckoutResult) { + fmt.Printf("Checked out PR %s as detached HEAD in %s\n", checkout.Rkey, checkout.Directory) + }) }, } diff --git a/internal/cli/pr_list.go b/internal/cli/pr_list.go --- a/internal/cli/pr_list.go +++ b/internal/cli/pr_list.go @@ -37,16 +37,13 @@ return fmt.Errorf("list PRs for %q: %w", repo, err) } - rows := buildPullRows(ctx, pulls.Items) - renderRows(rows, "No pull requests found.") - return nil + items := buildPullItems(ctx, pulls.Items) + return output(items, renderPullList) }, } -// buildPullRows resolves each PR author's DID to a handle, falling back -// to the raw DID on resolution failure. -func buildPullRows(ctx context.Context, items []tangled.PullListItem) []listRow { - rows := make([]listRow, 0, len(items)) +func buildPullItems(ctx context.Context, items []tangled.PullListItem) []pullItem { + result := make([]pullItem, 0, len(items)) for _, item := range items { var record tangled.PullRecord @@ -64,14 +61,33 @@ title = "(no title)" } - rows = append(rows, listRow{ - rkey: extractRKey(item.URI), - title: title, - state: item.State, - author: resolveAuthor(ctx, extractDID(item.URI)), - updated: shortDate(updated), + result = append(result, pullItem{ + Rkey: extractRKey(item.URI), + URI: item.URI, + Title: title, + State: item.State, + Author: resolveAuthor(ctx, extractDID(item.URI)), + CreatedAt: record.CreatedAt, + UpdatedAt: updated, + CommentCount: item.CommentCount, + SourceBranch: record.Source.Branch, + TargetBranch: record.Target.Branch, }) } - return rows + return result +} + +func renderPullList(items []pullItem) { + rows := make([]listRow, 0, len(items)) + for _, item := range items { + rows = append(rows, listRow{ + rkey: item.Rkey, + title: item.Title, + state: item.State, + author: item.Author.Handle, + updated: shortDate(item.UpdatedAt), + }) + } + renderRows(rows, "No pull requests found.") } diff --git a/internal/cli/pr_view.go b/internal/cli/pr_view.go --- a/internal/cli/pr_view.go +++ b/internal/cli/pr_view.go @@ -47,14 +47,24 @@ return err } - fmt.Printf("Title: %s\n", pr.Title) - fmt.Printf("Author: %s\n", resolveAuthor(ctx, authorDID)) - fmt.Printf("Created: %s\n", pr.CreatedAt) - fmt.Printf("Branch: %s → %s\n", pr.Source.Branch, pr.Target.Branch) - if pr.Body != "" { - fmt.Printf("\n%s\n", pr.Body) + result := prViewResult{ + Rkey: rkey, + Title: pr.Title, + Body: pr.Body, + Author: resolveAuthor(ctx, authorDID), + CreatedAt: pr.CreatedAt, + SourceBranch: pr.Source.Branch, + TargetBranch: pr.Target.Branch, } - return nil + return output(result, func(view prViewResult) { + fmt.Printf("Title: %s\n", view.Title) + fmt.Printf("Author: %s\n", view.Author.Handle) + fmt.Printf("Created: %s\n", view.CreatedAt) + fmt.Printf("Branch: %s → %s\n", view.SourceBranch, view.TargetBranch) + if view.Body != "" { + fmt.Printf("\n%s\n", view.Body) + } + }) }, } diff --git a/internal/cli/repo_clone.go b/internal/cli/repo_clone.go --- a/internal/cli/repo_clone.go +++ b/internal/cli/repo_clone.go @@ -2,6 +2,7 @@ import ( "fmt" + "os" "github.com/alyraffauf/tg/internal/gitutil" "github.com/spf13/cobra" @@ -27,7 +28,7 @@ dest = args[1] } - fmt.Printf("Cloning %s/%s into %s...\n", handle, repo, dest) + fmt.Fprintf(os.Stderr, "Cloning %s/%s into %s...\n", handle, repo, dest) if err := gitutil.CloneRepo(ctx, gitutil.CloneRepoParams{ Handle: handle, Repo: repo, @@ -35,6 +36,14 @@ }); err != nil { return fmt.Errorf("clone %q: %w", args[0], err) } - return nil + + result := repoCloneResult{ + Handle: handle, + Repo: repo, + Destination: dest, + } + return output(result, func(clone repoCloneResult) { + fmt.Printf("Cloned %s/%s into %s\n", clone.Handle, clone.Repo, clone.Destination) + }) }, } diff --git a/internal/cli/repo_create.go b/internal/cli/repo_create.go --- a/internal/cli/repo_create.go +++ b/internal/cli/repo_create.go @@ -66,7 +66,12 @@ } handle := ownerHandle(ctx, did) - fmt.Printf("Created repository %s/%s\n", handle, args[0]) + result := repoCreateResult{ + Handle: handle, + Name: args[0], + URI: uri, + Knot: knotHost, + } if repoCreateClone { if err := gitutil.CloneRepo(ctx, gitutil.CloneRepoParams{ @@ -76,6 +81,7 @@ }); err != nil { return fmt.Errorf("clone new repository: %w", err) } + result.Cloned = true } if repoCreatePushPath != "" { if err := pushToNewRepo(ctx, atClient, pushToNewRepoInput{ @@ -88,8 +94,18 @@ }); err != nil { return err } + result.Pushed = true } - return nil + + return output(result, func(repo repoCreateResult) { + fmt.Printf("Created repository %s/%s\n", repo.Handle, repo.Name) + if repo.Cloned { + fmt.Printf("Cloned into %s\n", repo.Name) + } + if repo.Pushed { + fmt.Printf("Pushed to %s\n", repo.Name) + } + }) }, } @@ -163,7 +179,7 @@ if err != nil { fmt.Fprintf(os.Stderr, "warning: could not set default branch: %v\n", err) } else { - fmt.Printf("Set default branch to %s\n", branch) + fmt.Fprintf(os.Stderr, "Set default branch to %s\n", branch) } if err := gitutil.PushNewRepo(ctx, gitutil.PushNewRepoParams{ Dir: in.PushPath, diff --git a/internal/cli/repo_list.go b/internal/cli/repo_list.go --- a/internal/cli/repo_list.go +++ b/internal/cli/repo_list.go @@ -38,8 +38,8 @@ return fmt.Errorf("list repos for %q: %w", handle, err) } - renderRepos(buildRepoRows(repos.Items)) - return nil + items := buildRepoItems(repos.Items) + return output(items, renderRepoList) }, } @@ -73,38 +73,33 @@ return ident.Handle.String(), nil } -type repoRow struct { - name string - knot string - description string - created string -} - -func buildRepoRows(items []tangled.Repo) []repoRow { - rows := make([]repoRow, 0, len(items)) +func buildRepoItems(items []tangled.Repo) []repoItem { + result := make([]repoItem, 0, len(items)) for _, item := range items { name := item.Value.Name if name == "" { - // Fall back to the rkey from the at:// URI. + // Fall back to the rkey segment of the at:// URI. if idx := strings.LastIndex(item.URI, "/"); idx != -1 { name = item.URI[idx+1:] } } - rows = append(rows, repoRow{ - name: name, - knot: item.Value.Knot, - description: item.Value.Description, - created: shortDate(item.Value.CreatedAt), + result = append(result, repoItem{ + Name: name, + URI: item.URI, + Knot: item.Value.Knot, + Description: item.Value.Description, + CreatedAt: item.Value.CreatedAt, + RepoDid: item.Value.RepoDid, }) } - return rows + return result } -func renderRepos(rows []repoRow) { - if len(rows) == 0 { +func renderRepoList(items []repoItem) { + if len(items) == 0 { fmt.Println("No repositories found.") return } @@ -112,8 +107,8 @@ tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', tabwriter.TabIndent) fmt.Fprintln(tw, "NAME\tKNOT\tDESCRIPTION\tCREATED") - for _, row := range rows { - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", row.name, row.knot, row.description, row.created) + for _, item := range items { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", item.Name, item.Knot, item.Description, shortDate(item.CreatedAt)) } tw.Flush() } diff --git a/internal/cli/root.go b/internal/cli/root.go --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -24,6 +24,8 @@ Logger: slog.Default(), } auth *atproto.AuthManager + + jsonOutput bool ) var rootCmd = &cobra.Command{ @@ -36,6 +38,8 @@ } func init() { + rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") + initAuth() rootCmd.AddCommand(authCmd) diff --git a/internal/cli/rows.go b/internal/cli/rows.go --- a/internal/cli/rows.go +++ b/internal/cli/rows.go @@ -19,15 +19,6 @@ updated string } -// resolveAuthor returns the handle for didStr, falling back to the -// raw DID string on resolution failure. -func resolveAuthor(ctx context.Context, didStr string) string { - if ident, err := resolver.ResolveDID(ctx, didStr); err == nil { - return ident.Handle.String() - } - return didStr -} - // shortDate trims an ISO 8601 timestamp to its YYYY-MM-DD prefix. func shortDate(timestamp string) string { if len(timestamp) > 10 { @@ -64,4 +55,16 @@ return uri[idx+1:] } return uri +} + +// resolveAuthor resolves a DID to an author, falling back to the raw +// DID string for Handle if resolution fails. +func resolveAuthor(ctx context.Context, did string) author { + result := author{DID: did} + if ident, err := resolver.ResolveDID(ctx, did); err == nil { + result.Handle = ident.Handle.String() + } else { + result.Handle = did + } + return result } diff --git a/internal/cli/ssh_key_add.go b/internal/cli/ssh_key_add.go --- a/internal/cli/ssh_key_add.go +++ b/internal/cli/ssh_key_add.go @@ -77,8 +77,10 @@ return fmt.Errorf("add SSH key: %w", err) } - fmt.Printf("Added SSH key %q (%s)\n", title, uri) - return nil + result := sshKeyAddResult{Name: title, URI: uri} + return output(result, func(added sshKeyAddResult) { + fmt.Printf("Added SSH key %q (%s)\n", added.Name, added.URI) + }) }, } diff --git a/internal/cli/ssh_key_list.go b/internal/cli/ssh_key_list.go --- a/internal/cli/ssh_key_list.go +++ b/internal/cli/ssh_key_list.go @@ -43,12 +43,33 @@ return fmt.Errorf("list SSH keys for %q: %w", handle, err) } - renderSSHKeys(out.Records) - return nil + items := buildSSHKeyItems(out.Records) + return output(items, renderSSHKeyList) }, } -func renderSSHKeys(items []atproto.RecordItem) { +func buildSSHKeyItems(records []atproto.RecordItem) []sshKeyItem { + items := make([]sshKeyItem, 0, len(records)) + for _, rec := range records { + var key sshKeyRecord + data, err := json.Marshal(rec.Value) + if err != nil { + continue + } + if err := json.Unmarshal(data, &key); err != nil { + continue + } + items = append(items, sshKeyItem{ + Name: key.Name, + Key: key.Key, + CreatedAt: key.CreatedAt, + URI: rec.URI, + }) + } + return items +} + +func renderSSHKeyList(items []sshKeyItem) { if len(items) == 0 { fmt.Println("No SSH keys found.") return @@ -58,15 +79,7 @@ fmt.Fprintln(tw, "NAME\tKEY\tADDED") for _, item := range items { - var rec sshKeyRecord - data, err := json.Marshal(item.Value) - if err != nil { - continue - } - if err := json.Unmarshal(data, &rec); err != nil { - continue - } - fmt.Fprintf(tw, "%s\t%s\t%s\n", rec.Name, rec.Key, shortDate(rec.CreatedAt)) + fmt.Fprintf(tw, "%s\t%s\t%s\n", item.Name, item.Key, shortDate(item.CreatedAt)) } tw.Flush() } -- tangled.sh