diff --git a/Makefile b/Makefile index 2d57a63..74a35ae 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,6 @@ SCRIPTS = preflight.sh provision.sh setup-user.sh sync-code.sh destroy.sh \ - files/remote-setup.sh files/claude-notify.tmpl files/codex-notify.tmpl + files/remote-setup.sh files/claude-notify.tmpl files/codex-notify.tmpl \ + files/kimi-notify.tmpl .PHONY: check residue preflight provision setup sync all destroy diff --git a/README.md b/README.md index 125d0bd..95861c6 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ manage, wired for Claude Code — and rebuild the whole thing from this repo in - **Tailnet-only.** Zero public TCP ports. Access is Tailscale SSH: your tailnet identity is the credential. - **Hardened and self-maintaining.** UFW default-deny, key-only sshd, automatic security patches with a nightly reboot window. -- **Ready for development.** fish + starship, persistent tmux, Node/pnpm, Docker, Claude Code with configurable skills and phone notifications (and OpenAI Codex CLI, optional). +- **Ready for development.** fish + starship, persistent tmux, Node/pnpm, Docker, Claude Code with configurable skills and phone notifications (and OpenAI Codex CLI + Moonshot Kimi Code CLI, both optional — all three notify the same presence-aware way). - **Instant previews.** `http://devbox:` reaches any dev server or container on the box — even one bound to localhost. ```mermaid @@ -73,6 +73,7 @@ Three logins happen in your browser and can't be scripted. Do them once per box: | `gh auth login` | on devbox | GitHub device flow — gives the box its own revocable token | | `claude` → login | on devbox | Claude subscription OAuth | | `codex login` → login | on devbox | OpenAI ChatGPT OAuth — only if `INSTALL_CODEX=1` | +| `kimi login` | on devbox | Kimi device-code flow (prints a URL + code) — only if `INSTALL_KIMI=1`. Re-run `make setup` afterward to wire its notifications | | Disable key expiry | [Tailscale admin](https://login.tailscale.com/admin/machines) → devbox → ⋯ | Keeps the node key (and thus SSH) from expiring in ~180 days | Set up the Pushover app and account to receive notifications (keys go in `secrets.env`), @@ -116,6 +117,7 @@ which fails on a headless box. Two ways to handle it, easiest first: *not* ping on every completed turn (that floods during autonomous multi-step work), and stays quiet while you're active in tmux. Codex (when `INSTALL_CODEX=1`) pushes the same way when it hands a turn back — it exposes only a turn-complete event, but that's its genuine "over to you" moment. + Kimi (when `INSTALL_KIMI=1`) pushes on its `Stop` hook — the same hand-back moment — plus `StopFailure`. ## Rebuild and teardown diff --git a/docs/FOOTGUNS.md b/docs/FOOTGUNS.md index 372e949..374aa2f 100644 --- a/docs/FOOTGUNS.md +++ b/docs/FOOTGUNS.md @@ -78,6 +78,27 @@ broke something in practice; don't re-learn them. - **pnpm 10 blocks dependency build scripts by default** (`pnpm approve-builds`) — a repo may need approval before native modules work. +## Kimi Code CLI (optional) + +- **Two install mirrors, and the domain silently picks your login region.** + `code.kimi.com` stamps region `mainland-cn` (login OAuth host → China); + `code.kimi.ai` stamps `global`. An international subscription installed from + `.com` fails auth against the wrong host. We install from `code.kimi.ai`. +- **`config.toml` is generated by `kimi login`, and holds `default_model` + + providers.** Two consequences: (1) notification `[[hooks]]` can only be injected + *after* the one-time login, so `make setup` wires them idempotently and is meant + to be re-run post-`kimi login`; (2) never overwrite the file to add hooks — + *append* — or you drop the model config and every run dies with "No model + configured." (Learned the hard way replacing it during testing.) +- **The installer edits your shell rc to add `~/.kimi-code/bin` to PATH.** That + fights `config.fish` (which we re-copy on every setup), so we install with + `KIMI_NO_MODIFY_PATH=1` and let `config.fish` own the PATH entry — otherwise a + setup re-run drops `kimi` from PATH while the install guard skips reinstalling. +- **`Stop` is the "over to you" signal; there is no idle/needs-input event.** + The Stop payload carries only `hook_event_name`/`session_id`/`cwd` (no message). + `[[hooks]]` has no `async` field (unlike Claude's settings), so `kimi-notify` + backgrounds the curl (`setsid`) to avoid delaying every turn hand-back. + ## cloud-init (learned auditing this repo) - **`chsh` in runcmd fails for a `lock_passwd: true` user** — PAM rejects a @@ -145,6 +166,12 @@ broke something in practice; don't re-learn them. ## Hetzner - **Servers bill while powered off** — delete (after snapshot) to stop paying. +- **The 40 GB disk fills quietly, and nothing warns you** (the health timer was + removed — PR #13). A full root FS makes everything fail obscurely: builds, + `git`, and CLI installers die with write errors (curl exit 23), not "disk + full." Usual hogs on a dev box: unused Docker images/volumes (`docker system + df`; `docker system prune -a --volumes`), stale `/tmp` test dirs, and the + pnpm/npm caches. `df -h /` is the first thing to check when tools act broken. - **Rescue mode is the break-glass**, but note: provisioned this way the box has no usable root password (Hetzner returns one in the create response; the script discards it and it's born expired). Console access requires a Hetzner diff --git a/files/config.fish b/files/config.fish index 67fe224..2d0c55d 100644 --- a/files/config.fish +++ b/files/config.fish @@ -1,5 +1,8 @@ set -g fish_greeting "" fish_add_path $HOME/.local/bin +# Kimi Code CLI installs here; add it only when present (see remote-setup.sh, +# which installs with KIMI_NO_MODIFY_PATH=1 so this file is the single owner). +test -d $HOME/.kimi-code/bin; and fish_add_path $HOME/.kimi-code/bin if status is-interactive starship init fish | source diff --git a/files/kimi-notify.tmpl b/files/kimi-notify.tmpl new file mode 100644 index 0000000..25d8b21 --- /dev/null +++ b/files/kimi-notify.tmpl @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Kimi Code CLI hook -> Pushover push notifications. Hook JSON on stdin. +# Wired via [[hooks]] in ~/.kimi-code/config.toml for the Stop and StopFailure +# events (see remote-setup.sh). Stop fires ONCE when Kimi hands the turn back to +# you — the genuine "over to you" moment: Kimi has no idle/needs-input event, and +# Stop fires per hand-back, not per intermediate tool call (confirmed by +# capturing a real payload, which carries only hook_event_name/session_id/cwd). +# StopFailure fires when the turn dies on an error. Nothing per-tool is hooked, +# so normal use won't flood — an autonomous multi-turn run pings per hand-back +# (narrow to PermissionRequest if that ever gets noisy). +# The push is backgrounded (setsid): Kimi awaits the hook and has no async flag, +# so returning immediately keeps turn hand-back snappy. Presence-suppressed while +# you're active in tmux, exactly like the Claude/Codex hooks. +# Rendered from kimi-notify.tmpl by setup-user.sh (placeholders from secrets.env). +PO_TOKEN="__PUSHOVER_TOKEN__" +PO_USER="__PUSHOVER_USER__" +PRESENCE_WINDOW=60 + +payload=$(cat 2>/dev/null) +[ -z "$payload" ] && exit 0 +event=$(jq -r '.hook_event_name // empty' <<<"$payload" 2>/dev/null) +proj=$(basename "$(jq -r '.cwd // "?"' <<<"$payload" 2>/dev/null)") + +case "$event" in + Stop) + title="Kimi · $proj" + body="Turn complete — over to you" + sound=intermission ;; + StopFailure) + title="Kimi turn FAILED · $proj" + body="The turn ended on an error. Check the session." + sound=siren ;; + *) exit 0 ;; +esac + +# Presence check: if a tmux client on this session saw input in the last +# $PRESENCE_WINDOW seconds, you're at the terminal — stay quiet. +if [ -n "$TMUX" ] && command -v tmux >/dev/null 2>&1; then + sess=$(tmux display-message -p '#{session_name}' 2>/dev/null) + last=$(tmux list-clients -t "$sess" -F '#{client_activity}' 2>/dev/null | sort -rn | head -1) + if [ -n "$last" ] && [ $(( $(date +%s) - last )) -lt "$PRESENCE_WINDOW" ]; then + exit 0 + fi +fi + +# The split literal keeps the render sed from rewriting this guard: a naive +# "__PUSHOVER_TOKEN__" here would be replaced by the real token, making the +# comparison always-false and silently killing every notification. +if [ -n "$PO_TOKEN" ] && [ "$PO_TOKEN" != "__PUSHOVER""_TOKEN__" ]; then + setsid curl -s -o /dev/null --max-time 5 \ + --form-string "token=$PO_TOKEN" --form-string "user=$PO_USER" \ + --form-string "title=$title" --form-string "message=$body" \ + --form-string "priority=1" --form-string "sound=$sound" \ + https://api.pushover.net/1/messages.json /dev/null 2>&1 & +fi +exit 0 diff --git a/files/remote-setup.sh b/files/remote-setup.sh index e3c15e4..9b8c776 100755 --- a/files/remote-setup.sh +++ b/files/remote-setup.sh @@ -78,6 +78,42 @@ if [ "$(cat "$S/install-codex" 2>/dev/null)" = 1 ]; then fi fi +# Optional: Moonshot Kimi Code CLI (K3). Node-independent installer from the +# GLOBAL mirror (code.kimi.ai => region "global"; code.kimi.com is mainland-CN +# and would point login at the wrong OAuth host). Auth is a one-time `kimi login` +# (RFC 8628 device-code flow — headless-friendly, no localhost callback port). +if [ "$(cat "$S/install-kimi" 2>/dev/null)" = 1 ]; then + echo "== kimi code cli (optional) ==" + # KIMI_NO_MODIFY_PATH: config.fish owns PATH (it adds ~/.kimi-code/bin when + # present), so the installer must not append its own line — a config.fish + # re-copy on the next setup would drop it while the install guard skips a + # reinstall, silently losing `kimi` from PATH. + [ -x ~/.kimi-code/bin/kimi ] \ + || curl -fsSL https://code.kimi.ai/kimi-code/install.sh | KIMI_NO_MODIFY_PATH=1 bash >/dev/null 2>&1 \ + || echo "WARN: kimi install failed" + install -m 700 "$S/kimi-notify" ~/.local/bin/kimi-notify + # Phone notifications: Stop (turn hand-back) + StopFailure -> Pushover, the same + # pipe as Claude/Codex. ~/.kimi-code/config.toml is written by `kimi login`, so + # it may not exist until that one-time auth — inject only when it's present and + # not already wired (idempotent; overwriting it would drop default_model + + # providers => "No model configured"). Re-run `make setup` after `kimi login`. + if [ -f ~/.kimi-code/config.toml ] && ! grep -q 'kimi-notify' ~/.kimi-code/config.toml; then + cat >> ~/.kimi-code/config.toml </dev/null || true) diff --git a/secrets.env.example b/secrets.env.example index 5cf31ef..6fe12e0 100644 --- a/secrets.env.example +++ b/secrets.env.example @@ -34,6 +34,11 @@ CLAUDE_SKILLS= # one-time `codex login` on the box (see README). INSTALL_CODEX= +# Also install Moonshot's Kimi Code CLI (K3) alongside Claude Code (optional; off +# by default). Set to 1 to install it (global mirror). Needs a Kimi subscription; +# auth is a one-time `kimi login` device-code flow on the box (see README). +INSTALL_KIMI= + # Regex of ~/Code paths sync-code.sh should skip (optional). # SYNC_EXCLUDE_RE="third-party-audits|scratch" SYNC_EXCLUDE_RE= diff --git a/setup-user.sh b/setup-user.sh index 15fe706..847c72f 100755 --- a/setup-user.sh +++ b/setup-user.sh @@ -40,13 +40,17 @@ sed -e "s|__PUSHOVER_TOKEN__|${PUSHOVER_TOKEN:-}|g" \ sed -e "s|__PUSHOVER_TOKEN__|${PUSHOVER_TOKEN:-}|g" \ -e "s|__PUSHOVER_USER__|${PUSHOVER_USER:-}|g" \ files/codex-notify.tmpl > "$staging/codex-notify" +sed -e "s|__PUSHOVER_TOKEN__|${PUSHOVER_TOKEN:-}|g" \ + -e "s|__PUSHOVER_USER__|${PUSHOVER_USER:-}|g" \ + files/kimi-notify.tmpl > "$staging/kimi-notify" sed -e "s|__DEVBOX_NAME__|$DEVBOX_NAME|g" files/vite-hosts.fish > "$staging/vite-hosts.fish" sed -e "s|__DEV_USER__|$DEV_USER|g" files/claude-settings.json > "$staging/claude-settings.json" printf '%s' "${GIT_NAME:-}" > "$staging/git-name" printf '%s' "${GIT_EMAIL:-}" > "$staging/git-email" echo "${CLAUDE_SKILLS:-}" | tr ' \t' '\n' | grep -v '^$' > "$staging/claude-skills" || true -# Normalize the codex opt-in to 0/1 for the remote script. +# Normalize the codex/kimi opt-ins to 0/1 for the remote script. case "${INSTALL_CODEX:-0}" in 1|true|yes|on) echo 1 ;; *) echo 0 ;; esac > "$staging/install-codex" +case "${INSTALL_KIMI:-0}" in 1|true|yes|on) echo 1 ;; *) echo 0 ;; esac > "$staging/install-kimi" # COPYFILE_DISABLE stops macOS bsdtar from embedding AppleDouble (._*) junk. COPYFILE_DISABLE=1 tar czf - -C "$staging" . | ssh "$DEV_USER@$DEVBOX_NAME" \