返回 Skills 目錄
heredotnow/skill已通過檢查

SKILL DETAIL

here-now

heredotnow/skill/here-now

here.now lets agents publish websites and files to live URLs in seconds. Publish HTML, documents, images, PDFs, videos, and static files to live URLs at {slug}.here.now or custom domains. Use when asked to "publish this", "host this", "deploy this", "share this on the web", "make a website", "put this online", "create a webpage", "generate a URL", "build a chatbot", "password protect this site", "make this site private", or "share this site with only certain people". here.now also includes workspaces — shared team accounts where Sites belong to the team and serve at {label}.{workspace}.here.now — use when asked to "publish this to our team workspace", "share this with my team", or "put this in our company workspace".

安裝量 · 96查看來源

Installation

npx skills add https://github.com/heredotnow/skill --skill here-now

技能檔案

SKILL.md

最近同步 · 2026年9月6日

scripts/drive.sh
#!/usr/bin/env bash
set -euo pipefail

BASE_URL="https://here.now"
CREDENTIALS_FILE="$HOME/.herenow/credentials"
API_KEY="${HERENOW_API_KEY:-}"
DRIVE_TOKEN="${HERENOW_DRIVE_TOKEN:-}"
ALLOW_NON_HERENOW_BASE_URL=0
MAX_FILE_BYTES=$((500 * 1024 * 1024))

usage() {
  cat <<'USAGE'
Usage: drive.sh [global options] <command> [args]

Global options:
  --api-key <key>        Account API key (or $HERENOW_API_KEY / ~/.herenow/credentials)
  --token <drv_live_...> Drive token (or $HERENOW_DRIVE_TOKEN)
  --base-url <url>       API base (default: https://here.now)
  --allow-nonherenow-base-url

Commands:
  create [name] [--default]
  default
  ls
  ls <drive> [prefix]
  cat <drive> <path>
  put <drive> <path> --from <local-file>
  import <drive> <prefix> --from <local-folder> [--dry-run]
  export <drive> <prefix> --to <local-folder> [--dry-run]
  rm <drive> <path> [--recursive --confirm <path>]
  share <drive> --perms read|write [--prefix notes/] [--ttl 30d] [--label text] [--manage-tokens]
  tokens <drive>
  revoke <drive> <tokenId>
  delete <drive> --confirm "<drive name>"
USAGE
  exit 1
}

die() { echo "error: $1" >&2; exit 1; }

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
BUNDLED_JQ="${SKILL_DIR}/bin/jq"

if [[ -x "$BUNDLED_JQ" ]]; then
  JQ_BIN="$BUNDLED_JQ"
elif command -v jq >/dev/null 2>&1; then
  JQ_BIN="$(command -v jq)"
else
  die "requires jq"
fi

for cmd in curl file; do
  command -v "$cmd" >/dev/null 2>&1 || die "requires $cmd"
done

while [[ $# -gt 0 ]]; do
  case "$1" in
    --api-key) API_KEY="$2"; shift 2 ;;
    --token) DRIVE_TOKEN="$2"; shift 2 ;;
    --base-url) BASE_URL="$2"; shift 2 ;;
    --allow-nonherenow-base-url) ALLOW_NON_HERENOW_BASE_URL=1; shift ;;
    --help|-h) usage ;;
    --*) die "unknown global option: $1" ;;
    *) break ;;
  esac
done

CMD="${1:-}"
[[ -n "$CMD" ]] || usage
shift || true

if [[ -z "$API_KEY" && -z "$DRIVE_TOKEN" && -f "$CREDENTIALS_FILE" ]]; then
  API_KEY=$(tr -d '[:space:]' < "$CREDENTIALS_FILE")
fi

BASE_URL="${BASE_URL%/}"
if [[ "$BASE_URL" != "https://here.now" && "$ALLOW_NON_HERENOW_BASE_URL" -ne 1 ]]; then
  if [[ -n "$API_KEY" || -n "$DRIVE_TOKEN" ]]; then
    die "refusing to send credentials to non-default base URL; pass --allow-nonherenow-base-url to override"
  fi
fi

auth_header=()
if [[ -n "$DRIVE_TOKEN" ]]; then
  auth_header=(-H "authorization: Bearer $DRIVE_TOKEN")
elif [[ -n "$API_KEY" ]]; then
  auth_header=(-H "authorization: Bearer $API_KEY")
else
  die "missing credentials; set HERENOW_API_KEY, HERENOW_DRIVE_TOKEN, or ~/.herenow/credentials"
fi

compute_sha256() {
  local f="$1"
  if command -v sha256sum >/dev/null 2>&1; then
    sha256sum "$f" | cut -d' ' -f1
  else
    shasum -a 256 "$f" | cut -d' ' -f1
  fi
}

guess_content_type() {
  local f="$1"
  case "${f##*.}" in
    html|htm) echo "text/html; charset=utf-8" ;;
    css) echo "text/css; charset=utf-8" ;;
    js|mjs) echo "text/javascript; charset=utf-8" ;;
    json) echo "application/json; charset=utf-8" ;;
    md|txt) echo "text/plain; charset=utf-8" ;;
    svg) echo "image/svg+xml" ;;
    png) echo "image/png" ;;
    jpg|jpeg) echo "image/jpeg" ;;
    gif) echo "image/gif" ;;
    webp) echo "image/webp" ;;
    pdf) echo "application/pdf" ;;
    *) file --brief --mime-type "$f" 2>/dev/null || echo "application/octet-stream" ;;
  esac
}

api_json() {
  local method="$1"; shift
  local url="$1"; shift
  local body="${1:-}"
  local tmp
  tmp=$(mktemp)
  local code
  if [[ -n "$body" ]]; then
    code=$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "$url" "${auth_header[@]}" -H "content-type: application/json" -d "$body")
  else
    code=$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "$url" "${auth_header[@]}")
  fi
  if [[ "$code" -lt 200 || "$code" -ge 300 ]]; then
    local err
    err=$("$JQ_BIN" -r '.error // empty' "$tmp" 2>/dev/null || true)
    [[ -n "$err" ]] || err="$(cat "$tmp")"
    rm -f "$tmp"
    die "HTTP $code: $err"
  fi
  cat "$tmp"
  rm -f "$tmp"
}

urlenc() {
  "$JQ_BIN" -nr --arg v "$1" '$v|@uri'
}

urlenc_path() {
  local path="$1"
  local out=""
  local part
  IFS='/' read -r -a parts <<< "$path"
  for part in "${parts[@]}"; do
    [[ -n "$out" ]] && out="$out/"
    out="$out$(urlenc "$part")"
  done
  echo "$out"
}

resolve_drive() {
  local name="$1"
  if [[ "$name" == drv_* ]]; then
    echo "$name"
    return
  fi
  if [[ -n "$DRIVE_TOKEN" ]]; then
    die "drive tokens must reference drives by drv_ id; use account credentials to resolve drive names"
  fi
  if [[ "$name" == "default" || "$name" == "my-drive" || "$name" == "My Drive" ]]; then
    api_json GET "$BASE_URL/api/v1/drives/default" | "$JQ_BIN" -r '.drive.id'
    return
  fi
  local rows count
  rows=$(api_json GET "$BASE_URL/api/v1/drives" | "$JQ_BIN" --arg n "$name" '[.drives[] | select(.name == $n)]')
  count=$(echo "$rows" | "$JQ_BIN" 'length')
  [[ "$count" -eq 1 ]] || die "drive name '$name' matched $count drives; use a drv_ id"
  echo "$rows" | "$JQ_BIN" -r '.[0].id'
}

drive_head() {
  local id="$1"
  api_json GET "$BASE_URL/api/v1/drives/$id" | "$JQ_BIN" -r '.drive.headVersionId // .headVersionId // empty'
}

file_meta() {
  local id="$1"
  local path="$2"
  local prefix
  prefix=$(urlenc "$path")
  api_json GET "$BASE_URL/api/v1/drives/$id/files?prefix=$prefix&limit=200" | "$JQ_BIN" -c --arg p "$path" '.files[]? | select(.path == $p)' | head -n 1
}

put_file() {
  local drive="$1"; shift
  local path="$1"; shift
  local local_file=""
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --from) local_file="$2"; shift 2 ;;
      *) die "unexpected put argument: $1" ;;
    esac
  done
  [[ -f "$local_file" ]] || die "--from must be a file"
  local id sz ct sha meta body upload upload_url upload_id http_code
  id=$(resolve_drive "$drive")
  sz=$(wc -c < "$local_file" | tr -d ' ')
  [[ "$sz" -le "$MAX_FILE_BYTES" ]] || die "$path exceeds the $MAX_FILE_BYTES byte Drive file limit"
  ct=$(guess_content_type "$local_file")
  sha=$(compute_sha256 "$local_file")
  meta=$(file_meta "$id" "$path" || true)
  body=$("$JQ_BIN" -n --arg p "$path" --argjson s "$sz" --arg c "$ct" --arg sha "$sha" \
    '{path:$p,size:$s,contentType:$c,sha256:$sha}')
  if [[ -n "$meta" ]]; then
    etag=$(echo "$meta" | "$JQ_BIN" -r '.etag')
    body=$(echo "$body" | "$JQ_BIN" --arg e "$etag" '.ifMatch = $e')
  else
    body=$(echo "$body" | "$JQ_BIN" '.ifNoneMatch = "*"')
  fi
  upload=$(api_json POST "$BASE_URL/api/v1/drives/$id/files/uploads" "$body")
  upload_url=$(echo "$upload" | "$JQ_BIN" -r '.uploadUrl')
  upload_id=$(echo "$upload" | "$JQ_BIN" -r '.uploadId')
  # `|| http_code=000`: a connection-level failure would otherwise exit the
  # script under set -e with only curl's message, before the hint below.
  http_code=$(curl -sS -o /dev/null -w "%{http_code}" -X PUT "$upload_url" -H "Content-Type: $ct" --data-binary "@$local_file") || http_code="000"
  [[ "$http_code" -ge 200 && "$http_code" -lt 300 ]] || die "upload failed for $path (HTTP $http_code). The upload URL PUTs directly to *.r2.cloudflarestorage.com, not to here.now; if this environment restricts outbound network access, allow that host as well as here.now."
  api_json POST "$BASE_URL/api/v1/drives/$id/files/finalize" "$("$JQ_BIN" -n --arg u "$upload_id" '{uploadId:$u}')" | "$JQ_BIN" .
}

case "$CMD" in
  create)
    name=""
    is_default="false"
    while [[ $# -gt 0 ]]; do
      case "$1" in
        --default) is_default="true"; shift ;;
        *) [[ -z "$name" ]] && name="$1" || die "unexpected argument: $1"; shift ;;
      esac
    done
    body=$("$JQ_BIN" -n --arg n "$name" --argjson d "$is_default" '{isDefault:$d} + (if $n == "" then {} else {name:$n} end)')
    api_json POST "$BASE_URL/api/v1/drives" "$body" | "$JQ_BIN" .
    ;;
  default)
    api_json GET "$BASE_URL/api/v1/drives/default" | "$JQ_BIN" .
    ;;
  ls)
    if [[ $# -eq 0 ]]; then
      [[ -z "$DRIVE_TOKEN" ]] || die "drive tokens cannot list drives; pass a drv_ id"
      api_json GET "$BASE_URL/api/v1/drives" | "$JQ_BIN" .
    else
      id=$(resolve_drive "$1")
      prefix="${2:-}"
      api_json GET "$BASE_URL/api/v1/drives/$id/files?prefix=$(urlenc "$prefix")" | "$JQ_BIN" .
    fi
    ;;
  cat)
    [[ $# -eq 2 ]] || die "usage: drive.sh cat <drive> <path>"
    id=$(resolve_drive "$1")
    curl -fsS "$BASE_URL/api/v1/drives/$id/files/$(urlenc_path "$2")" "${auth_header[@]}"
    ;;
  put)
    [[ $# -ge 2 ]] || die "usage: drive.sh put <drive> <path> --from <local-file>"
    put_file "$@"
    ;;
  import)
    [[ $# -ge 2 ]] || die "usage: drive.sh import <drive> <prefix> --from <local-folder> [--dry-run]"
    drive="$1"; prefix="${2%/}"; shift 2
    from=""; dry=0
    while [[ $# -gt 0 ]]; do
      case "$1" in
        --from) from="$2"; shift 2 ;;
        --dry-run) dry=1; shift ;;
        *) die "unexpected import argument: $1" ;;
      esac
    done
    [[ -d "$from" ]] || die "--from must be a folder"
    uploaded=0
    skipped=0
    failed=0
    planned=0
    while IFS= read -r -d '' f; do
      rel="${f#$from/}"
      [[ "$rel" == .git/* || "$rel" == node_modules/* || "$rel" == ".DS_Store" || "$rel" == */.DS_Store ]] && continue
      planned=$((planned + 1))
      sz=$(wc -c < "$f" | tr -d ' ')
      if [[ "$sz" -gt "$MAX_FILE_BYTES" ]]; then
        echo "skip oversized $f ($sz bytes > $MAX_FILE_BYTES)" >&2
        skipped=$((skipped + 1))
        continue
      fi
      dest="$rel"
      [[ -n "$prefix" ]] && dest="$prefix/$rel"
      if [[ "$dry" -eq 1 ]]; then
        echo "upload $f -> $dest"
        skipped=$((skipped + 1))
      else
        if (put_file "$drive" "$dest" --from "$f" >/dev/null); then
          uploaded=$((uploaded + 1))
        else
          failed=$((failed + 1))
        fi
      fi
    done < <(find "$from" -type f -print0 | sort -z)
    echo "planned=$planned uploaded=$uploaded skipped=$skipped failed=$failed"
    [[ "$failed" -eq 0 ]] || exit 1
    ;;
  export)
    [[ $# -ge 2 ]] || die "usage: drive.sh export <drive> <prefix> --to <local-folder> [--dry-run]"
    id=$(resolve_drive "$1"); prefix="${2%/}"; shift 2
    to=""; dry=0
    while [[ $# -gt 0 ]]; do
      case "$1" in
        --to) to="$2"; shift 2 ;;
        --dry-run) dry=1; shift ;;
        *) die "unexpected export argument: $1" ;;
      esac
    done
    [[ -n "$to" ]] || die "--to is required"
    cursor=""
    total=0
    while true; do
      url="$BASE_URL/api/v1/drives/$id/files?prefix=$(urlenc "$prefix")&limit=200"
      [[ -n "$cursor" ]] && url="$url&cursor=$(urlenc "$cursor")"
      files=$(api_json GET "$url")
      while IFS= read -r p; do
        [[ -n "$p" ]] || continue
        rel="$p"
        [[ -n "$prefix" ]] && rel="${p#$prefix/}"
        out="$to/$rel"
        if [[ "$dry" -eq 1 ]]; then
          echo "download $p -> $out"
        else
          mkdir -p "$(dirname "$out")"
          curl -fsS "$BASE_URL/api/v1/drives/$id/files/$(urlenc_path "$p")" "${auth_header[@]}" -o "$out"
        fi
        total=$((total + 1))
      done < <(echo "$files" | "$JQ_BIN" -r '.files[].path')
      cursor=$(echo "$files" | "$JQ_BIN" -r '.nextCursor // empty')
      [[ -n "$cursor" ]] || break
    done
    echo "files=$total"
    ;;
  rm)
    [[ $# -ge 2 ]] || die "usage: drive.sh rm <drive> <path> [--recursive --confirm <path>]"
    id=$(resolve_drive "$1"); path="$2"; shift 2
    recursive=0; confirm=""
    while [[ $# -gt 0 ]]; do
      case "$1" in
        --recursive) recursive=1; shift ;;
        --confirm) confirm="$2"; shift 2 ;;
        *) die "unexpected rm argument: $1" ;;
      esac
    done
    if [[ "$recursive" -eq 1 ]]; then
      [[ "$confirm" == "$path" ]] || die "recursive delete requires --confirm '$path'"
      head=$(drive_head "$id")
      api_json DELETE "$BASE_URL/api/v1/drives/$id/files/$(urlenc_path "$path")?recursive=true&baseVersionId=$(urlenc "$head")" | "$JQ_BIN" .
    else
      meta=$(file_meta "$id" "$path")
      etag=$(echo "$meta" | "$JQ_BIN" -r '.etag')
      curl -fsS -X DELETE "$BASE_URL/api/v1/drives/$id/files/$(urlenc_path "$path")" "${auth_header[@]}" -H "If-Match: $etag" | "$JQ_BIN" .
    fi
    ;;
  share)
    [[ $# -ge 1 ]] || die "usage: drive.sh share <drive> --perms read|write [--prefix notes/] [--ttl 30d] [--label text] [--manage-tokens]"
    id=$(resolve_drive "$1"); shift
    perms="write"; prefix=""; ttl=""; label=""; manage_tokens="false"
    while [[ $# -gt 0 ]]; do
      case "$1" in
        --perms) perms="$2"; shift 2 ;;
        --prefix) prefix="$2"; shift 2 ;;
        --ttl) ttl="$2"; shift 2 ;;
        --label) label="$2"; shift 2 ;;
        --manage-tokens) manage_tokens="true"; shift ;;
        *) die "unexpected share argument: $1" ;;
      esac
    done
    body=$("$JQ_BIN" -n --arg p "$perms" --arg pp "$prefix" --arg ttl "$ttl" --arg label "$label" --argjson mt "$manage_tokens" \
      '{perms:$p} + (if $mt then {manageTokens:true} else {} end) + (if $ttl == "" then {} else {ttl:$ttl} end) + (if $pp == "" then {} else {pathPrefix:$pp} end) + (if $label == "" then {} else {label:$label} end)')
    api_json POST "$BASE_URL/api/v1/drives/$id/tokens" "$body" | "$JQ_BIN" -r '.shareBlock'
    ;;
  tokens)
    [[ $# -eq 1 ]] || die "usage: drive.sh tokens <drive>"
    id=$(resolve_drive "$1")
    api_json GET "$BASE_URL/api/v1/drives/$id/tokens" | "$JQ_BIN" .
    ;;
  revoke)
    [[ $# -eq 2 ]] || die "usage: drive.sh revoke <drive> <tokenId>"
    id=$(resolve_drive "$1")
    api_json DELETE "$BASE_URL/api/v1/drives/$id/tokens/$2" | "$JQ_BIN" .
    ;;
  delete)
    [[ $# -ge 1 ]] || die "usage: drive.sh delete <drive> --confirm <drive name>"
    id=$(resolve_drive "$1"); shift
    confirm=""
    while [[ $# -gt 0 ]]; do
      case "$1" in
        --confirm) confirm="$2"; shift 2 ;;
        *) die "unexpected delete argument: $1" ;;
      esac
    done
    drive=$(api_json GET "$BASE_URL/api/v1/drives/$id")
    name=$(echo "$drive" | "$JQ_BIN" -r '.drive.name')
    [[ "$confirm" == "$name" ]] || die "delete requires --confirm '$name'"
    api_json DELETE "$BASE_URL/api/v1/drives/$id" | "$JQ_BIN" .
    ;;
  *)
    die "unknown command: $CMD"
    ;;
esac
scripts/publish.sh
#!/usr/bin/env bash
set -euo pipefail

BASE_URL="https://here.now"
CREDENTIALS_FILE="$HOME/.herenow/credentials"
API_KEY="${HERENOW_API_KEY:-}"
API_KEY_SOURCE="none"
if [[ -n "${HERENOW_API_KEY:-}" ]]; then
  API_KEY_SOURCE="env"
fi
ALLOW_NON_HERENOW_BASE_URL=0
SLUG=""
WORKSPACE=""
CLAIM_TOKEN=""
TITLE=""
DESCRIPTION=""
TTL=""
CLIENT=""
TARGET=""
SPA_MODE=""
FROM_DRIVE=""
DRIVE_VERSION=""
OVERWRITE=0
BASE_VERSION_ID=""

usage() {
  cat <<'USAGE'
Usage: publish.sh <file-or-dir> [options]

Options:
  --api-key <key>         API key (or set $HERENOW_API_KEY)
  --slug <slug>           Update existing publish
  --workspace <subdomain> Publish into a workspace (team account) you belong to
  --claim-token <token>   Claim token for anonymous updates
  --title <text>          Viewer title
  --description <text>    Viewer description
  --ttl <seconds>         Expiry (authenticated only)
  --client <name>         Agent name for attribution (e.g. cursor, claude-code)
  --overwrite             Skip the stale-base check when updating (see below)
  --spa                   Enable SPA routing
  --from-drive <drv_...>  Publish a Drive snapshot instead of local files
  --version <dv_...>      Drive version for --from-drive (default: current head)
  --base-url <url>        API base (default: https://here.now)
  --allow-nonherenow-base-url
                         Allow auth requests to non-default API base URL
USAGE
  exit 1
}

die() { echo "error: $1" >&2; exit 1; }

# Prints an actionable version_conflict report and exits. $1 = response JSON.
die_version_conflict() {
  local resp="$1"
  local msg cur src at
  msg=$(echo "$resp" | "$JQ_BIN" -r '.message // .error')
  cur=$(echo "$resp" | "$JQ_BIN" -r '.details.currentVersionId // empty')
  src=$(echo "$resp" | "$JQ_BIN" -r '.details.currentVersionSource // empty')
  at=$(echo "$resp" | "$JQ_BIN" -r '.details.currentVersionCreatedAt // empty')
  echo "error: $msg" >&2
  [[ -n "$cur" ]] && echo "live version: $cur${src:+ (created by $src)}${at:+ at $at}" >&2
  echo "publish_result.conflict=version_conflict" >&2
  echo "The live Site changed since this directory last published it." >&2
  echo "Options: read the live files and reconcile your local copy first" >&2
  echo "  (GET ${BASE_URL}/api/v1/publish/${SLUG}/files lists them; GET .../files/{path} returns each," >&2
  echo "  with your API key, no visitor password needed), then republish;" >&2
  echo "or re-run with --overwrite to replace the live version anyway." >&2
  exit 1
}

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
BUNDLED_JQ="${SKILL_DIR}/bin/jq"

if [[ -x "$BUNDLED_JQ" ]]; then
  JQ_BIN="$BUNDLED_JQ"
elif command -v jq >/dev/null 2>&1; then
  JQ_BIN="$(command -v jq)"
else
  die "requires jq"
fi

for cmd in curl file; do
  command -v "$cmd" >/dev/null 2>&1 || die "requires $cmd"
done

while [[ $# -gt 0 ]]; do
  case "$1" in
    --api-key)      API_KEY="$2"; API_KEY_SOURCE="flag"; shift 2 ;;
    --slug)         SLUG="$2"; shift 2 ;;
    --workspace)    WORKSPACE="$2"; shift 2 ;;
    --claim-token)  CLAIM_TOKEN="$2"; shift 2 ;;
    --title)        TITLE="$2"; shift 2 ;;
    --description)  DESCRIPTION="$2"; shift 2 ;;
    --ttl)          TTL="$2"; shift 2 ;;
    --client)       CLIENT="$2"; shift 2 ;;
    --base-url)     BASE_URL="$2"; shift 2 ;;
    --allow-nonherenow-base-url) ALLOW_NON_HERENOW_BASE_URL=1; shift ;;
    --overwrite)    OVERWRITE=1; shift ;;
    --spa)          SPA_MODE="true"; shift ;;
    --from-drive)   FROM_DRIVE="$2"; shift 2 ;;
    --version)      DRIVE_VERSION="$2"; shift 2 ;;
    --help|-h)      usage ;;
    -*)             die "unknown option: $1" ;;
    *)              [[ -z "$TARGET" ]] && TARGET="$1" || die "unexpected argument: $1"; shift ;;
  esac
done

if [[ -n "$FROM_DRIVE" ]]; then
  [[ -z "$TARGET" ]] || die "--from-drive does not accept a local file-or-dir argument"
else
  [[ -n "$TARGET" ]] || usage
  [[ -e "$TARGET" ]] || die "path does not exist: $TARGET"
fi

# Load API key from credentials file if not provided via flag or env
if [[ -z "$API_KEY" && -f "$CREDENTIALS_FILE" ]]; then
  API_KEY=$(cat "$CREDENTIALS_FILE" | tr -d '[:space:]')
  [[ -n "$API_KEY" ]] && API_KEY_SOURCE="credentials"
fi

BASE_URL="${BASE_URL%/}"
STATE_DIR=".herenow"
STATE_FILE="$STATE_DIR/state.json"

# Workspace publishing requires an account API key and is not supported for
# --from-drive in this script (Drives are personal; use the API directly).
if [[ -n "$WORKSPACE" ]]; then
  [[ -n "$API_KEY" ]] || die "--workspace requires an account API key"
  [[ -z "$FROM_DRIVE" ]] || die "--workspace cannot be combined with --from-drive"
fi

# Safety guard: avoid accidentally sending bearer auth to arbitrary endpoints.
if [[ -n "$API_KEY" && "$BASE_URL" != "https://here.now" && "$ALLOW_NON_HERENOW_BASE_URL" -ne 1 ]]; then
  die "refusing to send API key to non-default base URL; pass --allow-nonherenow-base-url to override"
fi

# Auto-load claim token from state file for slug updates (server uses it only for
# anonymous sites; harmless when an API key is also present).
if [[ -n "$SLUG" && -z "$CLAIM_TOKEN" && -f "$STATE_FILE" ]]; then
  CLAIM_TOKEN=$("$JQ_BIN" -r --arg s "$SLUG" '.publishes[$s].claimToken // empty' "$STATE_FILE" 2>/dev/null || true)
fi

if [[ -n "$FROM_DRIVE" ]]; then
  [[ -n "$API_KEY" ]] || die "--from-drive requires an account API key"
  BODY=$("$JQ_BIN" -n --arg d "$FROM_DRIVE" '{driveId:$d}')
  [[ -n "$DRIVE_VERSION" ]] && BODY=$(echo "$BODY" | "$JQ_BIN" --arg v "$DRIVE_VERSION" '.versionId = $v')
  [[ -n "$SLUG" ]] && BODY=$(echo "$BODY" | "$JQ_BIN" --arg s "$SLUG" '.slug = $s')
  if [[ -n "$TITLE" || -n "$DESCRIPTION" ]]; then
    viewer="{}"
    [[ -n "$TITLE" ]] && viewer=$(echo "$viewer" | "$JQ_BIN" --arg t "$TITLE" '.title = $t')
    [[ -n "$DESCRIPTION" ]] && viewer=$(echo "$viewer" | "$JQ_BIN" --arg d "$DESCRIPTION" '.description = $d')
    BODY=$(echo "$BODY" | "$JQ_BIN" --argjson v "$viewer" '.viewer = $v')
  fi
  [[ "$SPA_MODE" == "true" ]] && BODY=$(echo "$BODY" | "$JQ_BIN" '.spaMode = true')
  CLIENT_HEADER_VALUE="here-now-publish-sh"
  if [[ -n "$CLIENT" ]]; then
    normalized_client=$(echo "$CLIENT" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9._-' '-')
    normalized_client="${normalized_client#-}"
    normalized_client="${normalized_client%-}"
    if [[ -n "$normalized_client" ]]; then
      CLIENT_HEADER_VALUE="${normalized_client}/publish-sh"
    fi
  fi

  echo "publishing from Drive..." >&2
  RESPONSE=$(curl -sS -X POST "$BASE_URL/api/v1/publish/from-drive" \
    -H "authorization: Bearer $API_KEY" \
    -H "x-herenow-client: $CLIENT_HEADER_VALUE" \
    -H "content-type: application/json" \
    -d "$BODY")
  if echo "$RESPONSE" | "$JQ_BIN" -e '.error' >/dev/null 2>&1; then
    err=$(echo "$RESPONSE" | "$JQ_BIN" -r '.error')
    die "$err"
  fi
  SITE_URL=$(echo "$RESPONSE" | "$JQ_BIN" -r '.siteUrl')
  OUT_SLUG=$(echo "$RESPONSE" | "$JQ_BIN" -r '.slug')
  CURRENT_VERSION=$(echo "$RESPONSE" | "$JQ_BIN" -r '.currentVersionId')
  DRIVE_VERSION_OUT=$(echo "$RESPONSE" | "$JQ_BIN" -r '.driveVersionId')
  echo "$SITE_URL"
  echo "" >&2
  echo "publish_result.site_url=$SITE_URL" >&2
  echo "publish_result.slug=$OUT_SLUG" >&2
  echo "publish_result.action=from_drive" >&2
  echo "publish_result.auth_mode=authenticated" >&2
  echo "publish_result.api_key_source=$API_KEY_SOURCE" >&2
  echo "publish_result.persistence=permanent" >&2
  echo "publish_result.drive_id=$FROM_DRIVE" >&2
  echo "publish_result.drive_version_id=$DRIVE_VERSION_OUT" >&2
  echo "publish_result.current_version_id=$CURRENT_VERSION" >&2
  exit 0
fi

# Absolute source path: scopes the saved base version to slug + source path,
# so publishing a different directory to the same slug never falsely claims
# its files are current.
if [[ -f "$TARGET" ]]; then
  TARGET_ABS="$(cd "$(dirname "$TARGET")" && pwd)/$(basename "$TARGET")"
else
  TARGET_ABS="$(cd "$TARGET" && pwd)"
fi

# Optimistic concurrency (https://here.now/docs#update): when updating a slug
# this state file has published before from this same source path, declare
# that version as the base. The server rejects the update (version_conflict)
# if the live site moved past it — e.g. it was edited from another tool.
# --overwrite skips the check (an unchecked full replacement).
if [[ -n "$SLUG" && "$OVERWRITE" -ne 1 && -f "$STATE_FILE" ]]; then
  stored_version=$("$JQ_BIN" -r --arg s "$SLUG" '.publishes[$s].versionId // empty' "$STATE_FILE" 2>/dev/null || true)
  stored_path=$("$JQ_BIN" -r --arg s "$SLUG" '.publishes[$s].path // empty' "$STATE_FILE" 2>/dev/null || true)
  if [[ -n "$stored_version" && -n "$stored_path" && "$stored_path" == "$TARGET_ABS" ]]; then
    BASE_VERSION_ID="$stored_version"
  fi
fi

compute_sha256() {
  local f="$1"
  if command -v sha256sum >/dev/null 2>&1; then
    sha256sum "$f" | cut -d' ' -f1
  else
    shasum -a 256 "$f" | cut -d' ' -f1
  fi
}

guess_content_type() {
  local f="$1"
  case "${f##*.}" in
    html|htm) echo "text/html; charset=utf-8" ;;
    css)      echo "text/css; charset=utf-8" ;;
    js|mjs)   echo "text/javascript; charset=utf-8" ;;
    json)     echo "application/json; charset=utf-8" ;;
    md|txt)   echo "text/plain; charset=utf-8" ;;
    svg)      echo "image/svg+xml" ;;
    png)      echo "image/png" ;;
    jpg|jpeg) echo "image/jpeg" ;;
    gif)      echo "image/gif" ;;
    webp)     echo "image/webp" ;;
    pdf)      echo "application/pdf" ;;
    mp4)      echo "video/mp4" ;;
    mov)      echo "video/quicktime" ;;
    mp3)      echo "audio/mpeg" ;;
    wav)      echo "audio/wav" ;;
    xml)      echo "application/xml" ;;
    woff2)    echo "font/woff2" ;;
    woff)     echo "font/woff" ;;
    ttf)      echo "font/ttf" ;;
    ico)      echo "image/x-icon" ;;
    *)
      local detected
      detected=$(file --brief --mime-type "$f" 2>/dev/null || echo "application/octet-stream")
      echo "$detected"
      ;;
  esac
}

# Build file manifest as JSON array
FILES_JSON="[]"

if [[ -f "$TARGET" ]]; then
  sz=$(wc -c < "$TARGET" | tr -d ' ')
  ct=$(guess_content_type "$TARGET")
  bn=$(basename "$TARGET")
  h=$(compute_sha256 "$TARGET")
  FILES_JSON=$("$JQ_BIN" -n --arg p "$bn" --argjson s "$sz" --arg c "$ct" --arg h "$h" \
    '[{"path":$p,"size":$s,"contentType":$c,"hash":$h}]')
  FILE_MAP=$("$JQ_BIN" -n --arg p "$bn" --arg a "$(cd "$(dirname "$TARGET")" && pwd)/$(basename "$TARGET")" \
    '{($p):$a}')
elif [[ -d "$TARGET" ]]; then
  FILE_MAP="{}"
  while IFS= read -r -d '' f; do
    rel="${f#$TARGET/}"
    [[ "$rel" == ".DS_Store" ]] && continue
    [[ "$(basename "$rel")" == ".DS_Store" ]] && continue
    [[ "$rel" == ".herenow/fork-meta.json" ]] && continue
    # Local publish state (slug/claim token cache) — never site content.
    [[ "$rel" == ".herenow/state.json" ]] && continue
    sz=$(wc -c < "$f" | tr -d ' ')
    ct=$(guess_content_type "$f")
    h=$(compute_sha256 "$f")
    abs=$(cd "$(dirname "$f")" && pwd)/$(basename "$f")
    FILES_JSON=$(echo "$FILES_JSON" | "$JQ_BIN" --arg p "$rel" --argjson s "$sz" --arg c "$ct" --arg h "$h" \
      '. + [{"path":$p,"size":$s,"contentType":$c,"hash":$h}]')
    FILE_MAP=$(echo "$FILE_MAP" | "$JQ_BIN" --arg p "$rel" --arg a "$abs" '. + {($p):$a}')
  done < <(find "$TARGET" -type f -print0 | sort -z)
else
  die "not a file or directory: $TARGET"
fi

file_count=$(echo "$FILES_JSON" | "$JQ_BIN" 'length')
[[ "$file_count" -gt 0 ]] || die "no files found"

# Build request body
BODY=$(echo "$FILES_JSON" | "$JQ_BIN" '{files: .}')

if [[ -n "$TTL" ]]; then
  BODY=$(echo "$BODY" | "$JQ_BIN" --argjson t "$TTL" '.ttlSeconds = $t')
fi

if [[ -n "$TITLE" || -n "$DESCRIPTION" ]]; then
  viewer="{}"
  [[ -n "$TITLE" ]] && viewer=$(echo "$viewer" | "$JQ_BIN" --arg t "$TITLE" '.title = $t')
  [[ -n "$DESCRIPTION" ]] && viewer=$(echo "$viewer" | "$JQ_BIN" --arg d "$DESCRIPTION" '.description = $d')
  BODY=$(echo "$BODY" | "$JQ_BIN" --argjson v "$viewer" '.viewer = $v')
fi

if [[ -n "$CLAIM_TOKEN" && -n "$SLUG" ]]; then
  BODY=$(echo "$BODY" | "$JQ_BIN" --arg ct "$CLAIM_TOKEN" '.claimToken = $ct')
fi

if [[ "$SPA_MODE" == "true" ]]; then
  BODY=$(echo "$BODY" | "$JQ_BIN" '.spaMode = true')
fi

if [[ -n "$BASE_VERSION_ID" ]]; then
  BODY=$(echo "$BODY" | "$JQ_BIN" --arg v "$BASE_VERSION_ID" '.baseVersionId = $v')
fi

# Determine endpoint and method
if [[ -n "$SLUG" ]]; then
  URL="$BASE_URL/api/v1/publish/$SLUG"
  METHOD="PUT"
else
  URL="$BASE_URL/api/v1/publish"
  METHOD="POST"
fi

# Build auth header
AUTH_ARGS=()
if [[ -n "$API_KEY" ]]; then
  AUTH_ARGS=(-H "authorization: Bearer $API_KEY")
fi

AUTH_MODE="anonymous"
if [[ -n "$API_KEY" ]]; then
  AUTH_MODE="authenticated"
fi

CLIENT_HEADER_VALUE="here-now-publish-sh"
if [[ -n "$CLIENT" ]]; then
  normalized_client=$(echo "$CLIENT" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9._-' '-')
  normalized_client="${normalized_client#-}"
  normalized_client="${normalized_client%-}"
  if [[ -n "$normalized_client" ]]; then
    CLIENT_HEADER_VALUE="${normalized_client}/publish-sh"
  fi
fi
CLIENT_ARGS=(-H "x-herenow-client: $CLIENT_HEADER_VALUE")

# Workspace account selector: sent on create/update and finalize so the Site
# is owned by the workspace (see https://here.now/docs#workspaces).
ACCOUNT_ARGS=()
if [[ -n "$WORKSPACE" ]]; then
  ACCOUNT_ARGS=(-H "x-herenow-account: $WORKSPACE")
fi

# Step 1: Create/update publish
echo "creating publish ($file_count files)..." >&2
RESPONSE=$(curl -sS -X "$METHOD" "$URL" \
  "${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"}" \
  "${CLIENT_ARGS[@]+"${CLIENT_ARGS[@]}"}" \
  "${ACCOUNT_ARGS[@]+"${ACCOUNT_ARGS[@]}"}" \
  -H "content-type: application/json" \
  -d "$BODY")

# Check for errors
if echo "$RESPONSE" | "$JQ_BIN" -e '.error' >/dev/null 2>&1; then
  if [[ "$(echo "$RESPONSE" | "$JQ_BIN" -r '.code // empty')" == "version_conflict" ]]; then
    die_version_conflict "$RESPONSE"
  fi
  err=$(echo "$RESPONSE" | "$JQ_BIN" -r '.error')
  details=$(echo "$RESPONSE" | "$JQ_BIN" -r '.details // empty')
  die "$err${details:+ ($details)}"
fi

OUT_SLUG=$(echo "$RESPONSE" | "$JQ_BIN" -r '.slug')
VERSION_ID=$(echo "$RESPONSE" | "$JQ_BIN" -r '.upload.versionId')
FINALIZE_URL=$(echo "$RESPONSE" | "$JQ_BIN" -r '.upload.finalizeUrl')
SITE_URL=$(echo "$RESPONSE" | "$JQ_BIN" -r '.siteUrl')
UPLOAD_COUNT=$(echo "$RESPONSE" | "$JQ_BIN" '.upload.uploads | length')
SKIPPED_COUNT=$(echo "$RESPONSE" | "$JQ_BIN" '.upload.skipped // [] | length')

[[ "$OUT_SLUG" != "null" ]] || die "unexpected response: $RESPONSE"

# Step 2: Upload files (skipped files are unchanged from previous version)
if [[ "$SKIPPED_COUNT" -gt 0 ]]; then
  echo "uploading $UPLOAD_COUNT files ($SKIPPED_COUNT unchanged, skipped)..." >&2
else
  echo "uploading $UPLOAD_COUNT files..." >&2
fi
upload_errors=0
upload_network_failures=0

# C-style loop: BSD seq counts DOWN for `seq 0 -1`, so a zero-upload
# republish (all files unchanged) used to iterate twice with null paths
# and die between create and finalize, stranding the site in pending.
for ((i = 0; i < UPLOAD_COUNT; i++)); do
  upload_path=$(echo "$RESPONSE" | "$JQ_BIN" -r ".upload.uploads[$i].path")
  upload_url=$(echo "$RESPONSE" | "$JQ_BIN" -r ".upload.uploads[$i].url")
  upload_ct=$(echo "$RESPONSE" | "$JQ_BIN" -r ".upload.uploads[$i].headers[\"Content-Type\"] // empty")

  if [[ -f "$TARGET" && ! -d "$TARGET" ]]; then
    local_file="$TARGET"
  else
    local_file=$(echo "$FILE_MAP" | "$JQ_BIN" -r --arg p "$upload_path" '.[$p]')
  fi

  if [[ ! -f "$local_file" ]]; then
    echo "warning: missing local file for $upload_path" >&2
    upload_errors=$((upload_errors + 1))
    continue
  fi

  ct_args=()
  [[ -n "$upload_ct" ]] && ct_args=(-H "Content-Type: $upload_ct")

  # `|| http_code=000`: a connection-level failure (proxy refusing CONNECT,
  # DNS, TLS) exits curl non-zero, which under set -e would kill the script
  # here with only curl's own message. Fall through so the count and the
  # hint below are reached.
  http_code=$(curl -sS -o /dev/null -w "%{http_code}" -X PUT "$upload_url" \
    "${ct_args[@]+"${ct_args[@]}"}" \
    --data-binary "@$local_file") || http_code="000"

  if [[ "$http_code" -lt 200 || "$http_code" -ge 300 ]]; then
    echo "warning: upload failed for $upload_path (HTTP $http_code)" >&2
    upload_errors=$((upload_errors + 1))
    upload_network_failures=$((upload_network_failures + 1))
  fi
done

if [[ "$upload_errors" -gt 0 ]]; then
  # Every PUT failed while the create call succeeded: the usual cause is an
  # egress allowlist that permits here.now but not the storage host.
  if [[ "$UPLOAD_COUNT" -gt 0 && "$upload_network_failures" -eq "$UPLOAD_COUNT" ]]; then
    echo "hint: every upload failed. Upload URLs PUT directly to *.r2.cloudflarestorage.com, not to here.now;" >&2
    echo "      if this environment restricts outbound network access, allow that host as well as here.now." >&2
  fi
  die "$upload_errors file(s) failed to upload"
fi

# Step 3: Finalize
echo "finalizing..." >&2
FIN_RESPONSE=$(curl -sS -X POST "$FINALIZE_URL" \
  "${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"}" \
  "${CLIENT_ARGS[@]+"${CLIENT_ARGS[@]}"}" \
  "${ACCOUNT_ARGS[@]+"${ACCOUNT_ARGS[@]}"}" \
  -H "content-type: application/json" \
  -d "{\"versionId\":\"$VERSION_ID\"}")

if echo "$FIN_RESPONSE" | "$JQ_BIN" -e '.error' >/dev/null 2>&1; then
  if [[ "$(echo "$FIN_RESPONSE" | "$JQ_BIN" -r '.code // empty')" == "version_conflict" ]]; then
    die_version_conflict "$FIN_RESPONSE"
  fi
  err=$(echo "$FIN_RESPONSE" | "$JQ_BIN" -r '.error')
  die "finalize failed: $err"
fi

# Save state. Merge into the existing entry (never replace it wholesale) so
# a previously saved claimToken survives authenticated republishes.
mkdir -p "$STATE_DIR"
if [[ -f "$STATE_FILE" ]]; then
  STATE=$(cat "$STATE_FILE")
else
  STATE='{"publishes":{}}'
fi

entry=$(echo "$STATE" | "$JQ_BIN" --arg s "$OUT_SLUG" '.publishes[$s] // {}')
entry=$(echo "$entry" | "$JQ_BIN" --arg v "$SITE_URL" '.siteUrl = $v')

# The live version after this publish comes from the finalize response's
# currentVersionId — a byte-identical republish keeps the previous live
# version (unchanged:true), so the staged upload versionId must never be
# saved as the base.
LIVE_VERSION_ID=$(echo "$FIN_RESPONSE" | "$JQ_BIN" -r '.currentVersionId // empty')
[[ -n "$LIVE_VERSION_ID" ]] && entry=$(echo "$entry" | "$JQ_BIN" --arg v "$LIVE_VERSION_ID" '.versionId = $v')
entry=$(echo "$entry" | "$JQ_BIN" --arg v "$TARGET_ABS" '.path = $v')

RESPONSE_CLAIM_TOKEN=$(echo "$RESPONSE" | "$JQ_BIN" -r '.claimToken // empty')
RESPONSE_CLAIM_URL=$(echo "$RESPONSE" | "$JQ_BIN" -r '.claimUrl // empty')
RESPONSE_EXPIRES=$(echo "$RESPONSE" | "$JQ_BIN" -r '.expiresAt // empty')

[[ -n "$RESPONSE_CLAIM_TOKEN" ]] && entry=$(echo "$entry" | "$JQ_BIN" --arg v "$RESPONSE_CLAIM_TOKEN" '.claimToken = $v')
[[ -n "$RESPONSE_CLAIM_URL" ]] && entry=$(echo "$entry" | "$JQ_BIN" --arg v "$RESPONSE_CLAIM_URL" '.claimUrl = $v')
[[ -n "$RESPONSE_EXPIRES" ]] && entry=$(echo "$entry" | "$JQ_BIN" --arg v "$RESPONSE_EXPIRES" '.expiresAt = $v')

STATE=$(echo "$STATE" | "$JQ_BIN" --arg slug "$OUT_SLUG" --argjson e "$entry" '.publishes[$slug] = $e')
echo "$STATE" | "$JQ_BIN" '.' > "$STATE_FILE"

# Workspace label URL (finalize response preferred; create response fallback)
ACCOUNT_URL=$(echo "$FIN_RESPONSE" | "$JQ_BIN" -r '.accountUrl // empty')
if [[ -z "$ACCOUNT_URL" ]]; then
  ACCOUNT_URL=$(echo "$RESPONSE" | "$JQ_BIN" -r '.accountUrl // empty')
fi

# Output
echo "$SITE_URL"

PERSISTENCE="permanent"
if [[ "$AUTH_MODE" == "anonymous" ]]; then
  PERSISTENCE="expires_24h"
elif [[ -n "$RESPONSE_EXPIRES" ]]; then
  PERSISTENCE="expires_at"
fi

SAFE_CLAIM_URL=""
if [[ -n "$RESPONSE_CLAIM_URL" && "$RESPONSE_CLAIM_URL" == https://* ]]; then
  SAFE_CLAIM_URL="$RESPONSE_CLAIM_URL"
fi

ACTION="create"
if [[ -n "$SLUG" ]]; then
  ACTION="update"
fi

echo "" >&2
echo "publish_result.site_url=$SITE_URL" >&2
echo "publish_result.slug=$OUT_SLUG" >&2
echo "publish_result.action=$ACTION" >&2
echo "publish_result.auth_mode=$AUTH_MODE" >&2
echo "publish_result.api_key_source=$API_KEY_SOURCE" >&2
echo "publish_result.persistence=$PERSISTENCE" >&2
echo "publish_result.expires_at=$RESPONSE_EXPIRES" >&2
echo "publish_result.claim_url=$SAFE_CLAIM_URL" >&2
echo "publish_result.account_url=$ACCOUNT_URL" >&2
echo "publish_result.live_version_id=$LIVE_VERSION_ID" >&2

if [[ "$AUTH_MODE" == "authenticated" ]]; then
  echo "authenticated publish (permanent, saved to your account)" >&2
  if [[ -n "$ACCOUNT_URL" ]]; then
    echo "workspace URL: $ACCOUNT_URL" >&2
  fi
else
  echo "anonymous publish (expires in 24h)" >&2
  if [[ -n "$SAFE_CLAIM_URL" ]]; then
    echo "claim URL: $SAFE_CLAIM_URL" >&2
  fi
  if [[ -n "$RESPONSE_CLAIM_TOKEN" ]]; then
    echo "claim token saved to $STATE_FILE" >&2
  fi
fi
SKILL.md
---
name: here-now
description: >
  here.now lets agents publish websites and files to live URLs in seconds.
  Publish HTML, documents, images, PDFs, videos, and static files to live
  URLs at {slug}.here.now or custom domains. Use when asked to "publish
  this", "host this", "deploy this", "share this on the web", "make a
  website", "put this online", "create a webpage", "generate a URL",
  "build a chatbot", "password protect this site", "make this site
  private", or "share this site with only certain people". here.now also
  includes workspaces — shared team accounts where Sites belong to the
  team and serve at {label}.{workspace}.here.now — use when asked to
  "publish this to our team workspace", "share this with my team", or
  "put this in our company workspace".
---

# here.now

**Skill version: 1.28.0**

here.now lets agents publish websites and files to live URLs in seconds.

The core primitive is a **Site**: publish a file or folder and get a live URL at `{slug}.here.now` or a custom domain. Every Site has access control: public link (default), password, or restricted invite-only access.

here.now also includes **workspaces** — shared team accounts where Sites belong to the team and serve at `{label}.{workspace}.here.now` (see "Publish to a workspace" below).

To install or update (recommended): `npx skills add heredotnow/skill --skill here-now -g`

For repo-pinned/project-local installs, run the same command without `-g`.

## Current docs

**Before answering questions about here.now capabilities, features, or workflows, read the current docs:**

→ **https://here.now/docs**

Read the docs:

- at the first here.now-related interaction in a conversation
- any time the user asks how to do something
- any time the user asks what is possible, supported, or recommended
- before telling the user a feature is unsupported

Topics that require current docs (do not rely on local skill text alone):

- Site access control (passwords and restricted access)
- workspaces (team accounts, membership, label URLs)
- Drives and Drive sharing
- custom domains
- Site Data
- public profiles
- proxy routes and service variables
- limits and quotas
- SPA routing
- owner Site search
- Site analytics
- Site version history, previews, and rollback
- error handling and remediation
- feature availability

**If docs and live API behavior disagree, trust the live API behavior.**

Command-line fetches of https://here.now/docs (curl, WebFetch, etc.) receive a markdown summary, not the full HTML docs: it lists every stable public endpoint with a one-line description, but section anchors in this skill (like `/docs#access-control`) resolve only in the HTML version, and worked examples live there too. For complete request/response schemas and parameters, fetch **https://here.now/openapi.json**. Do not conclude an operation is unsupported from the markdown summary alone — check the OpenAPI spec first.

If the docs fetch fails or times out, continue with the local skill and live API/script output. Prefer live API behavior for active operations.

## Requirements

- Required binaries: `curl`, `file`, `jq`
- Required network access: `https://here.now` (API) and `https://*.r2.cloudflarestorage.com` (file uploads PUT directly to storage; the exact host is in each upload URL). In a sandbox or behind a proxy with an egress allowlist, allow BOTH hosts. With only `here.now` allowed, the create call succeeds, every upload fails, and finalize reports missing files.
- Optional environment variable: `$HERENOW_API_KEY`
- Optional Drive token variable: `$HERENOW_DRIVE_TOKEN`
- Optional credentials file: `~/.herenow/credentials`
- Bundled helpers:
  - `./scripts/publish.sh` for publishing sites
  - `./scripts/drive.sh` for private Drive storage

## If the helper scripts aren't installed

Some environments receive this document without the bundled `scripts/` directory (for example, hosted platform integrations that provide only `$HERENOW_API_KEY`). In that case, either install the full bundle first:

```bash
npx skills add heredotnow/skill --skill here-now -g
```

or call the API directly — every script workflow in this document is a wrapper over the public API. Publishing is a three-step flow: `POST /api/v1/publish` with a `files` array (`[{path, size}]`) returns presigned upload targets, `PUT` each file's bytes to its returned URL, then `POST` the returned `finalizeUrl`. The site is not live until finalize succeeds. Full walkthrough with request/response examples: https://here.now/docs#create (then #upload and #finalize), machine-readable schemas: https://here.now/openapi.json.

## Create a site

```bash
./scripts/publish.sh {file-or-dir}
```

Outputs the live URL (e.g. `https://bright-canvas-a7k2.here.now/`).

Under the hood this is a three-step flow: create/update -> upload files -> finalize. A site is not live until finalize succeeds.

Without an API key this creates an **anonymous site** that expires in 24 hours.
With a saved API key, the site is permanent.

**File structure:** For HTML sites, place `index.html` at the root of the directory you publish, not inside a subdirectory. The directory's contents become the site root. For example, publish `my-site/` where `my-site/index.html` exists — don't publish a parent folder that contains `my-site/`.

You can also publish raw files without any HTML. Single files get a rich auto-viewer (images, PDF, video, audio). Multiple files get an auto-generated directory listing with folder navigation and an image gallery.

## Update an existing site

```bash
./scripts/publish.sh {file-or-dir} --slug {slug}
```

The script auto-loads the `claimToken` from `.herenow/state.json` when updating anonymous sites. Pass `--claim-token {token}` to override.

Authenticated updates require a saved API key.

**Stale-base protection.** The live Site may have changed since your local files were published — the owner can edit it from other tools (another agent, the here.now Studio, a teammate). The script records the live `versionId` in `.herenow/state.json` after each publish and sends it as `baseVersionId` on the next update of the same slug from the same directory; if the live Site moved past it, the update is rejected with `code: "version_conflict"` naming the live version and what created it. When that happens, relay the message to the user and offer to (a) read the live files with `GET /api/v1/publish/{slug}/files` (lists them with a `url` each) and `GET /api/v1/publish/{slug}/files/{path}` (the bytes; owner API key, works for password-protected and restricted Sites without the visitor password), reconcile them into the local files, and republish, or (b) re-run with `--overwrite` to replace the live version anyway. Before editing local files for an authenticated Site you haven't touched recently, check for drift first: `GET /api/v1/publish/{slug}` returns `currentVersionId` plus `currentVersionSource` and `currentVersionCreatedAt` (what changed it and when, e.g. `studio`) — if the id differs from your state file's `versionId`, read the live files before editing. The published version is the shared truth; never fetch the public URL to read an owned Site (it is gated for protected Sites) and never ask the user for a visitor password to read their own Site. Anonymous Sites can't call these endpoints; they rely on the saved state and server enforcement. Omitting `baseVersionId` (or using `--overwrite`) is an unchecked full replacement — today's default for raw API callers.

Every publish records an immutable version. If the user asks to see earlier versions of a Site, undo a publish, or roll back: list history with `GET /api/v1/publish/{slug}/versions` and restore instantly with `POST /api/v1/publish/{slug}/versions/{versionId}/restore` (restoring keeps the current access mode, password, and domains). Version access requires a paid plan and is included for workspace Sites; free accounts' history is recorded and unlocks on upgrade. A byte-identical republish returns `unchanged: true` from finalize instead of creating a new version. See https://here.now/docs#versions.

Signed-in users also have public profiles. Agents can help users show or hide Sites on their profile and manage profile settings through the API documented at https://here.now/docs#profile.

## Publish to a workspace

Workspaces are shared team accounts: Sites published into one belong to the team, not the publishing member, and get a memorable URL at `{label}.{workspace}.here.now`.

```bash
./scripts/publish.sh {file-or-dir} --workspace {subdomain}
```

Requires a saved API key and membership in the workspace. List the user's workspaces (and valid subdomains) with `GET /api/v1/accounts`. Workspace Sites default to member-only access; the script reports the team URL as `publish_result.account_url`.

For everything else — creating workspaces, invites and auto-join, workspace domains and variables, label renames — read the current docs:

→ **https://here.now/docs#workspaces**

## Site access control

A Site uses one access mode at a time:

- **anyone_with_link** (default): anyone with the URL can view.
- **password**: visitors must enter a shared password.
- **restricted**: invite-only; only verified email addresses or email domains the owner allows can view.

Workspace-owned Sites default to **account_members** (visitors sign in and must be workspace members) and also support public, public with a password, and **restricted**. On a workspace Site, `restricted` means workspace members plus a per-Site guest allowlist: members always have access, and allowlisted emails/domains are outside guests who can view only that Site — they never become workspace members, though the Site appears in the guest's own dashboard as a shared Site. Workspace restricted requires at least one guest email or domain — an empty allowlist is rejected with a 400 (use `account_members` for members-only). See https://here.now/docs#workspace-access.

Manage access with `GET`/`PATCH /api/v1/publish/{slug}/access` (passwords via the metadata endpoint). Restricted access requires a claimed Site. The PATCH replaces the full allowlists — read, merge, then write. Before working with access control, read the current docs:

→ **https://here.now/docs#access-control**

## Use a Drive

Use a Drive when the user wants private cloud storage for agent files: documents, context, memory, plans, assets, media, research, code, and anything else that should persist without being published as a website.

Every signed-in account has a default Drive named `My Drive`.

```bash
./scripts/drive.sh default
./scripts/drive.sh ls My Drive
./scripts/drive.sh put My Drive notes/today.md --from ./notes/today.md
./scripts/drive.sh cat My Drive notes/today.md
./scripts/drive.sh share My Drive --perms write --prefix notes/ --ttl 7d
```

Use scoped Drive tokens for agent-to-agent handoff. If you receive a `herenow_drive` share block, use its `token` as `Authorization: Bearer <token>` against `api_base`, respect `pathPrefix` when present, and preserve ETags on writes. A `pathPrefix` of `null` means full-Drive access. If the skill is available, prefer `./scripts/drive.sh`; otherwise call the listed API operations directly.

## Client attribution

Pass `--client` with the name of the **agent product or harness you are running in** — `cursor`, `claude-code`, `codex`, `grok-bot`, `openclaw`, `gemini`, etc:

```bash
./scripts/publish.sh {file-or-dir} --client claude-code
```

This sends `X-HereNow-Client: claude-code/publish-sh` on publish API calls. If omitted, the script sends a fallback value.

Use the platform's name, **not** the name you were given inside it. If you are a bot named "research-bot" running inside Grok Bot, the correct value is `grok-bot` — not `research-bot`. Bot names, personas, sub-agents, projects, and thread names don't identify the platform. To record your instance name too, append it after a slash:

```bash
./scripts/publish.sh {file-or-dir} --client grok-bot/research-bot
```

Only a standalone agent running in no harness should use its own product name.

## API key storage

The publish script reads the API key from these sources (first match wins):

1. `--api-key {key}` flag (CI/scripting only — avoid in interactive use)
2. `$HERENOW_API_KEY` environment variable
3. `~/.herenow/credentials` file (recommended for agents)

To store a key, write it to the credentials file:

```bash
mkdir -p ~/.herenow && echo "{API_KEY}" > ~/.herenow/credentials && chmod 600 ~/.herenow/credentials
```

**IMPORTANT**: After receiving an API key, save it immediately — run the command above yourself. Do not ask the user to run it manually. Avoid passing the key via CLI flags (e.g. `--api-key`) in interactive sessions; the credentials file is the preferred storage method.

Never commit credentials or local state files (`~/.herenow/credentials`, `.herenow/state.json`) to source control.

## Getting an API key

To upgrade from anonymous (24h) to permanent sites:

1. Ask the user for their email address.
2. Request a one-time sign-in code:

```bash
curl -sS https://here.now/api/auth/agent/request-code \
  -H "content-type: application/json" \
  -d '{"email": "[email protected]"}'
```

3. Tell the user: "Check your inbox for a sign-in code from here.now and paste it here."
4. Verify the code and get the API key:

```bash
curl -sS https://here.now/api/auth/agent/verify-code \
  -H "content-type: application/json" \
  -d '{"email":"[email protected]","code":"ABCD-2345"}'
```

5. Save the returned `apiKey` yourself (do not ask the user to do this):

```bash
mkdir -p ~/.herenow && echo "{API_KEY}" > ~/.herenow/credentials && chmod 600 ~/.herenow/credentials
```

## State file

After every site create/update, the script writes to `.herenow/state.json` in the working directory:

```json
{
  "publishes": {
    "bright-canvas-a7k2": {
      "siteUrl": "https://bright-canvas-a7k2.here.now/",
      "claimToken": "4fQ9tK2mXb7cW1pZ",
      "claimUrl": "https://here.now/c/4fQ9tK2mXb7cW1pZ",
      "expiresAt": "2026-02-18T01:00:00.000Z"
    }
  }
}
```

Before creating or updating sites, you may check this file to find prior slugs.
Treat `.herenow/state.json` as internal cache only.
Never present this local file path as a URL, and never use it as source of truth for auth mode, expiry, or claim URL.

## What to tell the user

For published sites:

- Always share the `siteUrl` from the current script run.
- Put the site URL on its own line with nothing else on that line — no punctuation, dashes, or status text after it (chat clients autolink everything up to whitespace, gluing your words into the URL). Status details like "permanent, saved to your account" go on the following line.
- Read and follow `publish_result.*` lines from script stderr to determine auth mode.
- When `publish_result.account_url` is non-empty (workspace publishes), share it as the primary team URL alongside `siteUrl`.
- When `publish_result.auth_mode=authenticated`: tell the user the site is **permanent** and saved to their account. No claim URL is needed.
- When `publish_result.auth_mode=anonymous`: tell the user the site **expires in 24 hours**. Share the claim URL (if `publish_result.claim_url` is non-empty and starts with `https://`) so they can keep it permanently. Copy it byte-for-byte as a clickable link — never shorten, redact, summarize, or replace any part of it with `...`; a modified claim link will not work. Warn that claim tokens are only returned once and cannot be recovered.
- Never tell the user to inspect `.herenow/state.json` for claim URLs or auth status.

For Drives:

- Do not describe Drive files as public URLs.
- Tell the user Drive contents are private unless shared with a scoped token.
- When sharing access with another agent, prefer a scoped token with a narrow `pathPrefix` and short TTL.

## publish.sh options

| Flag                   | Description                                  |
| ---------------------- | -------------------------------------------- |
| `--slug {slug}`        | Update an existing site instead of creating |
| `--workspace {subdomain}` | Publish into a workspace (team account) you belong to |
| `--claim-token {token}`| Override claim token for anonymous updates    |
| `--overwrite`          | Skip the stale-base check and replace the live version |
| `--title {text}`       | Viewer title (non-HTML sites)             |
| `--description {text}` | Viewer description                            |
| `--ttl {seconds}`      | Set expiry (authenticated only)               |
| `--client {name}`      | Agent harness for attribution — the platform you run in (e.g. `cursor`, `grok-bot`), not your bot/persona name; optionally append it: `grok-bot/research-bot` |
| `--base-url {url}`     | API base URL (default: `https://here.now`)    |
| `--allow-nonherenow-base-url` | Allow sending auth to non-default `--base-url` |
| `--api-key {key}`      | API key override (prefer credentials file)    |
| `--spa`                | Enable SPA routing (serve index.html for unknown paths) |

## Beyond publish.sh

For Drive operations, use `./scripts/drive.sh` or the Drive API. For broader account and Site management — Site Data, search, analytics, profiles, delete, metadata, access control, domains, variables, proxy routes, duplication, and more — see the current docs:

→ **https://here.now/docs**

Full docs: https://here.now/docs