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; runtangled auth loginagain. - 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. SetTANGLED_CONFIG_DIRto 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-gitconfigures 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 withorg.tangled.knot.describeKnotand remembered. -
SSH-only knots: the Go knot only takes pushes over SSH, so its repositories get SSH remotes. With
git_protocolset tohttps, 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 asalice.example. You can also pin an account to a knot withtangled auth setup-git --host knot.example --as alice.example --account. -
Pushing without a helper:
tangled auth tokenprints 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 fromupstreamfirst, thenorigin. - Issues and pull requests: their number (
42or#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,labelwith--add/--remove,react), the issue actions (create,comment,close,reopen,edit,delete,labelwith--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, andapifor 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 ansh.tangled.repo.artifactrecord there. The indexer lists these, and blob.pipobscure.com serves them;release uploadandrelease listgive 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 anorg.tangled.repo.artifactrecord 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:
- tests and builds the bundle;
- attaches
tangled.nzipto the tag (tangled release upload); - attests it;
- points the
tangledlisting athttps://blob.pipobscure.com/pipobscure.com/tangled/latest/tangled.nzip, sobundle install @<account>/tangledgets 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 #
- TypeScript 97%
- JavaScript 2%
- Shell 1%