
Here Now
- 11.7k installs
- 36 repo stars
- Updated July 21, 2026
- heredotnow/skill
Two-in-one service: (1) Sites publish web content to live URLs with access control; (2) Drives store private agent files in cloud folders.
About
here.now is a two-in-one platform for agents: Sites publish HTML, documents, images, PDFs, and static files to live URLs (e.g., {slug}.here.now or custom domains) with public, password, or invite-only access modes; Drives provide private cloud folders for persistent file storage (context, memory, plans, assets, code) with scoped token sharing across agent sessions. Developers use Sites when asked to host, deploy, or share content online; they use Drives for agent-to-agent handoffs and stateful file persistence. Core workflows: publish via ./scripts/publish.sh with optional API key for permanent sites, manage access control via REST endpoints, and share Drive tokens for cross-agent collaboration with fine-grained path and TTL controls.
- Publish HTML sites and raw files to live URLs; single files get auto-viewer (images, PDF, video), multiple files get aut
- Three access modes per site: public link (default), password-protected, or restricted invite-only with email allowlists
- Drives: private cloud folders for agent files (documents, context, memory, plans) with scoped token sharing and ETags fo
- Anonymous sites expire in 24h; claim token workflow enables users to make permanent; authenticated sites persist under s
- Client attribution tracking (--client flag) and full API coverage for Site Data, analytics, custom domains, SPA routing,
Here Now by the numbers
- 11,733 all-time installs (skills.sh)
- +491 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #19 of 1,453 DevOps & CI/CD skills by installs in the Skillselion catalog
- Security screen: HIGH risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
here-now capabilities & compatibility
Free tier available; authenticated sites require sign-in.
- Capabilities
- publish html and static files to live urls · auto view single files (image, pdf, video, audio · auto generate directory listing with gallery · access control: public, password, invite only · store private agent files in cloud drives · share drive folders via scoped tokens · persist agent state and context across sessions · custom domain and subdomain support
- Platforms
- macOS · Linux · Windows
- Runs
- Hosted SaaS
- Pricing
- Freemium
What here-now says it does
Use Sites to publish HTML, documents, images, PDFs, videos, and static files to live URLs at {slug}.here.now or custom domains.
Use Drives as private cloud folders where agents can store files (documents, context, memory, plans, assets, media, research, code, etc), share them with other agents, and continue across sessions and
npx skills add https://github.com/heredotnow/skill --skill here-nowAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 11.7k |
|---|---|
| repo stars | ★ 36 |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 21, 2026 |
| Repository | heredotnow/skill ↗ |
What it does
Publish websites and store private agent files in cloud storage with access control.
Who is it for?
Publishing static sites, auto-viewing files, sharing private agent context and memory across sessions, building chatbots with persistent state, agent collaboration.
Skip if: Dynamic server-side rendering, databases, complex backend logic, long-term archival without account, non-agent users managing sites via UI.
When should I use this skill?
User asks to publish, host, deploy, share content online, store files in cloud, save for later, share folders between agents, password-protect sites, or restrict access.
What you get
Sites live at {slug}.here.now or custom domains with public/password/invite access; Drives enable private file persistence and agent-to-agent handoff via scoped tokens.
- Uploaded drive files
- Drive listings
- Downloaded file contents
By the numbers
- Anonymous sites expire in 24 hours without API key.
- Site access modes: anyone_with_link (default), password, or restricted (invite-only).
- Drives support scoped tokens with pathPrefix, perms (read/write), and TTL controls.
Files
here.now
Skill version: 1.16.0
here.now lets agents publish websites and store private files in cloud Drives.
Use here.now for two jobs:
- Sites: publish websites and files at
{slug}.here.now. - Drives: store private agent files in cloud folders.
Every Site has access control: public link (default), password, or restricted invite-only access.
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)
- Drives and Drive sharing
- custom domains
- Site Data
- public profiles
- proxy routes and service variables
- subdomain handles and links
- limits and quotas
- SPA routing
- owner Site search
- Site analytics
- error handling and remediation
- feature availability
If docs and live API behavior disagree, trust the live API behavior.
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 - Optional environment variable:
$HERENOW_API_KEY - Optional Drive token variable:
$HERENOW_DRIVE_TOKEN - Optional credentials file:
~/.herenow/credentials - Bundled helpers:
./scripts/publish.shfor publishing sites./scripts/drive.shfor private Drive storage
Create a site
./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
./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.
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.
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.
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.
./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 7dUse 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 so here.now can track reliability by agent:
./scripts/publish.sh {file-or-dir} --client cursorThis sends X-HereNow-Client: cursor/publish-sh on publish API calls. If omitted, the script sends a fallback value.
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:
mkdir -p ~/.herenow && echo "{API_KEY}" > ~/.herenow/credentials && chmod 600 ~/.herenow/credentialsIMPORTANT: 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:
curl -sS https://here.now/api/auth/agent/request-code \
-H "content-type: application/json" \
-d '{"email": "user@example.com"}'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:
curl -sS https://here.now/api/auth/agent/verify-code \
-H "content-type: application/json" \
-d '{"email":"user@example.com","code":"ABCD-2345"}'5. Save the returned apiKey yourself (do not ask the user to do this):
mkdir -p ~/.herenow && echo "{API_KEY}" > ~/.herenow/credentials && chmod 600 ~/.herenow/credentialsState file
After every site create/update, the script writes to .herenow/state.json in the working directory:
{
"publishes": {
"bright-canvas-a7k2": {
"siteUrl": "https://bright-canvas-a7k2.here.now/",
"claimToken": "abc123",
"claimUrl": "https://here.now/claim?slug=bright-canvas-a7k2&token=abc123",
"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
siteUrlfrom the current script run. - Read and follow
publish_result.*lines from script stderr to determine auth mode. - 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 (ifpublish_result.claim_urlis non-empty and starts withhttps://) so they can keep it permanently. Warn that claim tokens are only returned once and cannot be recovered. - Never tell the user to inspect
.herenow/state.jsonfor 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
pathPrefixand short TTL.
publish.sh options
| Flag | Description |
|---|---|
--slug {slug} | Update an existing site instead of creating |
--claim-token {token} | Override claim token for anonymous updates |
--title {text} | Viewer title (non-HTML sites) |
--description {text} | Viewer description |
--ttl {seconds} | Set expiry (authenticated only) |
--client {name} | Agent name for attribution (e.g. cursor) |
--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, subdomain handles, links, variables, proxy routes, duplication, and more — see the current docs:
→ https://here.now/docs
Full docs: https://here.now/docs
#!/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=$(curl -sS -o /dev/null -w "%{http_code}" -X PUT "$upload_url" -H "Content-Type: $ct" --data-binary "@$local_file")
[[ "$http_code" -ge 200 && "$http_code" -lt 300 ]] || die "upload failed for $path (HTTP $http_code)"
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
#!/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=""
CLAIM_TOKEN=""
TITLE=""
DESCRIPTION=""
TTL=""
CLIENT=""
TARGET=""
SPA_MODE=""
FROM_DRIVE=""
DRIVE_VERSION=""
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
--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)
--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; }
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 ;;
--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 ;;
--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"
# 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
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
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
# 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")
# 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[@]}"}" \
-H "content-type: application/json" \
-d "$BODY")
# Check for errors
if echo "$RESPONSE" | "$JQ_BIN" -e '.error' >/dev/null 2>&1; then
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
for i in $(seq 0 $((UPLOAD_COUNT - 1))); 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=$(curl -sS -o /dev/null -w "%{http_code}" -X PUT "$upload_url" \
"${ct_args[@]+"${ct_args[@]}"}" \
--data-binary "@$local_file")
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))
fi
done
[[ "$upload_errors" -eq 0 ]] || die "$upload_errors file(s) failed to upload"
# Step 3: Finalize
echo "finalizing..." >&2
FIN_RESPONSE=$(curl -sS -X POST "$FINALIZE_URL" \
"${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"}" \
"${CLIENT_ARGS[@]+"${CLIENT_ARGS[@]}"}" \
-H "content-type: application/json" \
-d "{\"versionId\":\"$VERSION_ID\"}")
if echo "$FIN_RESPONSE" | "$JQ_BIN" -e '.error' >/dev/null 2>&1; then
err=$(echo "$FIN_RESPONSE" | "$JQ_BIN" -r '.error')
die "finalize failed: $err"
fi
# Save state
mkdir -p "$STATE_DIR"
if [[ -f "$STATE_FILE" ]]; then
STATE=$(cat "$STATE_FILE")
else
STATE='{"publishes":{}}'
fi
entry=$("$JQ_BIN" -n --arg s "$SITE_URL" '{siteUrl: $s}')
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"
# 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
if [[ "$AUTH_MODE" == "authenticated" ]]; then
echo "authenticated publish (permanent, saved to your account)" >&2
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
Related skills
How it compares
Use here-now when your workflow already uses Here.Now drives and you need bash-native file ops instead of manual dashboard uploads.
FAQ
How do I make a site permanent instead of expiring in 24 hours?
Request sign-in code via email, verify code to get API key, save to ~/.herenow/credentials. Authenticated publishes with --api-key or env var persist permanently.
Can I share a Drive folder with another agent?
Yes: use ./scripts/drive.sh share with --perms, --prefix (path scope), --ttl. Receive scoped token as herenow_drive block; use token as Bearer auth against api_base.
What files can I publish?
Any file type: HTML, images, PDFs, videos, audio, markdown, raw files. Single files get auto-viewer; multiple files get auto-generated directory listing with navigation.
Is Here Now safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.