The two variable faces under public/fonts are Zen and Zen Mono, but three
places still said Geist. The OG card's source SVG asked for a family the
site does not serve, so rsvg resolved it out of whatever the build host had
installed. The repo notes claimed Geist was served from /fonts/ and pointed
at an OFL.txt that is not there — the notice sits at LICENSE-Zen.txt. The
stylesheet comment narrated a dead CDN path instead of the rule that keeps
the faces on this origin.
An earlier pass renamed the families in index.css to Zen and left the files
alone, so @font-face declared 'Zen' over Geist-Variable.woff2 — whose name table
reads Geist. That is the worst shape this can take: every grep says Zen, the
page renders Geist, and nothing downstream can tell. It is the third instance
found in this estate, after @hanzo/design shipping Geist as Zen-Variable.woff2
and zoo's foundation declaring Zen over inter-var-latin.woff2.
The two faces are @hanzo/font 1.9.2's, checked by name table rather than by
filename: family Zen and Zen Mono. The licence beside them was Geist's alone —
"Copyright (c) 2023 Vercel" and no Hanzo line — which is the wrong notice for
these bytes; @hanzo/font's LICENSE.txt carries both holders, as OFL §2 asks of
the copy that ships.
A docs-site template repeated across these repos named three different faces in
one place: a binding called `inter` or `geist`, constructed from next/font/google
so it actually held Inter, publishing a custom property called
`--font-geist-sans` that @hanzo/font does not set. Whichever of the three a
reader trusted, two of them were wrong.
They take Zen and ZenMono from @hanzo/font — bindings named `sans` and `mono`
for the role they fill, setting --font-zen-sans and --font-zen-mono, which is
what the package publishes.
Actions are disabled on this repository, so these files cannot run: they are
build definitions no forge executes. Builds go through the native path --
POST /v1/runner, which schedules a BuildKit job -- and CI lives under
.hanzo/workflows.
Actions are disabled on this repository, so these files have not run in a long
time and cannot run: they are build definitions no forge executes. Builds go
through the native path -- POST /v1/runner, which schedules a BuildKit job --
and CI lives under .hanzo/workflows. A dead workflow that still looks like a
pipeline is worse than none, because it reads as coverage nobody has.
Yesterday's note said pushes never trigger a run; I replaced it with "a push to
main publishes" on the strength of run 30823263229. Both are wrong. Two pushes
today, same repo, same workflow, same pusher as the one that worked, started no
run at all; workflow_dispatch started one both times. The table is in LLM.md.
What matters is the rule that survives either explanation: a merge is not
evidence of a deploy on this host. Check for a run, dispatch if there is none,
and let the job's live re-fetch be the thing that says it landed.
Co-authored-by: Hanzo Dev <[email protected]>
Routing `/` through a Worker changed one thing nobody asked for: this zone has
Web Analytics auto-injection on, and Cloudflare appends a
static.cloudflareinsights.com <script> to HTML a Worker returns. It leaves plain
assets alone — /page.html comes back byte-identical to the build — so the page
carried no beacon while it was a static file and picked one up the moment `/`
became a Worker response. hanzo.ai and hanzo.bot carry none. This host does not
ship third-party script it did not write; `cache-control: no-transform` is the
standard way to say so.
The deploy gate stops comparing `/` byte-for-byte against the built document,
because an edge injection the Worker requests and does not control must not be
able to fail a deploy. It compares the document at /page.html exactly, still
compares the installer at `/` exactly, and asserts about `/` what actually
matters: a browser gets 15 bytes of <!DOCTYPE html> and a page that names this
build's content-hashed stylesheet. That is the check that catches staleness,
which is the failure this host has actually had.
Co-authored-by: Hanzo Dev <[email protected]>
Dockerfile and .hanzo/workflows/deploy.yml drafted a move to
ghcr.io/hanzoai/static behind hanzoai/ingress. They had never produced an image
— the forge job names a runner pool this org does not register, so it queued to
the 24h timeout in silence — and with the polyglot gone they cannot produce a
working one: hanzoai/static answers with http.ServeContent and has no way to
choose a representation from Accept, so `/` would be the page or the installer,
never both. Its asserts (`head -1 == #!/bin/sh` on dist/index.html) now fail by
construction. A Dockerfile that can only build a broken image is a trap; the
requirement it is blocked on is written down in LLM.md instead.
README: the deploy command named hanzo-apps/hanzo.sh, which is the wrong org —
the repo is hanzoai/hanzo.sh — and claimed pushes to this repo create no runs,
which run 30823263229 (push of 307e4fb, 2026-08-03) disproves. Both fixed, and
the local recipe now runs the real Worker over the real assets instead of
python3 -m http.server, which cannot answer `/` at all under the new layout.
LLM.md carries the rest: the routing rule, why the polyglot could not be
repaired, where the mark and the typeface come from, and the one defect this
repo cannot fix from inside — Cloudflare Managed Content synthesises a
/robots.txt for this zone that Disallow: / for ClaudeBot, GPTBot,
Google-Extended and CCBot, on the one host that publishes /llms.txt for exactly
those readers. Shipping public/robots.txt does not win; the zone toggle does.
hanzo.ai and hanzo.app do not have that block, hanzo.bot does.
Co-authored-by: Hanzo Dev <[email protected]>
dist/index.html was a polyglot — `#!/bin/sh`, a `<<\EOF` heredoc swallowing the
whole document, the installer after the closing EOF. Both halves ran. The
browser half was never honest, and could not be made honest.
Anything before <!DOCTYPE html> that is not whitespace or a comment puts the
document in quirks mode. Chromium, at 390 and 1280: compatMode BackCompat;
document.head.children.length === 1, with all fifteen head elements — title,
icons, viewport, description, every og: and twitter: tag — parsed into <body>
where they do nothing; and "#!/bin/sh <<\EOF" as the first text node of the
page, first string in body.innerText, pushing <main> down 24px. Proven by
re-serving the identical live bytes with the 17-byte prefix stripped: BackCompat
-> CSS1Compat, head.children 1 -> 15, at both widths.
No arrangement of those bytes avoids it. Only whitespace and comments may
precede a DOCTYPE; an HTML comment opens with `<!`, which a shell reads as a
redirect from a file named `!--`, so a POSIX script cannot start there at all.
The trick was unfixable, not unlucky.
So the two resources become two files and `/` chooses between them by Accept,
which is what HTTP has for this. worker.js is the whole rule: text/html gets
dist/page.html, anything else — curl, wget, the /cli /mcp /dev shims that
re-exec `curl -fsSL https://hanzo.sh` — gets dist/install.sh. `/` carries
vary: accept and cache-control: no-store, because Cloudflare honours Vary only
on Accept-Encoding and a shared cache holding one representation for the other
kind of client breaks either the page or the install command.
scripts/postbuild.js replaces the polyglot rewrite: it moves the document off
index.html (Static Assets serve an exact match BEFORE the Worker runs, so a file
at that name would hand curl the HTML) and writes /install from install.sh, so
the alias is one file in git instead of 12,746 duplicated bytes.
wrangler.toml: routes moves ABOVE [assets]. TOML puts every key after a table
header inside that table, so it was parsing as assets.routes and wrangler
dropped it with a warning — the custom domain survived only because it was
attached out of band. html_handling and not_found_handling are "none": exact
match or the Worker, nothing silently rewritten, a mistyped path still 404s.
The deploy gate now checks both representations against the live URL, plus a
200 for every path the head declares — the check that would have caught
og-image.svg. Verified against the real runtime before pushing: wrangler dev
--local, curl gets install.sh byte-for-byte, a browser gets page.html with
<!DOCTYPE html> at byte 0, `sh -n -c "$(curl -fsSL /)"` parses under dash and
bash, and /index.html /nonexistent-xyz /some/deep/path still 404.
Co-authored-by: Hanzo Dev <[email protected]>
Three things the page declared and did not have.
Icons. /favicon.ico and /apple-touch-icon.png were 404 — hanzo.ai serves both —
and /favicon.png was 8-bit RGB with no alpha, so the rounded corners of the tile
were filled pure white and read as four white nubs on a dark tab strip. The set
is now the estate's, byte-identical to the files hanzo.ai serves and to
hanzoai/brand assets/logo/favicon.svg: .ico (16/32/48), the theme-aware .svg,
16x16 and 32x32 PNGs, and a 180x180 apple-touch-icon. Nothing redrawn.
public/hanzo-logo.svg goes with them: a second copy of the mark published at a
public URL and referenced by nothing.
The card. og:image pointed at /og-image.svg, which is in no commit of this repo
— every social card resolved to a 404, and it was relative, which not every
scraper follows. It is now an absolute /og-image.png, 1200x630, built by
rsvg-convert from scripts/og-image.svg: the canonical five-path mark and copy
that is already on the page, nothing invented, plus the width/height/alt that
let a scraper lay it out without fetching.
The typeface. src/index.css @imported Geist from
cdn.jsdelivr.net/npm/[email protected]/dist/fonts/{geist-sans,geist-mono}/style.css,
a path that package has not had since 1.4. Both returned 404, Chromium reported
ERR_BLOCKED_BY_ORB, document.fonts.size was 0 — no @font-face rule ever
existed, and every visitor read this page in their system fallback. Measured
with the local Geist hidden from fontconfig: rendered width of the same string
was 1191px against a 1193px system fallback (identical), and is 1328px now.
Nothing surfaced it because a cross-origin fetch that fails is not a console
error. The two variable files ship from /fonts (OFL, public/fonts/OFL.txt).
Also: the footer said (c) 2016 and the installer served from the same URL said
2024-2026 hanzo ai inc. Two founding years and two spellings of the company on
one host. Both now say what hanzo.ai says — the current year, "Hanzo AI, Inc." —
and the installer header carries no year it cannot keep current. The two depth
slivers of the inline mark are 0.867 opacity, which is the canonical #DDDDDD
over black; they were 0.7.
Co-authored-by: Hanzo Dev <[email protected]>
vite.config.ts imported componentTagger from the scaffold vendor's plugin and package.json
carried the dependency. It only ran in dev mode, so it never reached a
production byte — but it is the vendor's fingerprint in the build of a Hanzo
host, and it pulls 837 lines of lockfile for a plugin nobody here uses. The
the scaffold generator's CDN script tag it came with is already gone from index.html.
Co-authored-by: Hanzo Dev <[email protected]>
hanzo.ai/install.sh execs `curl -fsSL https://hanzo.sh/install | sh`. This site
serves install.sh and not install, so that fetch 404s; -f writes nothing and
exits 22; and sh reading an empty script exits 0. The documented one-liner —
curl -fsSL hanzo.ai/install.sh | sh — reports success and installs nothing.
The caller is already corrected in hanzo.ai, but that deploy has not shipped, so
the break is live right now. Serving the path it asks for repairs it from this
side immediately, with no dependency on the other pipeline.
Extensionless is this site's existing convention rather than a new one: cli, dev,
agents, bot and full are all served that way and hanzo.sh/cli answers 200 today.
The bytes are install.sh's, verified identical to what production serves, and the
shebang is #!/bin/sh — which matters, because the caller pipes this into sh and
not bash. The two files must stay identical: install.sh is the name the docs and
release notes use, install is the name the deployed caller asks for.
Co-authored-by: Hanzo Dev <[email protected]>
The deploy job asked for `ubuntu-latest`. This file lives in .hanzo/workflows,
which GitHub never reads -- git.hanzo.ai is the only thing that can run it, and
the fleet advertises no generic ubuntu-* label. That is deliberate and recorded
in the runner ConfigMap: ~1400 mirrored upstream forks all ask for ubuntu-latest,
and the forge errors rather than skipping a job it cannot place, so one bad job
was retried ~520 times across 10 runners.
An unmatched label is not a failure here, it is silence -- the job queues until
the 24h timeout. So this deploy has never produced an image, and nothing ever
said so. Same class as the playground regression, found by checking every
.hanzo/workflows file against the labels the runner ConfigMap actually declares
rather than against the ones I assumed.
hanzo-build-linux-amd64 is registered (verified against cm/git-runner-config).
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Search results and link previews for hanzo.sh read 'CLI, MCP, Agents, Dev tools —
in Python, Rust, or JavaScript', which is the model this change set removed: there
is one implementation of each tool and one way to install it. The description now
says what the command does.
Dropped the the scaffold generator's CDN script tag left over from the scaffold. It is a
third-party module on the page whose whole job is to hand people a shell command,
the CSP already blocked it from loading, and nothing here uses it — so it was
pure supply-chain surface on the most security-sensitive page we serve.
With this, 'python' no longer appears anywhere in the bytes hanzo.sh returns.
Co-authored-by: Hanzo Dev <[email protected]>
The previous commit's README claimed the workflow publishes on every push to
main. It does not, on this repo: four consecutive pushes created zero runs while
pushed_at advanced each time, and workflow_dispatch on the same file works every
time. Cause not established — Actions enabled, workflow active, file on main
with branches: [main], no rulesets, public repo, and the canonical remote instead
of the redirect made no difference.
`on: push` stays, because it is correct and costs nothing the day this clears.
What changes is the claim: dispatch is the path that works, a merge is not a
deploy until then, and the check is one curl. Shipping a doc that says deploys
are automatic when they are not is how the next person merges a fix and walks
away — which is the failure this whole change set exists to end.
Evidence and the ruled-out causes are in LLM.md so nobody re-derives them.
Co-authored-by: Hanzo Dev <[email protected]>
Its comment told the next person to run `pnpm build && npx wrangler@3 deploy`,
which is exactly how the live host ended up months behind main. Deploying is CI
now, and CI is the thing that checks the live bytes afterwards. The local command
is still there for a break-glass, with the consequence stated.
Co-authored-by: Hanzo Dev <[email protected]>
It documented a generated starter — an /improve project URL, 'Share -> Publish'
to deploy, and `npm i && npm run dev` for a repo that builds with pnpm and a
frozen lockfile. None of it described hanzo.sh, and the deploy section pointed
somewhere that has never deployed this host.
It now says what the one command installs, that the served file is a polyglot,
that downloading belongs to hanzoai/cli/install.sh and should stay there, and how
to exercise an installer change under both published shells before deploying.
Co-authored-by: Hanzo Dev <[email protected]>
The polyglot is one file that is both the installer and the landing page. The
shell half stopped installing uv; the HTML half still told everyone it does —
"Requires bash. Installs uv if missing.", a four-up grid offering `pip install
hanzo`, `uvx hanzo`, `cargo install hanzo-dev` and `npm i -g @hanzoai/cli`, and
an "Installation Bundles" section selecting python/rust/full. A fix that only
lands in the bytes a machine executes, and not in the bytes a person reads, has
fixed half the problem and left the documentation to contradict the product.
The page now says what the installer does. One command; the alternatives grid is
gone, because offering four package managers for a program with exactly one
implementation is the language-choice model this change exists to remove.
The per-language package grid (Python / Rust / JavaScript, listing hanzo-agents,
hanzo-dev, @hanzoai/mcp and others) is replaced by what is actually installed:
`hanzo` and `hanzo-mcp`, each with its second name and a link to the repo that
builds it. Below it, in the open, the four tools that are NOT installed and why —
the same list the installer prints, so the page and the terminal agree.
Corrected alongside: "After install" advertised `hanzo dev`, which needs the dev
binary that is not public, so the first suggested command would have failed; it
now shows `hanzo code`. The subtitle promised agents and SDKs that this installer
does not install.
Lint was already failing on 5 pre-existing errors in src/components/ui and
tailwind.config.ts; unchanged by this, and not fixed here.
Co-authored-by: Hanzo Dev <[email protected]>
hanzo.sh is a Cloudflare assets Worker that was published by hand. That is the
reason the installer fix in e300d78 never reached anyone: main was correct for
weeks while `curl hanzo.sh | bash` kept handing out a copy that predated it. The
repo and the bytes users get were two different states, and nothing in CI had an
opinion about the difference.
So the deploy runs on every push to main, and — the part that matters — it does
not pass because wrangler exited 0. It re-fetches https://hanzo.sh and compares
its md5 to the file just built, retrying while the edge revalidates, and fails
the job if they differ. A green deploy over a stale site is the failure being
fixed here; a deploy step that cannot detect it would reproduce it.
It also re-asserts the polyglot against the LIVE response, not just the artifact:
parses under dash, and carries no astral.sh or `uv tool install`. Cloudflare
serves this host with cf-cache-status: HIT, so the purge is attempted first, but
best-effort — the token may not carry Cache Purge, and the live-bytes check is
what actually decides.
Runs on GitHub's runner because this org registers no self-hosted runner and the
only copy of the Cloudflare credential is a GitHub org secret. It builds no
container image; the image path (Dockerfile -> hanzoai/static -> universe CR) is
a separate unfinished migration, documented in LLM.md.
Co-authored-by: Hanzo Dev <[email protected]>
`curl hanzo.sh | bash` installed uv from astral.sh and then `uv tool install`ed
hanzo, hanzo-mcp, hanzo-agents and hanzo-node. Every one of those is a native
Rust binary that we already publish, cross-compiled for five targets with a
sha256 beside each asset. So the installer was fetching a Python toolchain in
order to deliver programs that are not Python, and resolving `hanzo` from PyPI —
a different program that happens to share the name.
It now downloads one prebuilt binary per tool, verifies its sha256, and puts it
on PATH. That is the whole design, and it is smaller than what it replaces:
-349 lines net, and public/install.sh drops from 669 lines to 314.
The machinery for the right thing was already in the file and unreachable:
install_release() was defined and had ZERO call sites, while every bundle routed
through install_tool() to uv. Wiring it up was the tempting minimal fix and it is
the wrong one — install_release() verified nothing, so it would have traded a
wrong binary for an unverified one. It is deleted. Downloading lives in
hanzoai/cli/install.sh, which already verifies, and which this drives once per
tool via HANZO_INSTALL_{REPO,BIN,ALIAS}. One implementation, two callers.
What ships, verified by installing each from a clean PATH and empty HOME:
hanzo hanzoai/cli v1.9.18 + hanzo-node, one build, symlinked
mcp hanzoai/mcp rust-v1.1.15 + hanzo-mcp, one build, symlinked
What does not, and is now NAMED in the output instead of quietly swapped for
something else: dev and node (private repos — anonymous fetch is a 404 — and no
per-asset checksums), desktop (private, and linux-amd64 only), bot (not a native
binary). `dev` is the costly one: it is what `hanzo code` runs by default, so a
public install yields a CLI that cannot run its own headline command's default
backend. Better said at install time than discovered at first use.
POSIX sh now, which fixes a second, independent break. The page published
`| bash` and llms.txt published `| sh` while the script was bash-only, so the
`sh` half died on Debian at `set: Illegal option -o pipefail` before downloading
anything — and every agent reading llms.txt copies the `sh` form. Rather than
pick a winner and leave the other broken, both work; the polyglot already
declares #!/bin/sh, so this makes that shebang honest. The Dockerfile now asserts
under dash as well as bash, because a bash-only assert is precisely what let this
ship.
Also corrected: the installer told users to run `hanzo login`, which is not a
command — it is `hanzo auth login`, and `hanzo login` is swallowed as an AI task.
llms.txt advertised `hanzo deploy` and `hanzo node start --chain ethereum`;
neither exists. Its examples are now commands present in the shipped binary.
The bundle vocabulary is gone: naming nothing installs everything installable,
naming a tool installs that tool. /python and /rust selected a language for
programs that have exactly one implementation each, and both were already broken
(they piped a 404 into bash). /agents is `hanzo agent` in the CLI. The remaining
shims are routing only, so the installer stays the single place that knows what
exists. scripts/polyglot.sh, an unwired duplicate of the .js builder, is deleted.
Co-authored-by: Hanzo Dev <[email protected]>
`curl hanzo.sh | sh` resolved the name `hanzo` from PyPI. The project published
there is a different program that happens to share the name, so this installed
the wrong `hanzo` — into ~/.local/bin, which outranks /usr/local/bin on a normal
PATH, so it also won against anything correct already on the box.
Install the real CLI instead, and do not re-implement how: hanzoai/cli ships the
installer that knows its own per-platform asset naming, verifies the sha256
before unpacking, and places BOTH names — `hanzo` and the `hanzo-node` delegate
cloud's control binary execs — from that one verified build. This calls it. One
download path, one asset rule, one place to change when either moves.
Fetch that installer from the API rather than raw.githubusercontent, which is
CDN-cached and handed back a copy several minutes old during testing: a stale
script is the same class of bug this change exists to remove, so it is not worth
risking for a shorter URL. Raw stays as a fallback if the API is unreachable.
Then verify rather than assume — if the delegate name is not there afterwards
the install FAILS loudly, because a missing one silently falls through to
whatever older `hanzo-node` the box already had, which is the invisible failure
in the first place.
The quick-start also advertised two commands that no longer exist.
Co-authored-by: Hanzo Dev <[email protected]>
TypeScript 7 is the native Go compiler and removes `baseUrl` and
`moduleResolution: node|node10`. Both appear here, so `tsc` from TS7
refuses the config outright (TS5102 / TS5108) and cannot typecheck.
`paths` targets resolve relative to `baseUrl` when it is set and relative
to the tsconfig file otherwise. Every `baseUrl` folded here already
pointed at the config's own directory, so dropping it moves nothing and
the targets are left byte-identical. Where a baseUrl pointed elsewhere,
each affected target was rewritten as join(baseUrl, target).
`moduleResolution` was chosen from the declared `module`: commonjs ->
node16, esnext/preserve -> bundler. Configs whose `module` is unset or
exotic were left alone rather than guessed at.
The result is accepted by BOTH toolchains, so nothing has to upgrade
TypeScript in lockstep. Verified on hanzo/chat packages/api: tsc 5.9
779 -> 778 errors (no regression), and tsc 7.0.2 now runs the project
in 2s where it previously refused the config.
Co-authored-by: Hanzo Dev <[email protected]>
Superseded: the Hanzo GitHub App pushes a webhook, so the forge tracks GitHub
without a per-repo workflow. This file called git.hanzo.ai/api/v1/.../mirror-sync
— a Gitea API for a system we no longer drive — and would sit inert in every repo.
One mechanism, in one place, instead of ~350 copies of a cron.
.github/workflows now holds sync.yml alone, carrying refs to git.hanzo.ai; every
build runs natively there from .hanzo/workflows.
.hanzo/workflows/deploy.yml publishes ghcr.io/hanzoai/hanzo-sh:<sha> and does not
deploy — the operator CR in hanzoai/universe names which tag is live.
The GitHub Pages workflow it replaces never served this host: Pages reports
status null (no build ever succeeded) and hanzo.sh resolves to Cloudflare, where
an assets Worker answers. Its two jobs asked for hanzo-build-linux-amd64 and
hanzo-deploy-linux-amd64, retired labels nothing answers. public/CNAME goes with
it: that file is a Pages control file and means nothing to hanzoai/static.
The Dockerfile ends FROM ghcr.io/hanzoai/static:v0.5.1 with dist at /public, and
asserts the polyglot before the image exists: dist/index.html must keep its
`#!/bin/sh` first line, its `<!DOCTYPE html>`, and parse under bash -n, because
`curl hanzo.sh | bash` runs the same bytes the browser renders. Measured: static
returns the live 25426-byte polyglot byte-identical as text/html, and the served
response still runs the installer under bash.
wrangler.toml stays until the image is promoted; removing it first would strand
the live host.
Co-authored-by: Hanzo Dev <[email protected]>
git.hanzo.ai mirrors this repo by PULL on a ~10-minute interval, and arcd runs
CI/CD there — so every push waited out that interval before anything built.
This asks Gitea to pull HEAD immediately.
Latency only: the repo already mirrors via the App webhook, so a missing
HANZO_GIT_TOKEN or a failed curl is non-fatal and never fails the push.
Idempotent (mirror-sync just pulls HEAD) and concurrency-coalesced.
A binary earlier on PATH permanently masks the one the installer writes, and
the failure is SILENT in the worst way: the install reports success, the user
runs the old copy forever, and re-running the installer never fixes it because
it writes to a DIFFERENT directory.
Found live: ~/.local/bin/hanzo held a retired Go build (it reports version
'dev') while the canonical Rust CLI installs to ~/.cargo/bin. uv also installs
tools into ~/.local/bin, so a Python `hanzo` shadows the same way. Someone in
that state debugs a CLI they are not running.
check_shadowing compares `command -v <bin>` against where we actually put it,
for hanzo/mcp/dev/hanzod, and prints the exact mv to fix it. It does NOT delete
anything — the user's files are theirs — but staying quiet is what makes this
cost hours.
Verified all three cases with a harness against the real function:
stale first on PATH -> warns, naming both paths
canonical first -> silent (no false positive)
canonical not present-> silent (nothing to shadow)
(My first harness run passed for the wrong reason — I had created `cargo/bin`
instead of `.cargo/bin`, so the check correctly found nothing. Fixed the
harness, then it caught the case.)
Load Geist Sans/Mono before @tailwind (valid @import ordering) and map Tailwind
fontFamily.sans -> Geist, mono -> Geist Mono, with a base rule forcing html/code
onto them. Was falling back to the Tailwind system stack (no Geist declared).
- Change `hanzo cloud login` → `hanzo login`
- Fix dev install from `cargo install` to `npm i -g @hanzo/dev`
- Add AI CLI tool detection: aider, vibe, windsurf, amazon-q
- Remove hanzod from CLI tools list (it's a daemon, not CLI)
- Update quick start section with current commands
- Add environment section showing detected API keys
- List installed AI CLI tools with versions
- Remove aider suggestion, add hanzo-dev as recommended
- Only suggest installing tools that are missing
- Detect 15+ common API keys (Anthropic, OpenAI, Google, etc.)