npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@x-cite/zgentic

v0.38.4

Published

zgentic terminal client and local tool bridge (#174). Bun is NOT required to run the published package: the CLI ships as a single pre-bundled Node script (built with Bun at publish time), so plain `node`/`npx` works. Bun is only needed to develop or build

Readme

tui/ — the zgentic terminal client and local tool bridge (#174)

A terminal client for the platform and the bridge that lets the platform call tools on this machine. Q8: TypeScript / Bun / Ink.

bun install
bun test
bun x tsc --noEmit
bun run src/cli.ts login      # sign in (browser redirect, or --no-launch-browser to paste)
bun run src/cli.ts attach     # attach this machine, open a session, render the TUI
bun run src/cli.ts undo       # revert the last local change from its git snapshot

Installing and running it

Today: from this repository (the only supported path)

The client is Bun-based (#174 Q8 — there is no pnpm/Node fallback) and src/cli.ts carries a #!/usr/bin/env bun shebang, so Bun is a hard requirement rather than a preference.

curl -fsSL https://bun.sh/install | bash     # if you do not have it
cd tui && bun install
bun run src/cli.ts                            # or: bun run start

To get a zgentic command on your PATH without publishing anything. bin points at the built bundle (see the publishing section), so build it first:

cd tui && bun run build                       # writes dist/cli.js — gitignored, never committed
bun link                                      # then `zgentic` works anywhere

Note for anyone scripting this: Bun's installer appends its PATH line to your shell rc, which a non-interactive shell never reads — make tui-check looks in $HOME/.bun/bin explicitly for exactly that reason. If a script cannot find bun, that is usually why rather than a missing install.

The client needs a server to talk to, and a deployment's API base is not its bare host — see "Connecting to a deployment" below before you set ZGENTIC_SERVER.

Publishing to npm as @x-cite/zgentic

Operator decisions (#308, 2026-08-22): scope @x-cite, name @x-cite/zgentic (so the package and the zgentic command agree), dist-tag latest, and the package version tracks the platform version (backend/app/__init__.py) rather than continuing its own 0.1.0 line. The number is deliberately not restated here — a version written into prose goes stale on every release; read it from package.json, and read what is actually on the registry with npm view @x-cite/zgentic version rather than from a status line in this file.

package.json carries that name, no private flag, a files allowlist, license, repository, engines, a bin pointing at the built bundle, a prepack that copies the licence in (see below) and a prepublishOnly that builds the bundle.

What a publish ships — four files, measured with npm pack on 2026-08-23:

LICENSE          1.8 kB
README.md       ~29 kB
dist/cli.js      2.1 MB      (~403 kB packed, 2.1 MB unpacked)
package.json    ~1.5 kB

src/, test/, scripts/ and tsconfig.json are excluded by the files allowlist. Before that allowlist existed, a publish would have shipped the test suite and the tsconfig. Re-measure after any dependency change — npm pack --dry-run prints the whole listing without writing a tarball.

LICENSE is copied in at pack time, not committed (#329). The repository has exactly one licence document, at the repo ROOT, and npm auto-includes a licence file only when it sits in the package directory — a files allowlist cannot reach ../LICENSE. So both manifests declared "SEE LICENSE IN LICENSE" while neither tarball contained it: for a closed-beta product on a public registry, the terms missing from the artefact is the one place that bites. prepackscripts/copy-license.mjs copies the root file in (resolving from the script's own location, since npm runs a lifecycle script with the package directory as cwd), the copy is gitignored, and test/packaging-license.test.ts asserts on npm pack --dry-run --json — the tarball, not the file on disk, because the file on disk is the same proxy that let this ship.

Bun is NOT required to run the published package. bun build --target node bundles every dependency in — including react-devtools-core, whose static import inside [email protected] was the original blocker (resolved by declaring it a real dependency, 33b657e3). The packed artefact runs under plain node with no node_modules at all:

tar xzf "$(npm pack --silent)"                # the name carries the version — never type it
cd package && ./dist/cli.js --help            # exit 0, with bun absent from PATH

One trap worth knowing, because it stays invisible until a Node-only user hits it. bun build copies the source shebang into the bundle, so dist/cli.js came out carrying #!/usr/bin/env bun even under --target node. node dist/cli.js --help passes anyway — the interpreter is named on the command line — but npx and node_modules/.bin execute the file through its shebang, so the published command would have died with env: 'bun': No such file or directory for precisely the users the bundle exists to serve. The build script therefore rewrites line 1 to #!/usr/bin/env node after bundling (portably, via node -e, not sed -i). Verify it with head -1 dist/cli.js, never with node dist/cli.js.

Publishing, when the operator decides to:

export NPM_TOKEN=…                            # from the environment, NEVER a tracked file
npm publish --access public --tag latest      # prepublishOnly builds dist/cli.js first

--access public because a scoped package is private by default. .gitleaks.toml gates committed secrets and make sec runs it, so a token written into a tracked file fails the security gate rather than reaching origin. A token pasted anywhere shared should be rotated regardless.

Open points, stated because a publish settles them irreversibly:

  • The grant is the closed-beta T&C, and it is settled. license is SEE LICENSE IN LICENSE, pointing at the root LICENSE that 0.37.1 added on the operator's instruction ("License add the T&C of the beta") — document version beta-2026-08-12, https://docs.zgentic.io/terms/. It is deliberately not an OSS licence, which cannot be withdrawn once published. backend/tests/unit/test_license_cites_current_terms.py guards the document's content; the packaging test above guards that it is in the tarball.
  • The declared dependencies are redundant at runtime. All four are bundled into dist/cli.js, so npm i pulls ~2 MB the published CLI never loads. Left alone deliberately — the react-devtools-core declaration is the recorded #308 resolution — but it is dead weight in the install.
  • #298make e2e-tui has no suite, so everything currently proven about this client is proven against fakes. That was an internal matter; claiming @latest is the moment it stops being one.
  • npm never allows republishing a version. If what goes out is wrong, the only way forward is the next patch — there is no amend.

Connecting to a deployment

A deployment's API base carries an /api suffix. The bare host serves the web app; the platform's HTTP API is mounted under /api. Measured against the closed beta on 2026-08-23:

| request | answer | |---|---| | GET https://beta.zgentic.io/auth/cli/start | 404, content-type: text/html — the web app's not-found page | | GET https://beta.zgentic.io/api/auth/cli/start | 405 — the route exists; POST is the verb | | GET https://beta.zgentic.io/api/healthz | 200 |

So the closed beta is:

zgentic login --server https://beta.zgentic.io/api

That base is also the built-in default (DEFAULT_SERVER, #327), so a plain zgentic login targets the beta — the flag above is what you use to point somewhere else, and what the rest of this section is about.

Verify a base before debugging anything else. One request, no credential:

curl -fsS https://<host>/api/healthz          # 200 + JSON => that is the API base

Local development is the exception, not the pattern: make dev-up publishes the backend itself on :8000, mounted at the root, so it takes no suffix — that is what --server and ZGENTIC_SERVER are for.

zgentic login --server http://localhost:8000

Omitting /api is the mistake worth recognising, because the symptom does not look like a wrong address (#328): the web app answers an HTML 404, and the client used to report only HTTP 404: <!DOCTYPE html>… — accurate and useless. It now names the cause, "that is a web page, not this API's answer, so … does not look like an API root", and prints the --server to try. The platform's own uniform 404 — its answer to "not yours", "off" and "no such thing" — is JSON and is deliberately left alone: telling a user who was correctly refused that their address is wrong would be worse than saying nothing. The discriminator is the response content-type (src/api/http.ts), and both halves are pinned by test/api-root-hint.test.ts.

You give the server once. zgentic login records which server the credential belongs to, and every command resolves one order — --server > ZGENTIC_SERVER > the stored session's server > the platform default — so zgentic attach afterwards needs no flag. A resolved server that disagrees with the stored credential's is refused rather than guessed: a token minted by one origin is never sent to another (#327).

Signing in

zgentic login is the gcloud shape (#316): one grant, two ways the authorization code comes home.

zgentic login                        # opens your browser; it redirects straight back here
zgentic login --no-launch-browser    # prints a URL; you paste the code it shows you
  • Loopback (the default). The client binds a random port on 127.0.0.1 before it asks the server for a URL, so the redirect target is a port that is already listening. Your browser opens, you approve, the browser is redirected to that port with the code, and the terminal finishes on its own. Nothing to copy.
  • Paste (--no-launch-browser). For SSH, a container, or any machine with no browser. The URL is printed, you authorize it wherever you like, and you paste the code back. The keystrokes are not echoed — the code is a credential.

A browser that cannot be opened is not a failure: the listener stays armed and the URL is printed, so opening it by hand still completes the sign-in.

The paste path is not the weaker path. Both mint the same PKCE-bound, one-time, short-lived authorization code and exchange it at the same endpoint with the same verifier. The only difference is how the code travels: a socket, or a person. There is deliberately no "headless clients skip PKCE" branch.

The flow itself lives in desktop/src/client-auth/ and is shared verbatim with the desktop app — PKCE, the state check, the loopback constraint, the one-shot listener and the timeout are written once and neither client can opt out of them. It sits under desktop/ rather than in packages/ because the desktop app publishes unbundled tsc output and could not import a workspace package at runtime, while this client publishes a Bun bundle and can reach in through a tsconfig paths alias (desktop/src/client-auth/README.md has the full reasoning). This directory owns only src/api/cli-auth.ts — the two HTTP calls — and the terminal UX in src/cli.ts.

The RFC 8628 device-code flow this replaced is deleted in full, client and server: no second sign-in mechanism survives.

Where this sits

The agent loop is hostedauthorize(), billing, permission-aware RAG, connectors, HITL and the audit trail all live server-side, and #174 opens by saying that moving the loop to the laptop would move all of those with it. So this client contributes two things: a terminal surface on the ordinary product API, and local tools the platform can call.

The bridge is consumed through the ordinary connector path — an attached TUI becomes an McpConnector row in the user's own tenant and every call rides mcp_tools_service, the manifest, the per-chat activation and the trust refusals, exactly as a third-party MCP server would (the DOGFOOD PRINCIPLE; backend/app/services/runtime_bridge_connector.py). Nothing here is a bespoke port.

Layout

| path | what it owns | |---|---| | src/bridge/protocol.ts | the wire contract, mirrored from runtime_bridge.py (and pinned by a test that reads that file) | | src/bridge/sse.ts | the SSE reader — fetch + header auth, never EventSource (a credential must not ride a URL) | | src/bridge/ledger.ts | the client half of at-most-once: running / done / foreign | | src/bridge/channel.ts | the state machine: sync-first, cursor-on-state-only, heartbeat, deregistered is final | | src/api/* | http (bearer + JSON + redaction), bridge (/runtimes), cli-auth (the sign-in transport, #316), chat (northbound) | | src/tools/boundary.ts | the hard boundaries: root containment (resolve, then check) and the credential denial | | src/tools/git.ts | Q7's undo: snapshot commits in refs/zgentic/undo/*, never git stash | | src/tools/{read,write,vcs,exec}.ts | the twelve tools | | src/tools/browser.ts | browser_use (#311): a CONNECTION to the browser instance already on this machine — no Playwright, no CDP, no new dependency | | src/tools/registry.ts | the tool table + the opencode capability mapping | | src/approvals.ts | how a server verdict is PRESENTED. No policy. | | src/ui/* | the Ink surface and copy.ts (two strings are asserted by tests) | | src/session.ts | registration (persistent) vs session (ephemeral, and it IS a chat) |

Decisions worth knowing before you edit

It is NOT in a pnpm workspace, and it does not join make lint

There is no root pnpm-workspace.yaml in this repo: web/, mobile/, packages/, excel/ and desktop/ each carry their own. This directory follows desktop/'s precedent — its own package manifest and lockfile, its own gate target — with one difference: it is Bun, so pnpm-shaped tooling (pnpm outdated, pnpm audit in make deps-check) does not reach it. Use make tui-check. Adding tui/ to make lint's existing legs would have made the whole gate depend on a Bun install that may not be present, and a silent skip in a gate is worse than a separate target.

What is shared with web/mobile, and how

packages/src is consumed live, through tsconfig paths — not as a dependency:

"paths": {
  "@platform/shared/schemas": ["../packages/src/schemas.ts"],
  "@platform/shared/chat-elements": ["../packages/src/chat-elements.ts"],
  "@zgentic/client-auth/*": ["../desktop/src/client-auth/*.ts"]
}

The third alias reaches into desktop/src/ on purpose — see "Signing in" above.

Bun's link: protocol means a globally linked package, so link:../packages does not resolve here, and file:../packages copies — which is excel/'s trap ("edits are not live; re-install after editing packages/") and the issue explicitly warns against it. A path alias reads the real source on every run, so it cannot go stale. packages/ is not modified by this directory.

Two modules are imported and the choice is deliberate: ChatElementUpdateSchema (the single source of validation truth) and applyElementUpdate (the fold that a client must apply — the backend ships pure deltas for confirmations, tool calls and plan steps, and #72 records mobile losing its confirmation card by reading state alone). packages/src/client.ts is not consumed: it carries no /runtimes and no CLI-grant methods, and its fetch typing collides with Bun's. The schemas are the part that must not drift; a fetch wrapper is not.

The tool set: twelve, and they are not opencode's twelve

runtime_bridge_tools.BRIDGE_TOOLS declares twelve and this client implements exactly those (a test reads the Python file and fails on any divergence). The mapping to opencode's gated capability list, since "match opencode" is the stated bar:

| opencode | here | |---|---| | read | read_file | | edit (edit/write/patch) | write_file, apply_patch, move_file | | glob | list_files (its glob argument) | | grep | search_code | | bash | run_command | | — | delete_file, project_tree, git_status, git_diff, git_log | | task, skill, question, webfetch, websearch | platform-side already — agents / skills / the one HITL primitive / the Perplexity path. Local re-implementations would be second paths to existing capabilities. | | external_directory | not a tool — a hard BOUNDARY. opencode makes it an ask; #174 makes it a refusal ("a hard boundary, not a prompt"). | | doom_loop | not a client concern — the platform already owns it. TurnPolicy.max_identical_tool_failures (default 3, turn.max_identical_tool_failures) is the server-side doom-loop guard: a repeated identical FAILURE class halts the loop with a stated reason, a different failure class resets the streak, and a repeated successful call never trips it. Not a gap; a client-side copy would be a second policy layer with no audit trail. See the note below on which paths enforce it. | | lsp | out of scope — Q6 answered NO, deferred to 0.39 as #236. |

The browser capability (#311) — twelve tools plus one platform capability

browser_use is served from this machine but it is not a thirteenth bridge tool, and the distinction is the whole design. A name in BRIDGE_TOOLS is served through this bridge's connector row and therefore reaches the model NAMESPACED (tui_ab12cd34__read_file). The desktop app has offered a platform tool called plain browser_use since 0.32 — so declaring it here would give one capability two spellings for the model to learn, which is the two-mechanisms-for-one-concept defect #309 deleted in this same release. It rides the same SSE channel as a platform capability instead (runtime_bridge_tools.PLATFORM_BRIDGE_TOOLS), so the platform tool, its schema and its result rendering are shared with the desktop path and cannot drift: test/contract.test.ts reads the name out of browser_use_service.py rather than transcribing it.

There is no Playwright here — the operator's decision (2026-08-22): "For the TUI consider that there's a desktop / browser instance to connect to, so there's no playwright necessary." This client connects to a browser instance that is already running and forwards one task. The dependency list is unchanged and so is the packed size #308 measured.

Setting the variable is the consent. Nothing is probed, guessed or auto-started:

export ZGENTIC_BSK_URL=http://127.0.0.1:49152     # a bare `bsk daemon --port 49152`
export ZGENTIC_BSK_TOKEN=…                        # ALSO set -> it is a desktop shell's bridge

With the token unset it speaks the daemon's own contract (GET /health, POST /run); with it set it speaks the shell's (GET /bsk/capability, POST /bsk/run, bearer) — both are contracts this repository already implements in desktop/src/main/bsk-bridge.ts, neither is invented. The URL must be loopback, for the reason the backend pins its own capability endpoint to loopback: a remote URL would make this client an egress proxy carrying model-authored task text to an arbitrary host. A refused URL is reported, never silently dropped.

Three consequences worth knowing before you wonder why the model cannot see it:

  • No variable, no capability. The claim is sent per ATTACH ({capabilities: [...]}) and derived from the resolved target, so attaching a terminal client does not silently expose a browser — and a reconnect after the browser went away drops the claim. It is deliberately not a registration column: a stored row claiming a browser would be a lie every time the daemon is down.
  • It obeys the same per-chat activation as the other twelve. The platform offers it only in a chat where THIS machine's connector is switched on (a TUI session switches it on when it opens). A browser that ignored activation would be strictly broader than the file tools of the same laptop — you would turn a machine on for one chat and find its browser drivable from every other one, including from your phone.
  • The sync frame therefore lists thirteen names when a browser is attached: twelve connector tools plus the capability. The status bar's count is still "what this machine advertised", so 13 tools there and twelve rows in the #142 inventory is correct, not a mismatch.

An absent instance is a STATED absence. The interesting case is the user who set the variable and then quit the browser. This client answers noInstance: true, which the backend renders as its own sentence naming the missing thing, the fix, and the fact that retrying alone will not help (browser_use_service.NO_BROWSER_INSTANCE) — never a bare transport error, which is #246's defect class: a refusal with no stated reason becomes an invented outage the model then offers to retry.

What this client does NOT do: start, stop, supervise or restart the daemon. That lifecycle (detect the binary, guided install, health-check, restart with backoff) is the desktop app's and a second copy of it here would be exactly the duplication the paragraph above warns about.

Q7 — undo, and what happens outside a git repository

Mutating tools REFUSE outside a git work tree. write_file, apply_patch, move_file, delete_file and run_command all call git.requireWorkTree() before they resolve a path, so the refusal names the real reason ("there is no undo here") rather than a downstream symptom. The read tier is unaffected and useful on its own. Silently proceeding is the one option that is not defensible; a shadow repo would hide state somewhere the user never looks.

Inside a work tree, every mutation is preceded by a snapshot:

  • a throwaway index (GIT_INDEX_FILE → a temp file) plus commit-tree, so the user's staging area is untouched and no branch or HEAD moves;
  • the commit is reachable only from refs/zgentic/undo/<epoch-ms>, a namespace no ordinary git command lists;
  • git stash is never used. It is repo-global; on 2026-08-08 in this repo a concurrent pop destroyed another worktree (7 files, +510/-12), and on a user's machine the same hazard exists against their own stashes;
  • the touched paths are added with git add -f on top of git add -A, so a gitignored target is captured too — otherwise a write to an ignored file would be a mutation with no undo, which is the hole Q7 exists to close;
  • if the snapshot cannot be written, the mutation does not happen.

zgentic undo restores the snapshot's paths --worktree only (never --staged) and deletes files the snapshot did not contain, so a created file is removed rather than left behind.

At-most-once is enforced on both sides

The server answers a known call_id with duplicate and re-resolves nothing. That protects its state. src/bridge/ledger.ts is the local half:

  • a redelivered tool_call for a done id re-POSTs the recorded result and runs nothing;
  • an id the sync frame lists as completed is marked foreign and never executed — that is the crash-restart case a "have I seen this?" check gets wrong;
  • an id the server lists as pending that this process has no memory of is answered with an error, not re-run. Zero executions is the correct number.

Gating is the server's, and this client presents and obeys

#142 owns the policy (auto/hitl/off, pattern matching, last-match-wins, per-agent overrides that may only tighten) and runtime_bridge_tools.classify is the floor beneath it. There is no rule table, no allowlist and no configuration here. The only asymmetry is safe by construction: a verdict annotation on a dispatch can move this client from "execute" to "do not execute" and never the reverse.

Two consequences worth knowing before you attach and wonder why nothing happens:

  • A dispatch carries no verdict today. runtime_bridge.dispatch builds the frame as {call_id, tool, arguments}, and a gated call is refused server-side in runtime_bridge_tools.admit before it could become a dispatch at all. So src/approvals.ts is the client's half of a contract whose other half is not written yet: an approval reaches a TUI user through the assistant's own message (mcp_tool_hitl's ConsentOutcome.reason tells the model to say one is waiting) and through the Tasks inbox — not through the card in this client.
  • "Twelve advertised" is not "twelve callable". The bridge's connector row is created fresh on first attach, so #142's inventory records each of the twelve at off (mcp_tool_inventory.reconcile: policy=LEVEL_AUTO if grandfathering else LEVEL_OFF, and a row created after migration 0066 never grandfathers) and mcp_tools_service._withheld_from_manifest keeps an always-off tool out of the offered manifest entirely. Until a tenant Owner sets the per-tool policy, the model is offered none of them — including the read tier, whose classify verdict is auto. That is deny-by-default working as designed; it is also why the status bar's tool count (what this machine ADVERTISED, straight off the sync frame) can read 12 tools while the model can call zero.

An approval does not resume the turn, and the UI says so

#142 OQ-1 settled that a CONNECTOR-tool approval blocks — after a human approves, nothing re-drives. So the approval card carries, verbatim and asserted by test/approvals.test.ts:

Approving does NOT resume this turn. Once a reviewer approves, ask again in the chat to continue — nothing is running or waiting in the background.

A spinner there would be a lie by omission.

Two precisions the server's own code insists on. It is per gate, not platform-wide: mcp_tool_hitl.require_human_consent's comment says engine-run gates DO resume and that is exactly why the note belongs on this gate — so the sentence above is only ever true for a connector/bridge tool, which is the only gate a bridge dispatch can reach. And the server already authors that sentence, as after_approval on the approval payload, deliberately "rather than added to each client … so web, admin and mobile all render it with no new field and no chance of the three disagreeing". src/ui/copy.ts is therefore a fourth hand-kept copy of a server-owned string: correct today, but the right end state is rendering the server's field.

Security properties this client must not undermine

  • Heartbeat: 15s, 45s grace (three missed beats). stop() clears the timer and POSTs /detach; ctrl-c, SIGINT and SIGTERM all route through it. A dead client whose tools stay registered is what the transport design exists to prevent.
  • deregistered is final — no reconnect, no further dispatch, and the UI states why.
  • Symlinks are resolved before the containment check, including the deepest existing ancestor of a not-yet-created write target.
  • .env and credential shapes are refused even for read, and search_code skips them too (a grep that prints a matching line out of .env has read .env).
  • The session credential lives 0600 in a 0700 state dir, rides an Authorization header (never a URL, which is why the SSE reader is fetch-based), is redacted out of every error and log line, and is stripped from the environment of any child process.
  • Tool output is data: control characters are stripped before rendering, and nothing interprets a result.

Known gaps

  • doom_loop is not a gap — see the tool table. The platform enforces it via TurnPolicy.max_identical_tool_failures. One precise caveat, from policy.py's own comment: that dimension is "enforced today by the one sandbox-codegen retry engine (task_codegen.run_codegen_loop)", so a bridge tool failing identically is bounded by the turn's max_tool_calls (16) and the 60 s dispatch bound rather than by the identical-failure streak. Extending the existing guard to the bridge dispatch path is a small server-side follow-up — the policy dimension, the config key and the failure-classing helpers all already exist. It is not something the client should grow.
  • No LSP (#236, 0.39).
  • run_command's snapshot records no paths (a command is not declarative), so zgentic undo cannot list what it changed; the snapshot ref is returned in the tool result and git restore --source=<ref> --worktree -- <path> reverts a specific file.
  • Bun's single-file --compile packaging (Q5's curl | sh story) is not wired up yet.