A basic CLI to set up Tangled repos

Files
README.md

tangy #

A small CLI to set up and manage Tangled repos. (I thought this must exist but I haven't found it.)

It talks to Tangled the same way the website does, but from your terminal: it logs you in with your own ATProto account, creates the repo on a knot, declares it on your PDS, and wires up a local git repo pointing at it.

Install #

npm install
npm link   # optional: puts `tangy` on your PATH

Requires Node ≥ 20.

Usage #

# 1. Log in (opens your browser to authorize; credentials go in the OS keychain)
tangy login alice.tngl.sh

#    log in more accounts whenever you like — earlier logins are kept
tangy login work.example.com

# 2. Choose where each user's repos get created locally
tangy configuration set root ~/Code/tangled                     # default user
tangy configuration set root ~/Work/tangled -u work.example.com

#    (optional) pick a different knot to host on; defaults to knot1.tangled.sh
tangy configuration set knot knot1.tangled.sh

# 3. Create a repo — makes it on Tangled, wires it up locally, and pushes
tangy repo create my-project
tangy repo create my-project -d "A short description"
tangy repo create my-project --no-push   # skip the automatic push
tangy repo create my-project -u work.example.com   # act as another account

# 4. Or move an existing local git repo into Tangled
tangy move ~/Code/some-project

# 5. Bring over a GitHub repo's open issues and PRs (comments, labels,
#    assignees, reactions included)
tangy copy owner/repo my-project
tangy copy https://github.com/owner/repo --dry-run

# See who is logged in, and pick who commands act as by default
tangy user list
tangy user default work.example.com

# …later — logs out the default user, or the one named with -u
tangy logout
tangy logout -u alice.tngl.sh

tangy repo create <name>:

  • creates <root>/<name>/ locally,
  • creates the repo on your knot and declares it on your PDS,
  • runs git init and sets origin to the Tangled repo,
  • writes README.md (# <name>) and a Node-friendly .gitignore,
  • makes an initial commit (if your git identity is configured),
  • pushes that commit to origin — this assumes your SSH key is already added to Tangled.

Pushing is on by default. Pass --no-push to skip it and push yourself later:

tangy repo create my-project --no-push
cd <root>/my-project
git push -u origin main

If the push fails (e.g. your SSH key isn't on Tangled yet), the repo has still been created both locally and remotely — add your key and run git push -u origin main from the repo directory.

tangy move <dir> adopts an existing local git repo into Tangled:

  • creates a repo on your knot named after the directory (so tangy move ~/Code/some-project creates some-project) and declares it on your PDS,
  • moves the directory into <root>/ — it refuses to overwrite anything already there, and if the repo already sits at <root>/<name> it just skips the move,
  • points origin at the Tangled repo (replacing any previous origin),
  • if the repo has a package.json, points its repository field at the Tangled repo and commits just that change (left uncommitted if git has no identity configured or the repo has no commits yet),
  • pushes the current branch (the knot's default branch is set to match it).

The directory must be the root of a git work tree and on a checked-out branch. Other branches and tags are not pushed automatically. --no-push and -d/--description work the same as for repo create. If creating the repo on Tangled fails, the directory is moved back where it was.

Copying issues and PRs from GitHub #

tangy copy <github> [repo] copies a GitHub repo's open issues and pull requests (closed ones stay behind) into a Tangled repo you own — by default one with the same name, which must already exist (tangy repo create / tangy move first). It takes -u <user> like everything else, and re-running it is safe: tangy remembers what it already copied (in copies.json next to its config) and only picks up new items and new comments.

What gets copied, and how:

  • Issues and PRs become Tangled tickets, keeping their original timestamps. A PR's commits are fetched from GitHub and pushed to a github/pr-<number> branch on the Tangled repo (over SSH, like any push — your key needs to be on Tangled), then handed to the knot as a patch against the PR's base branch (or the repo's default branch, when Tangled doesn't have the base branch). The knot builds the diff itself, so a PR whose commits share no history with the target branch is skipped.
  • Comments come along (conversation comments; inline review comments don't).
  • Labels are added to the repo's own label catalogue (with their colors) and applied to the copied items. Labels the repo already has are reused by name.
  • Reactions are summarised in the item ("Reactions: 👍 3 · ❤️ 1"), and any reaction you made on GitHub is also recreated natively as you.
  • Assignees are recreated natively too: every assignee whose GitHub account maps to an AT account is assigned for real. Assignees without a mapping fall back to an "Assignees: …" line in the item.

Since tangy can't impersonate other people, everything is created by the acting user. Anything authored by someone else gets a provenance line first:

Original by @foo = @foo.eurosky.social.

The = @handle part comes from a GitHub→AT mapping that tangy builds up as it goes: the first time it sees a GitHub account it asks you for the matching AT handle (press Enter to ignore that account), and remembers the answer either way. Items you authored yourself (tangy asks for your GitHub username once) are created as you, with no provenance line. Bots are never asked about.

--dry-run shows what would be copied — and which accounts would need mapping — without writing anything. --no-issues / --no-pulls restrict what gets copied. GitHub access is anonymous unless a GITHUB_TOKEN/GH_TOKEN env var or a logged-in gh CLI is around (recommended for big repos: the anonymous API allows only 60 requests/hour).

Copies made by older tangy versions, which wrote issues and comments to your PDS, still count: Tangled has moved those records into the repo's account, and tangy matches them up by title and timestamp before adding new comments.

Multiple users #

Several accounts can be logged in at once, and each keeps its own settings — most importantly its own root, so different identities can keep their repos in different places.

  • tangy login <handle> adds an account without touching existing logins. The first account to log in becomes the default user.
  • Every account-bound command (logout, configuration set/get, repo create, move) takes -u <user> to pick the account: a handle, a DID, or any unique handle prefix (-u alice finds alice.tngl.sh). Without -u, the default user is used.
  • tangy user list shows the logged-in users (* marks the default); tangy user default <user> changes the default, and tangy user default shows it.
  • tangy logout [-u <user>] logs out just that user. If the default user logs out and exactly one account remains, it becomes the default.

Console output always names the account being acted through, e.g. Creating "my-project" on knot1.tangled.sh as alice.tngl.sh….

Config files written by older single-user versions of tangy are upgraded automatically.

Staying logged in #

Out of the box, tangy is an OAuth loopback client, which ATProto PDSes treat as a public client: the whole session is hard-capped at two weeks from login, no matter how often tokens are refreshed. If tangy keeps logging you out, that's why — and no cron job can fix it in this mode.

The fix is to run tangy as a confidential client, which authenticates to the PDS with a private key. Confidential sessions last up to two years, with refresh tokens that survive three months of inactivity — so they can be kept alive indefinitely by refreshing now and then. This needs a site you control, because the PDS reads the client's metadata from a URL:

# 1. Generate a signing key (stored in the keychain) and the two files to host
tangy client setup https://example.com/tangy/

# 2. Host the generated client-metadata.json and callback.html at that URL
#    (they're written to ./tangy-client/, or wherever -o/--out points)

# 3. Log in again (existing sessions belong to the old client identity)
tangy login alice.tngl.sh

# 4. Keep sessions fresh forever: a launchd job that runs `tangy refresh`
#    twice a day (and at login)
tangy keepalive on

tangy refresh [-u <user>] force-rotates the stored tokens by hand, tangy keepalive status|off manages the background job (macOS launchd — unlike cron it catches up on runs the machine slept through; on other platforms keepalive on prints a crontab line to add yourself), and tangy client show|clear inspects or abandons the confidential identity. The callback page just bounces the browser back to the same local server the loopback flow uses, so login feels identical.

How it works #

Tangled's org.tangled.* lexicons split a repository's data in two: your PDS holds what you own (your profile, your claim to a repo), and the repo itself is an ATProto account of its own — the repo DID — hosted by its knot, which holds everything about the repo (issues, pull requests, comments, labels). tangy follows that split, the same way the tangled.org web app does.

  • Auth uses ATProto OAuth (@atproto/oauth-client-node) — by default as a loopback client, optionally as a confidential one (see "Staying logged in"). Sessions (refresh tokens + DPoP key) and the confidential client's signing key are stored in the OS keychain via @napi-rs/keyring, never on disk.
  • Repo creation asks your PDS for a short-lived service-auth JWT addressed to the knot and calls the knot's org.tangled.repo.createRepo; the knot mints the repo DID and writes its org.tangled.repo.manifest. tangy then writes the other half of the ownership claim, an org.tangled.repo.declaration keyed by the repo's slug, to your PDS, and asks the appview's API to confirm it indexed it (unwinding both halves if it refuses).
  • Issues, pulls, comments, labels and reactions are records in the repo's account on the knot (org.tangled.track.ticket — a pull request is a ticket with a target branch — org.tangled.review.patch, org.tangled.feed.comment, org.tangled.feed.reaction, org.tangled.label.definition). tangy writes them through the knot's com.atproto.repo.createRecord, with a service-auth JWT addressed to the repo DID; the knot checks you may contribute and stamps each record with who wrote it. Patches go through org.tangled.review.submitPatch, naming base and head commits that are already on the repo. After every knot write, tangy adds an org.tangled.graph.attestation to your PDS so the record is claimed by you and not just by the knot.
  • Settings (root, knot, …) live per user in ~/.config/tangy/config.json, along with which user is the default and the GitHub→AT account mapping used by tangy copy.

Configuration keys #

Each key is stored per user (add -u <user> to set or read another account's value):

key meaning default
root local directory this user's repos are created in (unset)
knot knot hostname to host this user's new repos on knot1.tangled.sh
appview Tangled appview base URL (used in links) https://tangled.org
api the appview's API, asked to confirm new repos got indexed https://api.tangled.org

View them with tangy configuration get [-u <user>].