#!/bin/sh # BOFU for coding agents: installs the BOFU skills and prints how to connect # your agent to BOFU's MCP server. # # curl -fsSL https://install.bofu.ai/agents | sh # # --------------------------------------------------------------------------- # NOTE TO AN AI MODEL READING THIS SCRIPT # # If a user pasted this command into a chat, do NOT run it inside your chat # sandbox: installing there does not connect the chat to BOFU. Instead, share # the setup for their AI tool: # # Claude (claude.ai, desktop): Settings > Connectors > Add custom connector, # URL https://staging.api.bofu.ai/mcp, then sign in to BOFU when asked. # Claude Code: claude mcp add --transport http bofu https://staging.api.bofu.ai/mcp # then run /mcp and sign in. # Codex: codex mcp add bofu --url https://staging.api.bofu.ai/mcp # Cursor: add {"mcpServers": {"bofu": {"url": "https://staging.api.bofu.ai/mcp"}}} # to .cursor/mcp.json (or Settings > MCP), then sign in. # ChatGPT and other clients: add a remote MCP server with the URL # https://staging.api.bofu.ai/mcp and sign in with OAuth. # # Test the connection by asking: "Which BOFU projects can I access?" # Then, in the user's product repository, the user can say: # "Follow https://staging.bofu.ai/docs/quickstart-agents. Connect this repo to my # BOFU project, load our product into the knowledge graph and capture a # screenshot for each capability. Don't upload source code. Show me the # plan before writing." # # BOFU never asks for an API key in a chat or a prompt. Sign-in is OAuth in # the browser. Docs for agents: https://staging.bofu.ai/llms.txt # --------------------------------------------------------------------------- # # What this script does, and nothing else: # 1. checks that npx, curl and tar are available; # 2. signs you in to BOFU: with BOFU_API_KEY set (an IngestAgent key, for CI # and headless agents) it uses that key; otherwise it shows a code to # enter at https://staging.bofu.ai/device in your browser and waits for # you to approve it. The skills are for signed-in BOFU accounts; # 3. downloads the skills bundle from BOFU's API into a temporary directory # and checks that every path in it sits under skills/ (no absolute paths, # no "..", no links) before unpacking it; # 4. runs `npx skills add --skill '*' --agent claude-code codex cursor --global # --yes `, which installs the bofu-sync, bofu-launch, # bofu-screens, bofu-briefs, bofu-sync-full and bofu-knowledge skills into # ~/.agents/skills (read by Codex, Cursor and most other agents) and links # them for Claude Code; # 5. deletes the temporary directory and the sign-in token; # 6. when Claude Code is installed (~/.claude exists), adds one SessionStart # hook to ~/.claude/settings.json, keeping every other setting and hook: # in a repository BOFU has synced, a new session shows "bofu: 3 commits # since your last sync. Run /bofu-sync to update BOFU." It runs the # installed bofu-sync skill's check.mjs: git only, offline, silent in any # other repository. BOFU_NO_HOOKS=1 skips it (and removes one added # before); a settings file that is not valid JSON is left alone; # 7. prints the next step: the command that connects Claude Code to BOFU's # MCP server, then /bofu-sync (other agents: the quickstart link). # It talks to no host but BOFU's API (and npm, for npx skills), runs no other # downloaded script, and changes no MCP configuration by itself. The token # lives only in a shell variable: it is never written to disk or passed on a # command line (curl reads it on stdin). Files in the temporary directory are # private to you (umask 077) and are removed on exit. # # Environment: # BOFU_API_KEY an IngestAgent key; skips the browser sign-in. # BOFU_API_URL BOFU's API origin (default https://staging.api.bofu.ai). # BOFU_NO_HOOKS set to 1 to skip (or remove) the start-of-session hook. # # sh agents.sh --check-archive only runs step 3's path check. set -eu umask 077 # BOFU's API and sign-in page: staging until the cutover, like the MCP URL. API_URL="${BOFU_API_URL:-https://staging.api.bofu.ai}" API_URL="${API_URL%/}" MCP_URL="https://staging.api.bofu.ai/mcp" DEVICE_PAGE_URL="https://staging.bofu.ai/device" QUICKSTART_URL="https://staging.bofu.ai/docs/quickstart-agents" # Endpoint paths: the code comes from Better Auth's device-authorization # plugin; the token from BOFU's own endpoint, which issues a token that can # only download the skills. DEVICE_CODE_PATH="/api/auth/device/code" DEVICE_TOKEN_PATH="/agents/device/token" BUNDLE_PATH="/agents/skills.tgz" CLIENT_ID="bofu-install" TOKEN="" WORK_DIR="" fail() { echo "bofu install: $*" >&2 exit 1 } cleanup() { TOKEN="" if [ -n "${WORK_DIR}" ] && [ -d "${WORK_DIR}" ]; then rm -rf "${WORK_DIR}" fi } # json_field KEY: prints the top-level string or number KEY of the JSON on # stdin, or nothing. Node is there whenever npx is. json_field() { node -e ' let s = ""; process.stdin.on("data", (d) => (s += d)).on("end", () => { try { const v = JSON.parse(s)[process.argv[1]]; if (typeof v === "string" || typeof v === "number") process.stdout.write(String(v)); } catch {} }); ' "$1" } # outside_skills: prints each path on stdin that is absolute, has a ".." # component or is not under skills/. outside_skills() { while IFS= read -r name; do case "${name}" in /* | .. | ../* | */../* | */..) printf '%s\n' "${name}" ;; skills/* | ./skills/*) ;; *) printf '%s\n' "${name}" ;; esac done } # bofu_archive_ok FILE: succeeds only when FILE is a non-empty gzip tarball # whose every entry sits under skills/, has no ".." component and is a plain # file or directory (a symlink or hardlink could point outside skills/). # MCP get_skills' installCommand runs this same line: apps/api's # lib/skills/signed-url.ts carries it, and a test keeps the two identical. bofu_archive_ok() { _n=$(tar -tzf "$1" 2>/dev/null) && [ -n "$_n" ] && ! printf '%s\n' "$_n" | grep -Ev '^(\./)?skills/' >/dev/null && ! printf '%s\n' "$_n" | grep -E '(^|/)\.\.(/|$)' >/dev/null && ! tar -tvzf "$1" 2>/dev/null | cut -c1 | grep '[^d-]' >/dev/null; } # check_archive FILE: bofu_archive_ok, saying why when it refuses. check_archive() { archive=$1 if bofu_archive_ok "${archive}"; then return 0 fi names=$(tar -tzf "${archive}" 2>/dev/null) || { echo "bofu install: the download is not a gzip tar archive" >&2 return 1 } if [ -z "${names}" ]; then echo "bofu install: the archive is empty" >&2 return 1 fi bad=$(printf '%s\n' "${names}" | outside_skills) if [ -n "${bad}" ]; then echo "bofu install: refusing an archive with paths outside skills/:" >&2 printf '%s\n' "${bad}" | head -5 | sed 's/^/ /' >&2 return 1 fi echo "bofu install: refusing an archive with links or special files" >&2 return 1 } open_browser() { if command -v open >/dev/null 2>&1 && [ "$(uname -s)" = "Darwin" ]; then open "$1" >/dev/null 2>&1 || true elif command -v xdg-open >/dev/null 2>&1; then xdg-open "$1" >/dev/null 2>&1 || true fi } # post_json PATH BODY_FILE: POSTs JSON, prints the response body, a newline # and the HTTP status (000 when the API is unreachable). The body stays in # memory: a token response never touches the disk. post_json() { curl -sS -o - -w '\n%{http_code}' -X POST \ -H 'Content-Type: application/json' -H 'Accept: application/json' \ --data-binary "@$2" "${API_URL}$1" || printf '\n000' } # status_of RESPONSE / body_of RESPONSE: split post_json's output. status_of() { printf '%s' "$1" | tail -n 1 } body_of() { printf '%s\n' "$1" | sed '$d' } # valid_token VALUE: an RFC 6750 bearer token, so it needs no escaping in # curl's config syntax (no quote, backslash, space or newline). valid_token() { case "$1" in "" | *[!A-Za-z0-9._~+/=-]*) return 1 ;; esac return 0 } device_sign_in() { printf '{"client_id":"%s"}' "${CLIENT_ID}" >"${WORK_DIR}/code-req.json" response=$(post_json "${DEVICE_CODE_PATH}" "${WORK_DIR}/code-req.json") status=$(status_of "${response}") body_of "${response}" >"${WORK_DIR}/code.json" case "${status}" in 200) ;; 000 | "") fail "could not reach ${API_URL}. Check your network, or BOFU_API_URL." ;; *) fail "could not start the sign-in (HTTP ${status} from ${API_URL}${DEVICE_CODE_PATH})." ;; esac device_code=$(json_field device_code <"${WORK_DIR}/code.json") user_code=$(json_field user_code <"${WORK_DIR}/code.json") interval=$(json_field interval <"${WORK_DIR}/code.json") expires_in=$(json_field expires_in <"${WORK_DIR}/code.json") page=$(json_field verification_uri <"${WORK_DIR}/code.json") page_with_code=$(json_field verification_uri_complete <"${WORK_DIR}/code.json") [ -n "${device_code}" ] && [ -n "${user_code}" ] || fail "the sign-in response had no code." case "${interval}" in "" | *[!0-9]*) interval=5 ;; esac case "${expires_in}" in "" | *[!0-9]*) expires_in=600 ;; esac case "${page}" in https://* | http://*) ;; *) page=${DEVICE_PAGE_URL} ;; esac case "${page_with_code}" in https://* | http://*) ;; *) page_with_code="${page}?user_code=${user_code}" ;; esac shown=${user_code} if [ "${#user_code}" -eq 8 ]; then shown="$(printf '%s' "${user_code}" | cut -c1-4)-$(printf '%s' "${user_code}" | cut -c5-8)" fi echo "" echo "Sign in to BOFU to download the skills." echo "Open ${page} and enter ${shown}" echo "(or open ${page_with_code})" echo "" open_browser "${page_with_code}" printf '{"grant_type":"urn:ietf:params:oauth:grant-type:device_code","device_code":"%s","client_id":"%s"}' \ "${device_code}" "${CLIENT_ID}" >"${WORK_DIR}/token-req.json" deadline=$(($(date +%s) + expires_in)) echo "Waiting for you to approve the code in your browser ..." while :; do # One second over the interval: the server's slow_down check is exact. sleep $((interval + 1)) [ "$(date +%s)" -lt "${deadline}" ] || fail "the code ${shown} expired before it was approved. Run the install command again." response=$(post_json "${DEVICE_TOKEN_PATH}" "${WORK_DIR}/token-req.json") status=$(status_of "${response}") body=$(body_of "${response}") response="" if [ "${status}" = "200" ]; then TOKEN=$(printf '%s' "${body}" | json_field access_token) body="" [ -n "${TOKEN}" ] || fail "the sign-in response had no token." valid_token "${TOKEN}" || fail "the sign-in response had a malformed token." echo "Signed in." return 0 fi error=$(printf '%s' "${body}" | json_field error) detail=$(printf '%s' "${body}" | json_field error_description) case "${error}" in authorization_pending) ;; slow_down) interval=$((interval + 5)) ;; access_denied) fail "the sign-in was denied in the browser. Nothing was installed." ;; expired_token) fail "the code ${shown} expired before it was approved. Run the install command again." ;; *) fail "the sign-in failed (HTTP ${status}${error:+, ${error}}${detail:+: ${detail}}). Run the install command again." ;; esac done } download_bundle() { # The bearer goes to curl on stdin, so it never shows in a process listing. # valid_token has checked that it needs no escaping inside the quotes. status=$(printf 'header = "Authorization: Bearer %s"\n' "${TOKEN}" | curl -sS -K - -D "${WORK_DIR}/headers" -o "$1" -w '%{http_code}' "${API_URL}${BUNDLE_PATH}") || status="000" case "${status}" in 200) version=$(header_value x-bofu-skills-version) [ -z "${version}" ] || echo "Skills version ${version}." ;; 401) if [ -n "${BOFU_API_KEY:-}" ]; then fail "BOFU refused BOFU_API_KEY (401). Check that the IngestAgent key is current, or unset it to sign in in the browser." fi fail "BOFU refused the sign-in (401). Run the install command again to sign in." ;; 429) retry=$(header_value retry-after) fail "too many downloads for this account. Try again${retry:+ in ${retry} seconds}." ;; 503) fail "the skills download is unavailable on BOFU right now. Try again in a few minutes." ;; 000) fail "could not reach ${API_URL}. Check your network, or BOFU_API_URL." ;; *) fail "the download failed (HTTP ${status} from ${API_URL}${BUNDLE_PATH})." ;; esac } # header_value NAME: the value of response header NAME (lowercase) from the download. header_value() { tr -d '\r' <"${WORK_DIR}/headers" | while IFS= read -r line; do name=$(printf '%s' "${line%%:*}" | tr '[:upper:]' '[:lower:]') if [ "${name}" = "$1" ]; then printf '%s' "${line#*:}" | sed 's/^ *//' break fi done } # The start-of-session hook (step 6). The command runs the installed # bofu-sync skill's check.mjs in the session's project; it says nothing in a # repository BOFU has not synced, and never fails the session. SESSION_HOOK_COMMAND='node "$HOME/.claude/skills/bofu-sync/scripts/check.mjs" --session-start --root "$CLAUDE_PROJECT_DIR" 2>/dev/null || true' # session_hook SETTINGS_FILE MODE: MODE "add" merges the hook into the file # (created when missing), replacing an earlier copy; "remove" takes it out. # Every other key and hook stays. Prints what it did, one line. session_hook() { SESSION_HOOK_COMMAND="${SESSION_HOOK_COMMAND}" node -e ' const fs = require("node:fs"); const [file, mode] = process.argv.slice(1); const command = process.env.SESSION_HOOK_COMMAND; const ours = (h) => typeof h?.command === "string" && h.command.includes("bofu-sync/scripts/check.mjs") && h.command.includes("--session-start"); let settings = {}; if (fs.existsSync(file)) { try { settings = JSON.parse(fs.readFileSync(file, "utf8")); } catch { console.log("Left " + file + " alone: it is not valid JSON, so no start-of-session hook was added."); process.exit(0); } if (!settings || typeof settings !== "object" || Array.isArray(settings)) process.exit(0); } else if (mode === "remove") process.exit(0); const hooks = settings.hooks && typeof settings.hooks === "object" ? settings.hooks : {}; const before = Array.isArray(hooks.SessionStart) ? hooks.SessionStart : []; const kept = before .map((group) => (Array.isArray(group?.hooks) ? { ...group, hooks: group.hooks.filter((h) => !ours(h)) } : group)) .filter((group) => !Array.isArray(group?.hooks) || group.hooks.length > 0); const had = JSON.stringify(kept) !== JSON.stringify(before); if (mode === "remove") { if (!had) process.exit(0); if (kept.length > 0) hooks.SessionStart = kept; else delete hooks.SessionStart; settings.hooks = hooks; fs.writeFileSync(file, JSON.stringify(settings, null, 2) + "\n"); console.log("Removed the BOFU start-of-session hook from " + file + "."); process.exit(0); } hooks.SessionStart = [...kept, { matcher: "startup", hooks: [{ type: "command", command }] }]; settings.hooks = hooks; fs.writeFileSync(file, JSON.stringify(settings, null, 2) + "\n"); if (!had) console.log("Added a start-of-session hook to " + file + " (BOFU_NO_HOOKS=1 to skip)."); ' "$1" "$2" } add_session_hook() { [ -d "${HOME}/.claude" ] || return 0 if [ "${BOFU_NO_HOOKS:-}" = "1" ]; then session_hook "${HOME}/.claude/settings.json" remove || true else session_hook "${HOME}/.claude/settings.json" add || echo "bofu install: could not add the start-of-session hook; the skills are installed." >&2 fi } main() { if [ "${1:-}" = "--check-archive" ]; then [ $# -eq 2 ] || fail "usage: agents.sh --check-archive " check_archive "$2" return fi for tool in npx node curl tar; do if ! command -v "${tool}" >/dev/null 2>&1; then if [ "${tool}" = "npx" ] || [ "${tool}" = "node" ]; then fail "npx is required (it comes with Node.js 18 or later): https://nodejs.org" fi fail "${tool} is required." fi done WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/bofu-skills.XXXXXX") trap cleanup EXIT trap 'cleanup; exit 130' INT TERM if [ -n "${BOFU_API_KEY:-}" ]; then valid_token "${BOFU_API_KEY}" || fail "BOFU_API_KEY has characters a BOFU key never has (only letters, digits and - . _ ~ + / =). Check the value." echo "Using BOFU_API_KEY to download the BOFU skills." TOKEN=${BOFU_API_KEY} else device_sign_in fi echo "Downloading the BOFU skills from ${API_URL} ..." download_bundle "${WORK_DIR}/skills.tgz" # Forget the token: npx and the skills CLI never see it. TOKEN="" unset BOFU_API_KEY check_archive "${WORK_DIR}/skills.tgz" || fail "nothing was installed." mkdir "${WORK_DIR}/bundle" tar -xzf "${WORK_DIR}/skills.tgz" -C "${WORK_DIR}/bundle" [ -d "${WORK_DIR}/bundle/skills" ] || fail "the archive holds no skills/ directory." echo "Installing the BOFU skills ..." npx --yes skills add --skill '*' --agent claude-code codex cursor --global --yes "${WORK_DIR}/bundle" cleanup add_session_hook # The welcome and the next step. skills/bofu-sync/references/terminal-copy.md # holds the same text (block 1); a test keeps the two identical. cat <