The `gh` equivalent for tangled.org

Files
README.md

tangled #

tangled is a command-line client for tangled, in the spirit of GitHub's gh. It works with repositories, issues, pull requests, CI, release files, strings and profiles. It also lets you push to knots over HTTPS with your atproto account, without any SSH keys.

tangled auth login alice.bsky.social        # sign in (OAuth in the browser)
tangled repo clone alice.bsky.social/project
cd project && git push                      # over HTTPS, no SSH key needed
tangled pr create --fill
tangled issue list

Install #

tangled ships as a single file, a bundle. It needs Node 26.11.1 or later.

Install bundle if you don't yet have it. Then click the link to install tangled: nzip:pipobscure.com/tangled

Or, from a terminal, the bundle tool installs itself:

curl -LO https://github.com/pipobscure/bundles/releases/latest/download/bundle.nzip
node --experimental-vfs --vfs-load ./bundle.nzip install

Then:

bundle install @pipobscure.com/tangled

bundle install checks the release's attestation before it installs anything (see Releasing), and bundle update keeps tangled current.

Credential helper: this puts tangled on your PATH. That is enough for git, which runs the helper as tangled auth git-credential; tangled auth setup-git configures it that way. If you also link the same file as git-credential-tangled somewhere on your PATH, setup-git uses that name instead.

Tab completion and shell integration #

tangled shell --install        # adds one line to your shell's startup file

tangled auth login offers to do this the first time. It works for bash, zsh, fish and PowerShell, and tangled shell --uninstall takes it out again. To set it up by hand, load tangled shell <shell> from your startup file:

eval "$(tangled shell zsh)"             # ~/.zshrc  (bash: ~/.bashrc)
tangled shell fish | source             # ~/.config/fish/config.fish

What it adds:

  • Tab completion of commands, options and the values they take. Values include your repositories and knots, the accounts you are signed in as, the checkout's branches, tags and remotes, CI workflows, and settings. fish and PowerShell also show what each one is.
  • A quiet check when an interactive shell starts, at most once a day. It says nothing unless a signed-in session has ended, and it keeps what completion offers up to date.

Completion never waits on the network. Your repositories and knots come from a cache built from the repository declarations in your PDS. The cache is refreshed by the commands that change it, by the daily check, and in the background when it is more than a few hours old. tangled completion <shell> prints the completion part alone.

Signing in #

tangled auth login alice.bsky.social                 # OAuth, in the browser
tangled auth login --app-password alice.bsky.social  # an app password: SSH sessions, CI
echo "$APP_PASSWORD" | tangled auth login -p --password-stdin alice.bsky.social
tangled auth status
tangled auth switch           # several accounts; --as <handle> picks one per command
  • OAuth asks your PDS only for tangled's own record collections, blob uploads, and service tokens for the knot and spindle methods tangled calls. Sign-ins from before tangled moved to org.tangled.* (2026-10-29) lack the new permissions; run tangled auth login again.
  • App passwords come from your PDS's settings. The password starts the session and is not stored.
  • Sessions are kept in ~/.config/tangled/accounts.json (mode 0600) and refreshed as needed. Set TANGLED_CONFIG_DIR to keep them somewhere else.

Pushing over HTTPS #

knot2 (the knot server behind knot1.tangled.sh, among others) accepts pushes over HTTPS. Each push must carry a short-lived service-auth token: a JWT your PDS signs for the knot (aud = did:web:<knot>) and for pushing (lxm = org.tangled.git.receivePack). Sign-ins from before tangled asked for that permission can't mint it; run tangled auth login again.

tangled is a git credential helper that mints these tokens. When a knot asks for credentials, git asks tangled, and tangled asks your PDS for a fresh token:

tangled repo clone owner/name               # clones are set up as they are made
tangled repo remote                         # set up the remotes of an existing checkout
tangled auth setup-git                      # every knot your repositories live on, globally
tangled auth setup-git --host knot.example  # one knot

By default (git_protocol = auto), remotes of repositories on knots that take HTTPS pushes use HTTPS, with tangled as the credential helper. tangled tells such knots apart by asking without credentials: knot2 answers 401 and asks for some. Other knots get SSH remotes.

  • Global helper: setup-git configures the helper per knot. You can also set it for every host yourself (git config --global credential.helper tangled). It then answers only for hosts that are knots, checked with org.tangled.knot.describeKnot and remembered.

  • SSH-only knots: the Go knot only takes pushes over SSH, so its repositories get SSH remotes. With git_protocol set to https, they fetch over HTTPS and push over SSH.

  • Several accounts: git passes the URL's username to the helper, so https://alice.example@knot.example/… pushes as alice.example. You can also pin an account to a knot with tangled auth setup-git --host knot.example --as alice.example --account.

  • Pushing without a helper: tangled auth token prints a token to use directly:

    git -c "http.extraHeader=Authorization: Bearer $(tangled auth token)" push
    

Commands #

auth login, logout, status, switch, token, setup-git
repo list, view, create, clone, remote, fork, delete, edit, rename, sync, star, unstar, stars, branches, delete-branch, archive, collaborator
issue list, view, create, comment, close, reopen, edit, label, delete, react
pr list, view, diff, checkout, create, update, comment, close, reopen, merge, checks, label, react
run CI: list, view, logs (streams live), watch, cancel, rerun, approve, trigger
release Release files on tags: list, upload, download, delete-file
string Snippets: list, view, create, edit, delete
secret CI secrets: list, set, delete
user view, follow, unfollow, followers, following, edit
ssh-key list, add, delete, migrate
knot Knots you run: info, members, add-member, remove-member, blocked, ban, unban
spindle Spindles you run: info, members, add-member, remove-member
config, alias Settings, and short names for commands
api Any XRPC call: on your PDS, a knot, a spindle or the indexer
shell, completion Tab completion and shell integration
search, browse, status Search all of tangled (is:open, label: … qualifiers), open a repository, ticket, file or commit on tangled.org, and your open issues and pull requests

tangled <command> --help describes each command.

Naming things:

  • Repositories: owner/name (the owner as a handle or DID), the repository's own DID, or a URL. With none of these, it is the repository of the checkout you are in, taken from upstream first, then origin.
  • Issues and pull requests: their number (42 or #42), URL or AT-URI.

What writes #

Most commands only read. The ones that write:

  • On the network — they publish something other people can see, or change a repository, a knot or a spindle: repo create/fork/edit/rename/sync/delete, repo star/unstar/delete-branch/collaborator add/remove, the pull-request actions (create, update, comment, close, reopen, merge, label with --add/--remove, react), the issue actions (create, comment, close, reopen, edit, delete, label with --add/--remove, react), run trigger/cancel/rerun/approve, release upload/delete-file, string create/edit/delete, secret set/delete, user follow/unfollow/edit, ssh-key add/delete/migrate, changing a knot's members or bans or a spindle's members, and api for procedures (POST).
  • On this machine only — sessions, settings, git config, the shell: auth login/logout/switch/setup-git, repo clone/remote, pr checkout, config set/unset, alias set/delete, shell --install/--uninstall, skill install.
  • Either, by method: api — queries (GET) read, procedures (POST) write.

The generated command reference (skills/tangled/reference.md) marks every writing command and lists them all up top, and the agent skill (tangled skill show) carries the same list with what each one does. pr create --dry-run shows a pull request without submitting anything.

How it talks to tangled #

What Where How
Your own records: stars, follows, profile, SSH keys, strings, repository declarations your PDS your session
The repository's records: issues, pull requests, comments, reactions, labels, collaborators the repository's own account, on its knot service-auth tokens from your PDS, addressed to the repository
Actions: creating repos, branches, merging, syncing forks, CI, secrets the knot, the spindle, or the indexer's mirror of the knot service-auth tokens from your PDS
Knot members and bans; spindle allow-lists the knot's own account (its did:web); the spindle service-auth tokens from your PDS, addressed to the server (its admin only)
Listings: issues of a repo, stars, comments, search the indexer, api.tangled.org unauthenticated

A repository has a DID of its own, and is an atproto account its knot hosts. Issues and pull requests are records in it (org.tangled.track.ticket), numbered by the knot: #42 is the record with key 42. When the knot writes something for you there, tangled also writes an attestation to your PDS (org.tangled.graph.attestation) that says you wrote it.

Knots and spindles are not registered anywhere: each is a service of its own, named by its did:web, and a repository picks its spindle in its settings (repo edit --spindle). tangled knot and tangled spindle show what one runs (info) and, for their admin, manage who may use it. repo sync moves a fork's branch up to its source's through the indexer's mirror, or with git in a checkout (--git), and only ever as a fast-forward: commits the fork has of its own are never overwritten.

Labels belong to the repository: its owner defines them on tangled.org, and an issue or pull request carries the keys of the ones it has (tangled issue label lists them). They take no values; who an issue is assigned to is a list of people of its own, which maintainers set with issue create --assignee or issue edit --add-assignee. An issue is open, closed, or completed (issue close --completed); a merged pull request is a completed one.

A pull request is a ticket with a target branch, and each of its rounds is a pair of commits (org.tangled.review.patch): nothing is uploaded, so pr create and pr update push the branch first, to the repository or your fork of it, and the knot keeps every round's head commit. A patch file is not a pull request any more; push it to a branch. pr merge merges through the indexer's mirror of the knot, which only rebases for now: the round's commits go onto the target branch as they are, with no merge commit (so there is no --subject or --body for one), and a stacked pull request is merged on its own, not together with its stack. pr merge --style manual only marks a pull request merged, for changes you merged and pushed yourself.

Settings:

Setting Default
knot knot1.tangled.sh Where repo create and repo fork put repositories
git_protocol auto auto (HTTPS for knots that take HTTPS pushes, SSH for others), https or ssh
release_store pds Where release upload keeps files: pds, knot or both (see Release files)
appview https://tangled.org For web links
api https://api.tangled.org The indexer: listings, search, and tangled api --indexer
editor, pager

Change them with tangled config set <key> <value>.

Release files #

A release file is a blob attached to an annotated tag, kept in one of two places, or both:

  • pds: the blob is in the uploader's PDS, named by an sh.tangled.repo.artifact record there. The indexer lists these, and blob.pipobscure.com serves them; release upload and release list give a file kept only on the knot the knot's own blob URL instead.
  • knot: the blob is in the repository's own account on its knot, named by an org.tangled.repo.artifact record the knot writes there for you.

Not everyone wants release files hosted on their knot, so release upload keeps them where --store (or the release_store setting) says: pds (the default), knot, or both. With both, a file one store refuses still goes to the other, with a warning. release list, download and delete-file read both stores, and show the same file in both as one entry (pds+knot). delete-file removes your copies from both, or only from the store --store names.

Legacy records #

tangled copied most sh.tangled.* records to their org.tangled.* successors once. What it did not copy is still read: legacy strings (sh.tangled.string) and SSH keys (sh.tangled.publicKey) are listed, marked legacy, and deleting a string or key removes both forms. knot2 knots read only org.tangled.key.sshKey, so tangled ssh-key migrate copies legacy SSH keys over. Editing a legacy string saves it as a new one.

Development #

npm install
npm run dev -- --help     # run the TypeScript sources directly
npm test                  # unit tests
npm run typecheck
npm run build             # compile to dist/

The sources use only erasable TypeScript syntax, so Node runs them as they are. tangled has no runtime dependencies.

Releasing #

tangled ships as a bundle: one file containing everything it runs.

npm run bundle    # build, compute and check the file list, pack tangled.nzip

Trust: the bundle is not signed. Its publisher vouches for it with an atproto attestation (bundle attest --kind published), which bundle install checks. Both the attestation and the listing (bundle publish) are written with an app password, so a spindle can release without anyone signing in.

On the spindle: set the repository's spindle once, and give it an app password:

tangled repo edit --spindle spindle.tangled.network
tangled secret set TANGLED_HANDLE           # the account that uploads the release file
tangled secret set TANGLED_APP_PASSWORD     # an app password of that account
# optional, to attest and publish as another account (e.g. an organisation's):
tangled secret set BUNDLE_ATPROTO_IDENTIFIER
tangled secret set BUNDLE_ATPROTO_PASSWORD

Then push an annotated tag (release files attach to annotated tags only):

git tag -a v0.1.0 -m 'tangled 0.1.0' && git push origin v0.1.0
tangled run watch

.tangled/workflows/release.yml runs tools/release.sh, which:

  1. tests and builds the bundle;
  2. attaches tangled.nzip to the tag (tangled release upload);
  3. attests it;
  4. points the tangled listing at https://blob.pipobscure.com/pipobscure.com/tangled/latest/tangled.nzip, so bundle install @<account>/tangled gets it.

That URL never changes: blob.pipobscure.com looks up the newest tag's release file from atproto records. The script waits until it serves the new file, then publishes the listing anyway if it is slow (set RELEASE_URL to use another URL).

License #

EUPL-1.2