From 675d99b09856c053d18cbdffcfe814cecde799ad Mon Sep 17 00:00:00 2001 From: adamludwin Date: Thu, 23 Apr 2026 15:49:16 -0700 Subject: [PATCH 1/3] feat(skills): add built-in here.now skill Add the here.now productivity skill with a bundled publish runtime so Hermes can publish files and folders to live URLs. Keep the skill thin and docs-first while fixing script path resolution and upload failure handling. Made-with: Cursor --- skills/productivity/here-now/SKILL.md | 190 +++++++++ .../productivity/here-now/scripts/publish.sh | 384 ++++++++++++++++++ 2 files changed, 574 insertions(+) create mode 100644 skills/productivity/here-now/SKILL.md create mode 100755 skills/productivity/here-now/scripts/publish.sh diff --git a/skills/productivity/here-now/SKILL.md b/skills/productivity/here-now/SKILL.md new file mode 100644 index 0000000000000..11feb0e6e988c --- /dev/null +++ b/skills/productivity/here-now/SKILL.md @@ -0,0 +1,190 @@ +--- +name: here-now +description: > + Publish files and folders to the web instantly. Static hosting for HTML sites, + images, PDFs, and any file type. Sites can connect to external APIs (LLMs, + databases, email, payments) via proxy routes with server-side credential + injection. Use when asked to "publish this", "host this", "deploy this", + "share this on the web", "make a website", "put this online", "upload to + the web", "create a webpage", "share a link", "serve this site", "generate + a URL", or "build a chatbot". Outputs a live, shareable URL at {slug}.here.now. +version: 1.14.0 +author: here.now +license: MIT +prerequisites: + commands: [curl, file, jq] +platforms: [macos, linux] +metadata: + hermes: + tags: [here.now, herenow, publish, deploy, hosting, static-site, web, share, URL] + homepage: https://here.now + requires_toolsets: [terminal] +--- + +# here.now + +Create a live URL from any file or folder. Static hosting with optional proxy routes for calling external APIs server-side. + +## 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): + +- custom domains +- payments and payment gating +- forking +- proxy routes and service variables +- handles and links +- limits and quotas +- SPA routing +- 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 credentials file: `~/.herenow/credentials` +- Skill script path: `${HERMES_SKILL_DIR}/scripts/publish.sh` + +## Create a site + +```bash +PUBLISH="${HERMES_SKILL_DIR}/scripts/publish.sh" +bash "$PUBLISH" {file-or-dir} --client hermes +``` + +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 +PUBLISH="${HERMES_SKILL_DIR}/scripts/publish.sh" +bash "$PUBLISH" {file-or-dir} --slug {slug} --client hermes +``` + +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. + +## 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": "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: + +```bash +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): + +```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": "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 + +- Always share the `siteUrl` from 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 (if `publish_result.claim_url` is non-empty and starts with `https://`) 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.json` for claim URLs or auth status. + +## Script 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. `hermes`) | +| `--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) | +| `--forkable` | Allow others to fork this site | + +## Beyond the script + +For all other operations — delete, metadata, passwords, payments, domains, handles, links, variables, proxy routes, forking, duplication, and more — see the current docs: + +→ **https://here.now/docs** + +Full docs: https://here.now/docs diff --git a/skills/productivity/here-now/scripts/publish.sh b/skills/productivity/here-now/scripts/publish.sh new file mode 100755 index 0000000000000..c52ce9dd03587 --- /dev/null +++ b/skills/productivity/here-now/scripts/publish.sh @@ -0,0 +1,384 @@ +#!/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="" +FORKABLE="" +SPA_MODE="" + +usage() { + cat <<'USAGE' +Usage: publish.sh [options] + +Options: + --api-key API key (or set $HERENOW_API_KEY) + --slug Update existing publish + --claim-token Claim token for anonymous updates + --title Viewer title + --description Viewer description + --ttl Expiry (authenticated only) + --client Agent name for attribution (e.g. cursor, claude-code) + --forkable Allow others to fork this site + --spa Enable SPA routing + --base-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 ;; + --forkable) FORKABLE="true"; shift ;; + --spa) SPA_MODE="true"; shift ;; + --help|-h) usage ;; + -*) die "unknown option: $1" ;; + *) [[ -z "$TARGET" ]] && TARGET="$1" || die "unexpected argument: $1"; shift ;; + esac +done + +[[ -n "$TARGET" ]] || usage +[[ -e "$TARGET" ]] || die "path does not exist: $TARGET" + +# 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 anonymous updates +if [[ -n "$SLUG" && -z "$CLAIM_TOKEN" && -z "$API_KEY" && -f "$STATE_FILE" ]]; then + CLAIM_TOKEN=$("$JQ_BIN" -r --arg s "$SLUG" '.publishes[$s].claimToken // empty' "$STATE_FILE" 2>/dev/null || true) +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" + +# Read fork-meta.json defaults if present and no explicit flags given +FORK_META="" +if [[ -d "$TARGET" ]]; then + FORK_META_PATH="$TARGET/.herenow/fork-meta.json" + if [[ -f "$FORK_META_PATH" ]]; then + FORK_META=$(cat "$FORK_META_PATH") + if [[ -z "$FORKABLE" ]]; then + FORKABLE=$("$JQ_BIN" -r '.forkable // empty' <<< "$FORK_META" 2>/dev/null || true) + fi + fi +fi + +# 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" && -z "$API_KEY" ]]; then + BODY=$(echo "$BODY" | "$JQ_BIN" --arg ct "$CLAIM_TOKEN" '.claimToken = $ct') +fi + +if [[ "$FORKABLE" == "true" ]]; then + BODY=$(echo "$BODY" | "$JQ_BIN" '.forkable = true') +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 From 18ebb5f8f655c047704f5e081fff27f70e34671c Mon Sep 17 00:00:00 2001 From: adamludwin Date: Tue, 28 Apr 2026 22:07:21 -0700 Subject: [PATCH 2/3] Update here.now skill bundle Made-with: Cursor --- skills/productivity/here-now/SKILL.md | 67 ++- skills/productivity/here-now/scripts/drive.sh | 406 ++++++++++++++++++ .../productivity/here-now/scripts/publish.sh | 65 ++- 3 files changed, 521 insertions(+), 17 deletions(-) create mode 100755 skills/productivity/here-now/scripts/drive.sh diff --git a/skills/productivity/here-now/SKILL.md b/skills/productivity/here-now/SKILL.md index 11feb0e6e988c..f1491df3fa0fe 100644 --- a/skills/productivity/here-now/SKILL.md +++ b/skills/productivity/here-now/SKILL.md @@ -1,14 +1,17 @@ --- -name: here-now +name: here.now description: > - Publish files and folders to the web instantly. Static hosting for HTML sites, - images, PDFs, and any file type. Sites can connect to external APIs (LLMs, - databases, email, payments) via proxy routes with server-side credential - injection. Use when asked to "publish this", "host this", "deploy this", - "share this on the web", "make a website", "put this online", "upload to - the web", "create a webpage", "share a link", "serve this site", "generate - a URL", or "build a chatbot". Outputs a live, shareable URL at {slug}.here.now. -version: 1.14.0 + here.now lets agents publish websites and store private files in cloud + Drives. 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 tools. 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", "save this + to my Drive", "store this for later", "write this to cloud storage", "share a + folder with another agent", or "use my here.now Drive". +version: 1.15.3 author: here.now license: MIT prerequisites: @@ -16,14 +19,19 @@ prerequisites: platforms: [macos, linux] metadata: hermes: - tags: [here.now, herenow, publish, deploy, hosting, static-site, web, share, URL] + tags: [here.now, herenow, publish, deploy, hosting, static-site, web, share, URL, drive, storage] homepage: https://here.now requires_toolsets: [terminal] --- # here.now -Create a live URL from any file or folder. Static hosting with optional proxy routes for calling external APIs server-side. +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. ## Current docs @@ -40,6 +48,7 @@ Read the docs: Topics that require current docs (do not rely on local skill text alone): +- Drives and Drive sharing - custom domains - payments and payment gating - forking @@ -58,8 +67,11 @@ If the docs fetch fails or times out, continue with the local skill and live API - Required binaries: `curl`, `file`, `jq` - Optional environment variable: `$HERENOW_API_KEY` +- Optional Drive token variable: `$HERENOW_DRIVE_TOKEN` - Optional credentials file: `~/.herenow/credentials` -- Skill script path: `${HERMES_SKILL_DIR}/scripts/publish.sh` +- Skill helper paths: + - `${HERMES_SKILL_DIR}/scripts/publish.sh` for publishing sites + - `${HERMES_SKILL_DIR}/scripts/drive.sh` for private Drive storage ## Create a site @@ -90,6 +102,23 @@ The script auto-loads the `claimToken` from `.herenow/state.json` when updating Authenticated updates require a saved API key. +## 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 +DRIVE="${HERMES_SKILL_DIR}/scripts/drive.sh" +bash "$DRIVE" default +bash "$DRIVE" ls "My Drive" +bash "$DRIVE" put "My Drive" notes/today.md --from ./notes/today.md +bash "$DRIVE" cat "My Drive" notes/today.md +bash "$DRIVE" 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 ` 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 `drive.sh`; otherwise call the listed API operations directly. + ## API key storage The publish script reads the API key from these sources (first match wins): @@ -159,13 +188,21 @@ Never present this local file path as a URL, and never use it as source of truth ## What to tell the user +For published sites: + - Always share the `siteUrl` from 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 (if `publish_result.claim_url` is non-empty and starts with `https://`) 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.json` for claim URLs or auth status. -## Script options +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 | | ---------------------- | -------------------------------------------- | @@ -181,9 +218,9 @@ Never present this local file path as a URL, and never use it as source of truth | `--spa` | Enable SPA routing (serve index.html for unknown paths) | | `--forkable` | Allow others to fork this site | -## Beyond the script +## Beyond publish.sh -For all other operations — delete, metadata, passwords, payments, domains, handles, links, variables, proxy routes, forking, duplication, and more — see the current docs: +For Drive operations, use `drive.sh` or the Drive API. For broader account and site management — delete, metadata, passwords, payments, domains, handles, links, variables, proxy routes, forking, duplication, and more — see the current docs: → **https://here.now/docs** diff --git a/skills/productivity/here-now/scripts/drive.sh b/skills/productivity/here-now/scripts/drive.sh new file mode 100755 index 0000000000000..872a3d209783d --- /dev/null +++ b/skills/productivity/here-now/scripts/drive.sh @@ -0,0 +1,406 @@ +#!/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] [args] + +Global options: + --api-key Account API key (or $HERENOW_API_KEY / ~/.herenow/credentials) + --token Drive token (or $HERENOW_DRIVE_TOKEN) + --base-url API base (default: https://here.now) + --allow-nonherenow-base-url + +Commands: + create [name] [--default] + default + ls + ls [prefix] + cat + put --from + import --from [--dry-run] + export --to [--dry-run] + rm [--recursive --confirm ] + share --perms read|write [--prefix notes/] [--ttl 30d] [--label text] [--manage-tokens] + tokens + revoke + delete --confirm "" +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 " + 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 --from " + put_file "$@" + ;; + import) + [[ $# -ge 2 ]] || die "usage: drive.sh import --from [--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 --to [--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 [--recursive --confirm ]" + 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 --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 " + id=$(resolve_drive "$1") + api_json GET "$BASE_URL/api/v1/drives/$id/tokens" | "$JQ_BIN" . + ;; + revoke) + [[ $# -eq 2 ]] || die "usage: drive.sh revoke " + 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 --confirm " + 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 diff --git a/skills/productivity/here-now/scripts/publish.sh b/skills/productivity/here-now/scripts/publish.sh index c52ce9dd03587..f8f0b909e5876 100755 --- a/skills/productivity/here-now/scripts/publish.sh +++ b/skills/productivity/here-now/scripts/publish.sh @@ -18,6 +18,8 @@ CLIENT="" TARGET="" FORKABLE="" SPA_MODE="" +FROM_DRIVE="" +DRIVE_VERSION="" usage() { cat <<'USAGE' @@ -33,6 +35,8 @@ Options: --client Agent name for attribution (e.g. cursor, claude-code) --forkable Allow others to fork this site --spa Enable SPA routing + --from-drive Publish a Drive snapshot instead of local files + --version Drive version for --from-drive (default: current head) --base-url API base (default: https://here.now) --allow-nonherenow-base-url Allow auth requests to non-default API base URL @@ -71,14 +75,20 @@ while [[ $# -gt 0 ]]; do --allow-nonherenow-base-url) ALLOW_NON_HERENOW_BASE_URL=1; shift ;; --forkable) FORKABLE="true"; 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 -[[ -n "$TARGET" ]] || usage -[[ -e "$TARGET" ]] || die "path does not exist: $TARGET" +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 @@ -100,6 +110,57 @@ if [[ -n "$SLUG" && -z "$CLAIM_TOKEN" && -z "$API_KEY" && -f "$STATE_FILE" ]]; t 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 + [[ "$FORKABLE" == "true" ]] && BODY=$(echo "$BODY" | "$JQ_BIN" '.forkable = true') + [[ "$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 From 2cdf887d4c20e24f99e2bc79fb0ff9f65808430c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 30 Apr 2026 19:45:41 -0700 Subject: [PATCH 3/3] feat(skills): add here.now as an optional skill Moves the here-now skill under optional-skills/productivity/here-now/ so it's discoverable via the Skills Hub but not installed by default, and tightens the SKILL.md description to a single line to match sibling optional-skill descriptions. Install with: hermes skills install official/productivity/here-now Closes #378 --- .../productivity/here-now/SKILL.md | 12 +----------- .../productivity/here-now/scripts/drive.sh | 0 .../productivity/here-now/scripts/publish.sh | 0 scripts/release.py | 1 + 4 files changed, 2 insertions(+), 11 deletions(-) rename {skills => optional-skills}/productivity/here-now/SKILL.md (91%) rename {skills => optional-skills}/productivity/here-now/scripts/drive.sh (100%) rename {skills => optional-skills}/productivity/here-now/scripts/publish.sh (100%) diff --git a/skills/productivity/here-now/SKILL.md b/optional-skills/productivity/here-now/SKILL.md similarity index 91% rename from skills/productivity/here-now/SKILL.md rename to optional-skills/productivity/here-now/SKILL.md index f1491df3fa0fe..bbb07b0a4e52f 100644 --- a/skills/productivity/here-now/SKILL.md +++ b/optional-skills/productivity/here-now/SKILL.md @@ -1,16 +1,6 @@ --- name: here.now -description: > - here.now lets agents publish websites and store private files in cloud - Drives. 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 tools. 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", "save this - to my Drive", "store this for later", "write this to cloud storage", "share a - folder with another agent", or "use my here.now Drive". +description: Publish static sites to {slug}.here.now and store private files in cloud Drives for agent-to-agent handoff. version: 1.15.3 author: here.now license: MIT diff --git a/skills/productivity/here-now/scripts/drive.sh b/optional-skills/productivity/here-now/scripts/drive.sh similarity index 100% rename from skills/productivity/here-now/scripts/drive.sh rename to optional-skills/productivity/here-now/scripts/drive.sh diff --git a/skills/productivity/here-now/scripts/publish.sh b/optional-skills/productivity/here-now/scripts/publish.sh similarity index 100% rename from skills/productivity/here-now/scripts/publish.sh rename to optional-skills/productivity/here-now/scripts/publish.sh diff --git a/scripts/release.py b/scripts/release.py index 863123652401b..afed77a69bc9f 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ "leone.parise@gmail.com": "leoneparise", "teknium@nousresearch.com": "teknium1", "127238744+teknium1@users.noreply.github.com": "teknium1", + "aludwin+gh@gmail.com": "adamludwin", "2093036+exiao@users.noreply.github.com": "exiao", "rylen.anil@gmail.com": "rylena", "14046872+tmimmanuel@users.noreply.github.com": "tmimmanuel",