A terminal client for Tangled.
tui go tangled cli
8

Configure Feed

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

add create command with --clone and --push options

Aly Raffauf (Jul 7, 2026, 8:14 PM EDT) 25a533fd f7f617a8

+435 -2
+2 -1
.gitignore
··· 1 - tg 1 + /tg 2 + result/
+9
README.md
··· 26 26 # List repositories for a user 27 27 tg repo list microcosm.blue 28 28 29 + # Create a repository (requires `tg auth login`) 30 + tg repo create my-tool --description "A small tool" 31 + 32 + # Create and clone it into the current directory 33 + tg repo create my-tool --clone 34 + 35 + # Create and push an existing local repo (at the given path) to the new remote 36 + tg repo create my-tool --push=. 37 + 29 38 # Clone a repository 30 39 tg repo clone microcosm.blue/microcosm-rs 31 40
+33
atproto/records.go
··· 1 + package atproto 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + "github.com/bluesky-social/indigo/atproto/atclient" 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + ) 10 + 11 + // PutRecordInput is the argument to a com.atproto.repo.putRecord call. 12 + type PutRecordInput struct { 13 + Repo string `json:"repo"` 14 + Collection string `json:"collection"` 15 + Rkey string `json:"rkey"` 16 + Record any `json:"record"` 17 + } 18 + 19 + // PutRecord writes a record to the PDS, returning its at:// URI and CID. The 20 + // record must include its $type field. 21 + func PutRecord(ctx context.Context, pds *atclient.APIClient, in PutRecordInput) (uri, cid string, err error) { 22 + if pds == nil { 23 + return "", "", fmt.Errorf("PDS client is required") 24 + } 25 + var out struct { 26 + URI string `json:"uri"` 27 + CID string `json:"cid,omitempty"` 28 + } 29 + if err := pds.Post(ctx, syntax.NSID("com.atproto.repo.putRecord"), in, &out); err != nil { 30 + return "", "", fmt.Errorf("put %s/%s record: %w", in.Collection, in.Rkey, err) 31 + } 32 + return out.URI, out.CID, nil 33 + }
+36
atproto/serviceauth.go
··· 1 + package atproto 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "time" 7 + 8 + "github.com/bluesky-social/indigo/atproto/atclient" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + ) 11 + 12 + const serviceAuthTTL = 60 * time.Second 13 + 14 + // GetServiceAuth mints a short-lived service-auth JWT scoped to one lexicon 15 + // method on one audience (e.g. a knot's did:web). Present it to that audience 16 + // as a Bearer token. 17 + func GetServiceAuth(ctx context.Context, pds *atclient.APIClient, audience, lexiconMethod string) (string, error) { 18 + if pds == nil { 19 + return "", fmt.Errorf("PDS client is required") 20 + } 21 + var out struct { 22 + Token string `json:"token"` 23 + } 24 + params := map[string]any{ 25 + "aud": audience, 26 + "exp": time.Now().Add(serviceAuthTTL).Unix(), 27 + "lxm": lexiconMethod, 28 + } 29 + if err := pds.Get(ctx, syntax.NSID("com.atproto.server.getServiceAuth"), params, &out); err != nil { 30 + return "", fmt.Errorf("get service auth for %q: %w", audience, err) 31 + } 32 + if out.Token == "" { 33 + return "", fmt.Errorf("PDS returned an empty service auth token for %q", audience) 34 + } 35 + return out.Token, nil 36 + }
+5
cmd/tg/main.go
··· 1 1 package main 2 2 3 3 import ( 4 + "log/slog" 4 5 "os" 5 6 6 7 "github.com/alyraffauf/tg/internal/cli" 7 8 ) 8 9 9 10 func main() { 11 + // Indigo logs retried DPoP-nonce challenges at WARN; suppress to keep CLI 12 + // output clean. 13 + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))) 14 + 10 15 if err := cli.Execute(); err != nil { 11 16 os.Exit(1) 12 17 }
+211
internal/cli/repo_create.go
··· 1 + package cli 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "os" 7 + "time" 8 + 9 + "github.com/alyraffauf/tg/atproto" 10 + "github.com/alyraffauf/tg/internal/gitutil" 11 + "github.com/alyraffauf/tg/knot" 12 + "github.com/alyraffauf/tg/tangled" 13 + "github.com/bluesky-social/indigo/atproto/atclient" 14 + "github.com/spf13/cobra" 15 + ) 16 + 17 + var ( 18 + repoCreateDescription string 19 + repoCreateKnot string 20 + repoCreateClone bool 21 + repoCreatePushPath string 22 + repoCreateRemote string 23 + ) 24 + 25 + var repoCreateCmd = &cobra.Command{ 26 + Use: "create <name>", 27 + Short: "Create a repository on Tangled", 28 + Long: `Create a repository on Tangled. 29 + 30 + The repository is provisioned on a knot (default ` + knot.DefaultKnot + `) and a 31 + sh.tangled.repo record is written to your PDS. The repository name is used as 32 + the record key, matching the current Tangled schema. 33 + 34 + Use --clone to clone the new repository into the current directory, or 35 + --push=<path> to push an existing local repository at that path to the new 36 + remote (and set its current branch as the default branch). 37 + 38 + Requires authentication (run "tg auth login" first).`, 39 + Args: cobra.ExactArgs(1), 40 + RunE: func(cmd *cobra.Command, args []string) error { 41 + ctx := cmd.Context() 42 + 43 + if auth == nil || !auth.IsAuthenticated() { 44 + return fmt.Errorf("not logged in; run \"tg auth login\" first") 45 + } 46 + 47 + pds, err := auth.APIClient(ctx) 48 + if err != nil { 49 + return fmt.Errorf("get auth client: %w", err) 50 + } 51 + did := pds.AccountDID.String() 52 + 53 + knotHost := repoCreateKnot 54 + if knotHost == "" { 55 + knotHost = knot.DefaultKnot 56 + } 57 + 58 + uri, err := provisionRepo(ctx, pds, provisionRepoInput{ 59 + KnotHost: knotHost, 60 + OwnerDID: did, 61 + Name: args[0], 62 + Description: repoCreateDescription, 63 + }) 64 + if err != nil { 65 + return err 66 + } 67 + 68 + handle := ownerHandle(ctx, did) 69 + fmt.Printf("Created repository %s/%s\n", handle, args[0]) 70 + 71 + if repoCreateClone { 72 + if err := gitutil.CloneRepo(ctx, handle, args[0], args[0]); err != nil { 73 + return fmt.Errorf("clone new repository: %w", err) 74 + } 75 + } 76 + if repoCreatePushPath != "" { 77 + if err := pushToNewRepo(ctx, pds, pushToNewRepoInput{ 78 + KnotHost: knotHost, 79 + RepoURI: uri, 80 + Handle: handle, 81 + RepoName: args[0], 82 + PushPath: repoCreatePushPath, 83 + RemoteName: repoCreateRemote, 84 + }); err != nil { 85 + return err 86 + } 87 + } 88 + return nil 89 + }, 90 + } 91 + 92 + func init() { 93 + repoCreateCmd.Flags().StringVar(&repoCreateDescription, "description", "", "Repository description") 94 + repoCreateCmd.Flags().StringVar(&repoCreateKnot, "knot", "", "knot host to create on (default "+knot.DefaultKnot+")") 95 + repoCreateCmd.Flags().BoolVar(&repoCreateClone, "clone", false, "Clone the new repository into the current directory") 96 + repoCreateCmd.Flags().StringVar(&repoCreatePushPath, "push", "", "Push an existing local repository at this path to the new remote (e.g. .)") 97 + repoCreateCmd.Flags().StringVar(&repoCreateRemote, "remote", "origin", "Remote name to use with --push") 98 + } 99 + 100 + type provisionRepoInput struct { 101 + KnotHost string 102 + OwnerDID string 103 + Name string 104 + Description string 105 + } 106 + 107 + // provisionRepo creates the repo on the knot and writes the sh.tangled.repo 108 + // record to the PDS. 109 + func provisionRepo(ctx context.Context, pds *atclient.APIClient, in provisionRepoInput) (string, error) { 110 + token, err := atproto.GetServiceAuth(ctx, pds, "did:web:"+in.KnotHost, "sh.tangled.repo.create") 111 + if err != nil { 112 + return "", err 113 + } 114 + repoDid, err := knot.New(in.KnotHost, token).CreateRepo(ctx, knot.CreateRepoInput{ 115 + Name: in.Name, 116 + Rkey: in.Name, 117 + }) 118 + if err != nil { 119 + return "", err 120 + } 121 + // Name omitted: it's the rkey, and the AppView derives it from there. 122 + record := tangled.RepoRecord{ 123 + Type: "sh.tangled.repo", 124 + Knot: in.KnotHost, 125 + CreatedAt: time.Now().UTC().Format(time.RFC3339), 126 + RepoDid: repoDid, 127 + } 128 + if in.Description != "" { 129 + record.Description = in.Description 130 + } 131 + uri, _, err := atproto.PutRecord(ctx, pds, atproto.PutRecordInput{ 132 + Repo: in.OwnerDID, 133 + Collection: "sh.tangled.repo", 134 + Rkey: in.Name, 135 + Record: record, 136 + }) 137 + if err != nil { 138 + return "", err 139 + } 140 + return uri, nil 141 + } 142 + 143 + type pushToNewRepoInput struct { 144 + KnotHost string 145 + RepoURI string 146 + Handle string 147 + RepoName string 148 + PushPath string 149 + RemoteName string 150 + } 151 + 152 + // pushToNewRepo sets the default branch to the local repo's current branch, 153 + // then pushes. Default-branch failure is warned, not fatal. Set before push so 154 + // the knot's post-receive hook sees pushed == default and skips its PR 155 + // suggestion. 156 + func pushToNewRepo(ctx context.Context, pds *atclient.APIClient, in pushToNewRepoInput) error { 157 + branch, err := setDefaultBranch(ctx, pds, setDefaultBranchInput{ 158 + KnotHost: in.KnotHost, 159 + RepoURI: in.RepoURI, 160 + Dir: in.PushPath, 161 + }) 162 + if err != nil { 163 + fmt.Fprintf(os.Stderr, "warning: could not set default branch: %v\n", err) 164 + } else { 165 + fmt.Printf("Set default branch to %s\n", branch) 166 + } 167 + if err := gitutil.PushNewRepo(ctx, gitutil.PushNewRepoParams{ 168 + Dir: in.PushPath, 169 + Handle: in.Handle, 170 + Repo: in.RepoName, 171 + RemoteName: in.RemoteName, 172 + }); err != nil { 173 + return fmt.Errorf("push to new repository: %w", err) 174 + } 175 + return nil 176 + } 177 + 178 + type setDefaultBranchInput struct { 179 + KnotHost string 180 + RepoURI string 181 + Dir string 182 + } 183 + 184 + // setDefaultBranch repoints the default branch to the local repo's current 185 + // branch. Mints a fresh token — the create token is lexicon-scoped and won't 186 + // authorize setDefaultBranch. 187 + func setDefaultBranch(ctx context.Context, pds *atclient.APIClient, in setDefaultBranchInput) (string, error) { 188 + branch, err := gitutil.CurrentBranch(ctx, in.Dir) 189 + if err != nil { 190 + return "", err 191 + } 192 + token, err := atproto.GetServiceAuth(ctx, pds, "did:web:"+in.KnotHost, "sh.tangled.repo.setDefaultBranch") 193 + if err != nil { 194 + return "", err 195 + } 196 + if err := knot.New(in.KnotHost, token).SetDefaultBranch(ctx, knot.SetDefaultBranchInput{ 197 + Repo: in.RepoURI, 198 + DefaultBranch: branch, 199 + }); err != nil { 200 + return branch, err 201 + } 202 + return branch, nil 203 + } 204 + 205 + // ownerHandle resolves an owner DID to a handle, falling back to the DID. 206 + func ownerHandle(ctx context.Context, did string) string { 207 + if ident, err := resolver.ResolveDID(ctx, did); err == nil { 208 + return ident.Handle.String() 209 + } 210 + return did 211 + }
+2 -1
internal/cli/root.go
··· 51 51 prCmd.AddCommand(prCheckoutCmd) 52 52 53 53 rootCmd.AddCommand(repoCmd) 54 + repoCmd.AddCommand(repoCloneCmd) 55 + repoCmd.AddCommand(repoCreateCmd) 54 56 repoCmd.AddCommand(repoListCmd) 55 - repoCmd.AddCommand(repoCloneCmd) 56 57 } 57 58 58 59 func initAuth() {
+24
internal/gitutil/branch.go
··· 1 + package gitutil 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "os/exec" 7 + "strings" 8 + ) 9 + 10 + // CurrentBranch returns the checked-out branch name at dir; errors if HEAD is 11 + // detached. 12 + func CurrentBranch(ctx context.Context, dir string) (string, error) { 13 + cmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD") 14 + cmd.Dir = dir 15 + out, err := cmd.Output() 16 + if err != nil { 17 + return "", fmt.Errorf("get current branch in %q: %w", dir, err) 18 + } 19 + branch := strings.TrimSpace(string(out)) 20 + if branch == "" || branch == "HEAD" { 21 + return "", fmt.Errorf("no current branch (detached HEAD) in %q", dir) 22 + } 23 + return branch, nil 24 + }
+26
internal/gitutil/push_repo.go
··· 1 + package gitutil 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + ) 7 + 8 + type PushNewRepoParams struct { 9 + Dir string // local repository to push from 10 + Handle string // Tangled owner handle 11 + Repo string // repository name 12 + RemoteName string // git remote to add and push to 13 + } 14 + 15 + // PushNewRepo adds a remote at Dir and pushes the current branch. 16 + // Fails if RemoteName already exists. 17 + func PushNewRepo(ctx context.Context, params PushNewRepoParams) error { 18 + remoteURL := fmt.Sprintf("git@tangled.org:%s/%s", params.Handle, params.Repo) 19 + if err := runIn(params.Dir, ctx, "git", "remote", "add", params.RemoteName, remoteURL); err != nil { 20 + return fmt.Errorf("add remote %q (already exists? use --remote to pick another name): %w", params.RemoteName, err) 21 + } 22 + if err := runIn(params.Dir, ctx, "git", "push", "-u", params.RemoteName, "HEAD"); err != nil { 23 + return fmt.Errorf("push to %q: %w", params.RemoteName, err) 24 + } 25 + return nil 26 + }
+35
knot/client.go
··· 1 + package knot 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/bluesky-social/indigo/atproto/atclient" 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + ) 9 + 10 + // DefaultKnot is the public Tangled knot used when none is specified. 11 + const DefaultKnot = "knot1.tangled.sh" 12 + 13 + // Client calls Tangled knot procedures, authenticated with a PDS-minted 14 + // service-auth JWT (Bearer). 15 + type Client struct { 16 + *atclient.APIClient 17 + } 18 + 19 + // New returns a Client for host, authenticated with a service-auth token. 20 + func New(host, token string) *Client { 21 + return &Client{ 22 + APIClient: &atclient.APIClient{ 23 + Host: "https://" + host, 24 + Auth: bearerAuth(token), 25 + }, 26 + } 27 + } 28 + 29 + // bearerAuth is a Bearer-token AuthMethod for service-auth JWTs. 30 + type bearerAuth string 31 + 32 + func (b bearerAuth) DoWithAuth(c *http.Client, req *http.Request, _ syntax.NSID) (*http.Response, error) { 33 + req.Header.Set("Authorization", "Bearer "+string(b)) 34 + return c.Do(req) 35 + }
+30
knot/create_repo.go
··· 1 + package knot 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + ) 9 + 10 + // CreateRepoInput is the argument to sh.tangled.repo.create. 11 + type CreateRepoInput struct { 12 + Name string `json:"name"` 13 + Rkey string `json:"rkey"` 14 + DefaultBranch string `json:"defaultBranch,omitempty"` 15 + } 16 + 17 + // CreateRepo creates the repo via sh.tangled.repo.create, returning the minted 18 + // repoDid. Use the repo name as the rkey (current schema). 19 + func (c *Client) CreateRepo(ctx context.Context, input CreateRepoInput) (string, error) { 20 + var out struct { 21 + RepoDid *string `json:"repoDid,omitempty"` 22 + } 23 + if err := c.Post(ctx, syntax.NSID("sh.tangled.repo.create"), input, &out); err != nil { 24 + return "", fmt.Errorf("create repo on knot: %w", err) 25 + } 26 + if out.RepoDid == nil || *out.RepoDid == "" { 27 + return "", fmt.Errorf("knot did not return a repoDid") 28 + } 29 + return *out.RepoDid, nil 30 + }
+22
knot/set_default_branch.go
··· 1 + package knot 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + ) 9 + 10 + // SetDefaultBranchInput is the argument to sh.tangled.repo.setDefaultBranch. 11 + type SetDefaultBranchInput struct { 12 + Repo string `json:"repo"` // at:// URI of the sh.tangled.repo record 13 + DefaultBranch string `json:"defaultBranch"` 14 + } 15 + 16 + // SetDefaultBranch repoints the default branch (bare repo HEAD) on the knot. 17 + func (c *Client) SetDefaultBranch(ctx context.Context, input SetDefaultBranchInput) error { 18 + if err := c.Post(ctx, syntax.NSID("sh.tangled.repo.setDefaultBranch"), input, nil); err != nil { 19 + return fmt.Errorf("set default branch to %q: %w", input.DefaultBranch, err) 20 + } 21 + return nil 22 + }