[READ-ONLY] Mirror of https://github.com/just-cameron/tangled-cli. Automation-first CLI for Tangled.org
tangled-cli README.md
14 kB · 273 lines
Markdown
at main

tang #

tang is an independent, automation-first CLI for Tangled. It gives humans and coding agents one command surface for repository context, issues, pull requests, and Spindle CI without scraping the web UI.

Source of truth: https://tangled.org/cameron.stream/tangled-cli

Install #

Published releases support three paths:

# npm (Node 22+)
npm install --global tangled-cli

# or download a checksummed tang-{platform}-{arch} binary from the release page
# https://github.com/just-cameron/tangled-cli/releases

# source checkout
git clone git@tangled.org:cameron.stream/tangled-cli
cd tangled-cli
bun install --frozen-lockfile
bun run build
bun link

Tagged releases build Linux, macOS, and Windows binaries, attach SHA-256 files, and publish the npm package with provenance. Those artifacts are suitable inputs for a Homebrew tap; the tap formula should pin the release URL and checksum rather than building the repository at install time.

The Tangled repository is authoritative. GitHub is a release mirror used to run the cross-platform artifact workflow; tag the same revision in both places. Maintainers must configure the mirror's NPM_TOKEN Actions secret before cutting a release. The npm repository metadata points at that public mirror because npm provenance requires it to exactly match the GitHub repository that performs the publish.

Verify the installation:

tang --version
tang doctor

Authenticate #

OAuth is the default. The browser callback binds to loopback only, and session secrets are stored in the OS keychain.

OAuth login asks only for what tang does with your account, as AT Protocol permission scopes, not for the whole account (transition:generic):

  • repo: the records it writes to your PDS: org.tangled.graph.attestation (create), legacy sh.tangled.repo.issue (create, update, delete), sh.tangled.repo.issue.state and sh.tangled.label.op (create), and sh.tangled.repo.pull (create, delete);
  • rpc:…?aud=* the methods it asks your PDS for service-auth tokens to call: a repository knot's com.atproto.repo.createRecord, putRecord, deleteRecord and org.tangled.review.submitPatch (the knot is addressed by each repository's DID, so any audience), and a spindle's sh.tangled.ci.triggerPipeline and cancelPipeline;
  • blob:application/gzip for a pull request's patch.

The list lives in src/lib/permissions.ts, which is also the only code that writes to your PDS or asks for service auth. A session signed in before this keeps its old, whole-account grant until you sign in again; tang auth status --diagnostic shows the scope a session holds. App-password login has no scopes.

tang auth login
tang auth status
tang auth status --diagnostic     # expiry/scope/audience; token value stays hidden
tang auth logout                  # confirms before deleting the session

On macOS, tang stores OAuth credentials in the login Keychain and accesses them through Apple's stable /usr/bin/security helper. The normal login flow does not ask you to enter any Keychain item data: provide your handle, approve the browser login, and tang stores the resulting session automatically. If a legacy item needs authorization, macOS may show its standard system dialog; enter a Mac login password only in that recognizable dialog. A terminal prompt that says password data for new item is never an instruction to enter an account or Mac password.

Legacy PDS app passwords remain available:

tang auth login --app-password

auth status --show-token exists for exceptional debugging. It refuses JSON and non-interactive output, then presents a warning and requires confirmation. Tokens grant account access and should not be pasted into issues, logs, or agent prompts.

Select a repository #

Repo-scoped commands use the first available selector in this order:

  1. global --repo <selector>
  2. TANG_REPO
  3. configured remote (TANGLED_REMOTE, .tangledrc, or user config)
  4. a Tangled Git remote in the current checkout

Accepted selectors include:

owner.handle/repository
did:plc:owner/repository
did:plc:repository
at://did:plc:owner/sh.tangled.repo/<rkey>
https://tangled.org/owner.handle/repository

Examples:

tang --repo cameron.stream/tangled-cli context
TANG_REPO=did:plc:... tang issue list
tang context --json

Resolution follows the repository record to its stable repository DID and discovers its knot and Spindle. Public reads go to the record owner's authoritative PDS when a signed-in PDS cannot serve a foreign record.

Global automation flags #

--repo <selector>  operate outside a Git checkout
--json             machine-readable output
--fields <fields>  filter comma-separated fields when using global --json
--no-input         never prompt; fail with a remediation instead
-y, --yes          grant confirmation to commands that support it

Environment equivalents:

TANG_REPO
TANG_JSON=1
TANG_NO_INPUT=1
TANG_YES=1

Precedence is explicit command flag → environment → discovered/configured context. --no-input never implies consent: a destructive action also needs --yes.

Issues and pull requests #

tang issue list --state open --label needs-triage
tang issue create "Context fails across PDSes" --body-file issue.md
tang issue view 12                      # with its labels, and its comments by id
tang issue edit 12 --title "Context fails across PDSes and knots"
tang issue comment 12 --body "Reproduced on knot1."
tang issue comment 12 --edit 3myysu4pkg5hk --body "Reproduced on knot1 and knot2."
tang issue close 12
tang issue reopen 12
tang issue delete 12 --yes

tang label defs                         # the repository's label catalogue
tang label create needs-triage --color "#F97316"
tang label add issue 12 needs-triage
tang label remove issue 12 needs-triage --yes
tang label list issue 12

tang pr create --base main --head feature --title "Add feature" --body-file pr.md
tang -R owner/upstream pr create --head feature --title "Add feature"   # from a fork
tang pr list --all
tang pr view <rkey>
tang pr delete <rkey> --yes

List commands support --cursor, --limit, and --all. Bodies accept flags, files, or stdin (--body-file -). Pull creation checks whether the head is behind its base, builds and compresses the Git patch, uploads it as an AT Protocol blob, then creates the pull record.

Where issues live #

A repository with its own DID keeps its issues in that DID's account, hosted by its knot: the DID document (https://plc.directory/<repoDid>) names the knot as #atproto_pds, beside a #tangled_knot service. Issues are org.tangled.track.ticket records there, comments are org.tangled.feed.comment records, and the label catalogue is org.tangled.label.definition records; a ticket's labels are its tangledLabels, keyed by those definitions' record keys.

tang reads that store anonymously (com.atproto.repo.listRecords and getRecord on the knot). To write, it asks the signed-in PDS for a service-auth token whose audience is the repo DID and whose method is the write (com.atproto.server.getServiceAuth), and POSTs com.atproto.repo.createRecord, putRecord or deleteRecord to the knot with it. The knot checks permission, stamps the record with x-tngl-editor and answers with x-tngl-change, the change it made. Like tangled.org's own client, tang then attests every create and edit with an org.tangled.graph.attestation record in the author's PDS naming that change. Edits read the stored version first and swap on its CID, so a newer write by someone else makes the edit fail rather than vanish.

The repository is found through the owner's org.tangled.repo.declaration/<name> record, falling back to a sh.tangled.repo record.

Issue numbers #

A ticket's record key is its issue number, minted by the knot, so 12, #12, https://tangled.org/<owner>/<repo>/issues/12 and at://<repoDid>/org.tangled.track.ticket/12 all name the same issue, durably. JSON marks such numbers "numberKind": "record-key".

Legacy records #

Repositories without a knot store keep the old path: sh.tangled.repo.issue records in each author's PDS, sh.tangled.repo.issue.state records for state, and sh.tangled.label.op records for labels. Those records store no number; tang computes a deterministic 1-based order after collecting them all, so there:

  • use an AT-URI or exact rkey in scripts;
  • treat #12 as a human-facing fallback, not a durable automation ID;
  • JSON includes id, uri, and numberKind: "computed" so callers can make that distinction;
  • numeric resolution emits a warning.

In a repository with a knot store, a legacy record is still read by its AT-URI or rkey (tang issue view at://…/sh.tangled.repo.issue/<rkey>), and tang issue list --legacy lists them. tang issue delete at://<your-did>/sh.tangled.repo.issue/<rkey> --yes deletes one of your own legacy records from your PDS; it works outside a checkout and refuses anyone else's.

In a repository with a knot store, tang pr create opens a pull request as tangled.org keeps them now: an org.tangled.track.ticket with a targetBranch, numbered by the knot, then its org.tangled.review.patch, which only the knot writes, through org.tangled.review.submitPatch, naming the ticket, the base and head commits, and the source branch. The knot fetches the head from what was pushed, so the head branch must be on the remote at the commit it is locally, or nothing is written; if the knot refuses the patch, the ticket is taken back. From a fork's clone, tang -R <upstream> pr create opens it in the upstream repository instead: the head branch must be on the fork's own remote, the upstream's base branch is fetched (into FETCH_HEAD only) and the head compared with that, and the patch names the fork as the repository the knot fetches the head from; an upstream without a knot store is refused. pr list and view show such a pull request's branch as <fork-did>:<branch>. A session signed in before tang asked for submitPatch must sign in again. Elsewhere it writes a legacy sh.tangled.repo.pull record with the patch as a blob. There, pr list, view and delete work on those tickets: a pull request is addressed by its number, completed shows as merged, view gives its head commit, its rounds (one for each patch) and its comments, and delete removes the ticket alone, since its patches are the knot's.

Pipelines and CI #

Pipeline commands use the current sh.tangled.ci.* Spindle XRPC namespace directly:

tang pipeline list
tang ci list --all                         # `ci` aliases `pipeline`
tang pipeline view <pipeline-tid>
tang pipeline logs <pipeline-tid>
tang pipeline logs <pipeline-tid> -w check

tang run --sha "$(git rev-parse HEAD)"      # top-level dispatch shortcut
tang pipeline run -w check -i mode=full
tang pipeline retry <pipeline-tid> --yes
tang pipeline cancel <pipeline-tid> --yes
tang pipeline cancel <pipeline-tid> -w site --yes

list and view are public Spindle queries. run, retry, and cancel mint method-bound service-auth tokens through the signed-in PDS, then call the repository's configured Spindle. logs opens the Spindle subscription and decodes AT Protocol CBOR event frames; it does not scrape or proxy through the Tangled website.

JSON contracts #

Examples:

tang issue list --json
tang issue list --json id,title,state
tang pipeline list --json id,status,commit
tang pipeline logs <id> --json

Paginated list output is an envelope:

{
  "items": [],
  "count": 0,
  "nextCursor": "optional opaque cursor",
  "addressing": "issues and pulls also carry the identifier warning"
}

Field selection filters objects inside items, not envelope metadata. Streaming logs are newline-delimited JSON: one complete event object per stdout line. In JSON mode, normal status prose stays off stdout; failures use stderr and a nonzero exit status.

Diagnostics #

tang doctor
tang doctor --json

The doctor checks the runtime, config directory, DID resolution, live authentication, repository selection/resolution, Spindle discovery/query access, and Git. It prints concrete remediation for warnings and failures and exits nonzero for failed checks.

Command map #

tang auth login|status|logout
tang ssh-key add|verify
tang config list|get|set|unset
tang context
tang doctor
tang issue create|list|view|edit|close|reopen|comment|delete
tang pr create|list|view|delete
tang label defs|create|list|add|remove
tang pipeline|ci list|view|logs|run|retry|cancel
tang run

Use tang <command> --help for exact flags.

Status #

  • Issues, comments and issue labels work against tangled.org's current org.tangled.* records in the repository's knot store, and show up on the web: list (state, labels, --state, --label), view (labels, comments), create, edit, close, reopen, comment, edit one's own comment, delete, and label defs/create/add/remove/list.
  • Legacy issues (sh.tangled.repo.issue) are still read, listed with --legacy, and your own can be deleted.
  • Not yet: deleting comments, replies, reactions, assignees, milestones; editing, closing, reopening, commenting on or merging pull requests kept as tickets, and new rounds for them.

Development #

Prerequisites are Node 22+ and Bun.

bun install --frozen-lockfile
bun run dev -- --help
bun run check            # typecheck + Biome + Vitest
bun run build
bun run build:binary

Architecture:

  • src/commands/ — Commander adapters and terminal contracts
  • src/lib/ — repository resolution, addressing, PDS access, API, and CI logic
  • src/lexicon/ — generated record-oriented Tangled types
  • lexicons/ — vendored Tangled schemas, including current CI XRPC schemas
  • tests/ — unit and command tests

Update vendored schemas with bun run update-lexicons. CI lexicons are intentionally not fed to the record code generator because they describe queries, procedures, and subscriptions; src/lib/ci-api.ts implements that transport boundary directly.