diff --git a/.gitignore b/.gitignore index 34a77e5..9e8ca00 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ state/ *.log logs/ +# Local operator state (S7 connect state — gitignored) +.local/ + # Python .venv/ venv/ diff --git a/Makefile b/Makefile index 24588ee..63214bc 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,9 @@ # Approved structure: document targets only; do not invoke unimplemented scripts. .PHONY: help bootstrap install install-s0-s2 install-s3-s5 upgrade doctor verify sync-design \ - install-s0b install-s1 install-s2 install-s3 install-s4 install-s5 + install-s0b install-s1 install-s2 install-s3 install-s4 install-s5 \ + connect connect-status connect-name connect-channels connect-square \ + connect-quickbooks connect-vagaro connect-all help: @echo "Salon_Assistant (Lumina) — $(shell cat VERSION 2>/dev/null || echo 'unknown')" @@ -23,6 +25,16 @@ help: @echo " make install-s4 - S4 only: sandbox verify/onboard" @echo " make install-s5 - S5 only: policy overlays + skills sync" @echo "" + @echo "S7: Connect (operator helpers — default --dry-run):" + @echo " make connect - show connect help" + @echo " make connect-status - show connection status" + @echo " make connect-name - name / profile setup" + @echo " make connect-channels - connect messaging channels" + @echo " make connect-square - connect Square (remote MCP)" + @echo " make connect-quickbooks - connect QuickBooks Online (local MCP)" + @echo " make connect-vagaro - connect Vagaro (REST + webhooks)" + @echo " make connect-all - walk all targets" + @echo "" @echo "Not yet implemented (stubbed):" @echo " make upgrade - product upgrade" @echo " make sync-design - list design pack paths" @@ -66,6 +78,32 @@ install-s5: doctor: @bash scripts/doctor.sh +# ── S7: Connect targets ──────────────────────────────────────────────────── + +connect: + @bash scripts/connect.sh --help + +connect-status: + @bash scripts/connect.sh status + +connect-name: + @bash scripts/connect.sh name --dry-run + +connect-channels: + @bash scripts/connect.sh channels --dry-run + +connect-square: + @bash scripts/connect.sh square --dry-run + +connect-quickbooks: + @bash scripts/connect.sh quickbooks --dry-run + +connect-vagaro: + @bash scripts/connect.sh vagaro --dry-run + +connect-all: + @bash scripts/connect.sh all --dry-run + # ── Stubbed targets ──────────────────────────────────────────────────────── upgrade: diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 596837a..90d46f5 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -1,6 +1,6 @@ # Install -**Status:** Stages S0–S6 implemented. S7 pending. +**Status:** Stages S0–S7 implemented. ## Stages @@ -14,7 +14,7 @@ | S4 | Host script | Sandbox verify (attach) or onboard (clean host) | ✅ Implemented | | S5 | Host script | Policy overlays + skills sync via nemohermes | ✅ Implemented | | S6 | Host script | Doctor green | ✅ Implemented | -| S7 | Owner + operator connect helpers | Name assistant; connect **their** SaaS/channels | ⏳ Pending | +| S7 | Owner + operator connect helpers | Name assistant; connect **their** SaaS/channels | ✅ Implemented | ## Platform commands (normative) @@ -165,6 +165,91 @@ make install-s3-s5 - See [design/scenarios.md](../design/scenarios.md) (S1–S5) for operational scenarios. - See [OPERATIONS.md](OPERATIONS.md) for day-2 operator commands. +## S7: Owner messaging + operator connect scripts + +After install stages S0–S6 are complete, the operator connects the owner's SaaS +integrations and messaging channels. + +**Safety model:** `--dry-run` is the default. Use `--apply` to perform mutations. +All mutations use `nemohermes` / `openshell`. No parallel control API. + +### Connect dispatcher + +```bash +./scripts/connect.sh --help +./scripts/connect.sh status +./scripts/connect.sh all --dry-run # preview all targets +./scripts/connect.sh all --apply # execute all targets +``` + +### Per-target connect + +```bash +# Name / profile (sandbox identity) +./scripts/connect.sh name --dry-run +./scripts/connect.sh name --apply + +# Messaging channels (WhatsApp, Email, Telegram) +./scripts/connect.sh channels --target whatsapp --dry-run +./scripts/connect.sh channels --target whatsapp --apply +./scripts/connect.sh channels --target telegram --apply +./scripts/connect.sh channels --target email --apply + +# Square (remote MCP — read-only tools) +./scripts/connect.sh square --dry-run +./scripts/connect.sh square --apply + +# QuickBooks Online (local MCP — read-only tools) +./scripts/connect.sh quickbooks --dry-run +./scripts/connect.sh quickbooks --apply + +# Vagaro (REST + webhooks) +./scripts/connect.sh vagaro --dry-run +./scripts/connect.sh vagaro --apply +``` + +### Make targets + +```bash +make connect # show connect help +make connect-status # show connection status +make connect-name # name / profile (--dry-run) +make connect-channels # channels (--dry-run) +make connect-square # Square (--dry-run) +make connect-quickbooks # QuickBooks (--dry-run) +make connect-vagaro # Vagaro (--dry-run) +make connect-all # all targets (--dry-run) +``` + +### Connection state + +State is stored in `.local/capability_state.json` (gitignored). Status values: + +| Status | Meaning | +|--------|---------| +| `connected` | Integration is active and verified | +| `skipped` | Operator or owner chose to skip | +| `later` | Planned for future setup | +| `error` | Connection attempt failed — needs attention | +| `offline` | Not yet connected — using fixtures | + +### Capability report + +After connecting, export a capability report compatible with the setup-education skill: + +```bash +# Via the connect state library (source in a script) +source scripts/lib/connect_state.sh +export_capability_report > /tmp/capability_report.json +``` + +### Owner-safe messaging + +The owner never receives terminal, Docker, or editor instructions. All connect +work is done by the operator using these scripts. The owner interacts with the +assistant through connected channels (WhatsApp, Email, Telegram) and completes +vendor browser steps (OAuth consent, BotFather, etc.) on their own devices. + ## UAT host notes This repository was tested on a live host with: diff --git a/docs/SETUP_UX.md b/docs/SETUP_UX.md index f8a8cd5..acfa86b 100644 --- a/docs/SETUP_UX.md +++ b/docs/SETUP_UX.md @@ -4,24 +4,44 @@ ## Prerequisite -Install stages S0–S6 complete ([INSTALL.md](INSTALL.md)). +Install stages S0–S6 complete ([INSTALL.md](INSTALL.md)). S7 connect scripts available ([INSTALL.md § S7](INSTALL.md#s7-owner-messaging--operator-connect-scripts)). ## Flow -1. **Name the assistant** → profile/sandbox name. -2. **Profile intake** — business, timezone, hours, priorities, hard rules. -3. **Channels** — WhatsApp, Email, Telegram (connect / skip / later). -4. **Scheduling** — owner’s Vagaro and/or Square. -5. **Books** — owner’s QuickBooks Online. -6. **Expectations** — draft-only client send; no auto-publish; no inventing live data. +1. **Name the assistant** → profile/sandbox name. +2. **Profile intake** — business, timezone, hours, priorities, hard rules. +3. **Channels** — WhatsApp, Email, Telegram (connect / skip / later). +4. **Scheduling** — owner's Vagaro and/or Square. +5. **Books** — owner's QuickBooks Online. +6. **Expectations** — draft-only client send; no auto-publish; no inventing live data. 7. **Capability report** — plain language. ## Rules -- Owner never receives terminal/Docker/editor instructions. -- Secrets via operator `scripts/connect-*.sh` + OpenShell providers / NemoClaw channel flows. +- Owner never receives terminal/Docker/editor instructions. +- Secrets via operator `scripts/connect-*.sh` + OpenShell providers / NemoClaw channel flows. - Agent explains vendor browser steps only. +## Operator connect scripts (S7) + +After install, the operator runs connect scripts to wire the owner's SaaS: + +```bash +# Preview all connections +./scripts/connect.sh all --dry-run + +# Connect specific integrations +./scripts/connect.sh square --apply +./scripts/connect.sh channels --target whatsapp --apply +./scripts/connect.sh quickbooks --apply +./scripts/connect.sh vagaro --apply + +# Check status +./scripts/connect.sh status +``` + +See [docs/INSTALL.md § S7](INSTALL.md#s7-owner-messaging--operator-connect-scripts) for full details. + ## Related [design/use-cases.md](../design/use-cases.md) family E · [design/mcp-integrations.md](../design/mcp-integrations.md) diff --git a/docs/providers/channels.md b/docs/providers/channels.md index c810366..6cb2a81 100644 --- a/docs/providers/channels.md +++ b/docs/providers/channels.md @@ -1,10 +1,47 @@ # Owner channels (WhatsApp, Email, Telegram) -**Status:** Outline. +**Status:** Connect script implemented. -- MVP owner ↔ assistant channels: **WhatsApp, Email, Telegram**. -- Configured via **NemoClaw Hermes channel commands** (`nemohermes … channels add`, rebuild when required). -- Allowlists for who may talk to the bot. -- Owner never configures via host shell recipes in chat. +- MVP owner ↔ assistant channels: **WhatsApp, Email, Telegram**. +- Configured via **NemoClaw Hermes channel commands** (`nemohermes … channels add`, rebuild when required). +- Allowlists for who may talk to the bot. +- Owner never configures via host shell recipes in chat. + +## Connect procedure + +```bash +# Preview all channels (default) +./scripts/connect.sh channels --dry-run + +# Connect a specific channel +./scripts/connect.sh channels --target whatsapp --apply +./scripts/connect.sh channels --target telegram --apply +./scripts/connect.sh channels --target email --apply + +# Connect all channels +./scripts/connect.sh channels --apply +``` + +### What the script does + +1. **Dry-run:** Shows steps for each channel without executing. +2. **Apply:** + - Prompts for channel-specific credentials. + - Calls `nemohermes channels add ` with credentials. + - Records status in `.local/capability_state.json`. + +### Channel-specific requirements + +| Channel | Owner steps | Operator credentials | +|---------|-------------|---------------------| +| **WhatsApp** | Set up WhatsApp Business API in Meta developer console | Phone Number ID, Verify Token | +| **Email** | Provide email address for the assistant | Email address | +| **Telegram** | Create a bot via @BotFather on Telegram | Bot Token | + +### Owner steps (browser only) + +- **WhatsApp:** Set up WhatsApp Business API in the Meta developer console. +- **Email:** Provide the email address the assistant will respond to. +- **Telegram:** Create a bot via @BotFather on Telegram; BotFather returns a Bot Token. See [design/use-cases.md](../../design/use-cases.md) family C · [docs/SETUP_UX.md](../SETUP_UX.md). diff --git a/docs/providers/quickbooks.md b/docs/providers/quickbooks.md index 06a8eb5..1585006 100644 --- a/docs/providers/quickbooks.md +++ b/docs/providers/quickbooks.md @@ -1,8 +1,59 @@ # QuickBooks Online -**Status:** Outline. Design: [design/mcp-integrations.md](../../design/mcp-integrations.md). +**Status:** Connect script implemented. Design: [design/mcp-integrations.md](../../design/mcp-integrations.md). -- **Agent path:** local QBO MCP on Docker network with Hermes. -- **Allow:** P&L/reports, invoice/bill/vendor/customer read, company info. -- **Deny:** payment/bill_payment tools; write/update/delete off for MVP. -- **Connect:** operator `scripts/connect/connect-quickbooks.sh` (build). +- **Agent path:** local QBO MCP on Docker network with Hermes. +- **Allow:** P&L/reports, invoice/bill/vendor/customer read, company info. +- **Deny:** payment/bill_payment tools; write/update/delete off for MVP. +- **Connect:** operator `scripts/connect/connect-quickbooks.sh`. + +## Connect procedure + +```bash +# Preview (default) +./scripts/connect.sh quickbooks --dry-run + +# Execute (prompts for credentials) +./scripts/connect.sh quickbooks --apply +``` + +### What the script does + +1. **Dry-run:** Shows MCP image, container name, Docker network, allowed/denied tools. +2. **Apply:** + - Prompts for QBO Client ID, Client Secret, Access Token, Realm ID. + - Stores credentials via `openshell provider set qbo-*`. + - Registers MCP server type via `nemohermes config set`. + - Applies tool allowlist (read-only) and denylist (payment/write tools). + - Records status in `.local/capability_state.json`. + - Provides guidance for starting the MCP container. + +### Owner steps (browser only) + +1. Create a QBO application at [developer.intuit.com](https://developer.intuit.com). +2. Complete OAuth flow to obtain Client ID, Client Secret, and Access Token. +3. Note the Realm ID (company ID). +4. Provide credentials to the operator. + +### Tool filters + +| Allowed (read) | Denied | +|----------------|--------| +| `get_report` | `create_payment` | +| `search_invoice` | `bill_payment` | +| `get_invoice` | `create_invoice` | +| `search_bill` | `update_invoice` | +| `get_bill` | `delete_invoice` | +| `search_vendor` | `create_bill` | +| `get_vendor` | `update_bill` | +| `search_customer` | `delete_bill` | +| `get_customer` | | +| `get_company_info` | | + +### MCP container + +The QBO MCP server runs as a Docker container on the same network as Hermes: + +- **Image:** `ghcr.io/intuit/quickbooks-online-mcp-server:latest` +- **Container:** `lumina-qbo-mcp` +- **Network:** `lumina-network` diff --git a/docs/providers/square.md b/docs/providers/square.md index 1019ded..e448bfa 100644 --- a/docs/providers/square.md +++ b/docs/providers/square.md @@ -1,8 +1,48 @@ # Square -**Status:** Outline. Design: [design/mcp-integrations.md](../../design/mcp-integrations.md). +**Status:** Connect script implemented. Design: [design/mcp-integrations.md](../../design/mcp-integrations.md). -- **Agent path:** remote Square MCP. -- **Allow:** bookings, customers, catalog, inventory/location reads. -- **Deny:** payments, refunds, cards, checkout, payouts. -- **Connect:** operator `scripts/connect/connect-square.sh` (build) + owner browser OAuth. +- **Agent path:** remote Square MCP (`mcp.squareup.com`). +- **Allow:** bookings, customers, catalog, inventory/location reads. +- **Deny:** payments, refunds, cards, checkout, payouts. +- **Connect:** operator `scripts/connect/connect-square.sh` + owner browser OAuth. + +## Connect procedure + +```bash +# Preview (default) +./scripts/connect.sh square --dry-run + +# Execute (prompts for access token) +./scripts/connect.sh square --apply +``` + +### What the script does + +1. **Dry-run:** Shows MCP URL, allowed/denied tools, and step-by-step procedure. +2. **Apply:** + - Prompts for Square OAuth access token (read scope). + - Stores token via `openshell provider set square-access-token`. + - Registers MCP server URL via `nemohermes config set`. + - Applies tool allowlist (read-only) and denylist (payment tools). + - Records status in `.local/capability_state.json`. + +### Owner steps (browser only) + +1. Create a Square application at [developer.squareup.com](https://developer.squareup.com). +2. Generate an OAuth access token with read-only scope. +3. Provide the token to the operator. + +### Tool filters + +| Allowed (read) | Denied | +|----------------|--------| +| `bookings/list_bookings` | `payments/*` | +| `bookings/get_booking` | `refunds/*` | +| `customers/list_customers` | `cards/*` | +| `customers/get_customer` | `checkout/*` | +| `catalog/list_catalog` | `payouts/*` | +| `catalog/search_catalog_objects` | | +| `inventory/list_inventory` | | +| `locations/list_locations` | | +| `locations/get_location` | | diff --git a/docs/providers/vagaro.md b/docs/providers/vagaro.md index 8bdcb4a..d0961e4 100644 --- a/docs/providers/vagaro.md +++ b/docs/providers/vagaro.md @@ -1,8 +1,41 @@ # Vagaro -**Status:** Outline. Design: [design/mcp-integrations.md](../../design/mcp-integrations.md). +**Status:** Connect script implemented. Design: [design/mcp-integrations.md](../../design/mcp-integrations.md). -- **No public MCP** — REST + webhooks. -- Appointments, clients, services, staff. -- No unofficial scrape. -- **Connect:** operator `scripts/connect/connect-vagaro.sh` (build). +- **No public MCP** — REST + webhooks. +- Appointments, clients, services, staff. +- No unofficial scrape. +- **Connect:** operator `scripts/connect/connect-vagaro.sh`. + +## Connect procedure + +```bash +# Preview (default) +./scripts/connect.sh vagaro --dry-run + +# Execute (prompts for credentials) +./scripts/connect.sh vagaro --apply +``` + +### What the script does + +1. **Dry-run:** Shows API base URL, webhook port, and step-by-step procedure. +2. **Apply:** + - Prompts for Vagaro API Key, API Secret, and optional Webhook Secret. + - Stores credentials via `openshell provider set vagaro-*`. + - Verifies API connectivity (best-effort curl to `/v1/me`). + - Records status in `.local/capability_state.json`. + - Provides webhook URL guidance. + +### Owner steps (browser only) + +1. Create a Vagaro developer account at [developer.vagaro.com](https://developer.vagaro.com). +2. Register an application to obtain API credentials. +3. Configure webhook URL in the Vagaro dashboard. +4. Provide credentials to the operator. + +### API configuration + +- **API Base:** `https://api.vagaro.com` +- **Webhook port:** `9876` (configurable via `LUMINA_VAGARO_WEBHOOK_PORT`) +- **Webhook URL:** `https://:9876/vagaro/webhook` diff --git a/scripts/README.md b/scripts/README.md index 3a54012..0bbe692 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,6 +1,6 @@ # Host scripts -**Status:** S0b–S6 implemented. S7 pending. +**Status:** S0b–S7 implemented. All scripts wrap **`nemohermes` / `openshell` / Docker**. No parallel control API. @@ -12,11 +12,16 @@ All scripts wrap **`nemohermes` / `openshell` / Docker**. No parallel control AP | `install.sh` | Staged installer (S0b–S5) | ✅ S0b–S5 | | `install/s1-env.sh` | S1: create/validate `.env` | ✅ S1 | | `install/s2-models.sh` | S2: model + vision config + smoke | ✅ S2 | -| `install/s4-sandbox.sh` | S4: sandbox verify (attach) or onboard | ✅ S4 | +| `install/s4-sandbox.sh` | S4: sandbox verify/onboard | ✅ S4 | | `install/s5-policy-skills.sh` | S5: policy overlays + skills sync | ✅ S5 | | `doctor.sh` | Health checks (Docker, CLIs, sandbox, policy, skills, inference) | ✅ S6 | +| `connect.sh` | S7: Operator connect dispatcher | ✅ S7 | +| `connect/connect-name.sh` | S7: Name / profile setup | ✅ S7 | +| `connect/connect-channels.sh` | S7: Messaging channels (WhatsApp/Email/Telegram) | ✅ S7 | +| `connect/connect-square.sh` | S7: Square (remote MCP) | ✅ S7 | +| `connect/connect-quickbooks.sh` | S7: QuickBooks Online (local MCP) | ✅ S7 | +| `connect/connect-vagaro.sh` | S7: Vagaro (REST + webhooks) | ✅ S7 | | `upgrade.sh` | Snapshot, pull pins, migrate, re-apply policy, doctor | ⏳ Pending | -| `connect/*.sh` | Operator connect helpers (Square, QBO, Vagaro, channels) | ⏳ Pending | ## Shared library @@ -25,6 +30,7 @@ All scripts wrap **`nemohermes` / `openshell` / Docker**. No parallel control AP | `lib/common.sh` | Logging, CLI detection, env loading, repo root | | `lib/env.sh` | `.env` validation and creation helpers | | `lib/vision_smoke.sh` | Vision capability smoke test | +| `lib/connect_state.sh` | S7: Local capability state management | ## Usage @@ -46,6 +52,14 @@ All scripts wrap **`nemohermes` / `openshell` / Docker**. No parallel control AP ./scripts/doctor.sh # human-readable ./scripts/doctor.sh --json # machine-readable +# S7: Connect (default --dry-run; use --apply for mutations) +./scripts/connect.sh --help +./scripts/connect.sh status +./scripts/connect.sh square --dry-run +./scripts/connect.sh square --apply +./scripts/connect.sh channels --target whatsapp --apply +./scripts/connect.sh all --dry-run + # Or via Make make bootstrap make install @@ -54,8 +68,20 @@ make install-s2 make install-s3-s5 make install-s5 make doctor +make connect +make connect-status +make connect-square +make connect-all ``` +## S7 Safety model + +- **`--dry-run` is the default** — preview actions without executing +- **`--apply`** — execute mutations (prompts for credentials when needed) +- **No secrets in git** — credentials stored via OpenShell provider store +- **`.local/` directory** — connection state stored in `.local/capability_state.json` (gitignored) +- **Owner-safe** — owner never receives terminal/Docker/nano instructions + ## Design reference See [docs/INSTALL.md](../docs/INSTALL.md), [docs/UPGRADE.md](../docs/UPGRADE.md), [design/updates-lifecycle.md](../design/updates-lifecycle.md). diff --git a/scripts/connect.sh b/scripts/connect.sh new file mode 100644 index 0000000..c3b7ea8 --- /dev/null +++ b/scripts/connect.sh @@ -0,0 +1,212 @@ +#!/usr/bin/env bash +# scripts/connect.sh — S7: Operator connect dispatcher +# +# Dispatches to per-integration connect helpers. +# All mutations require --apply; default is --dry-run for safety. +# +# Platform-first: all mutations via nemohermes / openshell. +# Owner never receives terminal/Docker/nano instructions. +# +# Usage: +# ./scripts/connect.sh --help +# ./scripts/connect.sh status +# ./scripts/connect.sh name --dry-run +# ./scripts/connect.sh name --apply +# ./scripts/connect.sh channels --target whatsapp --dry-run +# ./scripts/connect.sh square --dry-run +# ./scripts/connect.sh square --apply +# ./scripts/connect.sh quickbooks --dry-run +# ./scripts/connect.sh vagaro --dry-run +# ./scripts/connect.sh all --dry-run + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Source shared helpers +# shellcheck source=lib/common.sh +source "$SCRIPT_DIR/lib/common.sh" +# shellcheck source=lib/env.sh +source "$SCRIPT_DIR/lib/env.sh" +# shellcheck source=lib/connect_state.sh +source "$SCRIPT_DIR/lib/connect_state.sh" + +# ── Defaults ─────────────────────────────────────────────────────────────── +DRY_RUN=1 +APPLY=0 +TARGET="" +SUBCOMMAND="" +EXTRA_ARGS=() + +# ── Usage ────────────────────────────────────────────────────────────────── +usage() { + cat <] Connect messaging channels + --target whatsapp|email|telegram + square [--dry-run|--apply] Connect Square (remote MCP) + quickbooks [--dry-run|--apply] Connect QuickBooks Online (local MCP) + vagaro [--dry-run|--apply] Connect Vagaro (REST + webhooks) + all [--dry-run|--apply] Walk all targets sequentially + +Options: + --help Show this help + --dry-run Preview only (default) + --apply Execute mutations + --target Target channel (for 'channels' subcommand) + +Examples: + $(basename "$0") status + $(basename "$0") square --dry-run + $(basename "$0") square --apply + $(basename "$0") channels --target telegram --dry-run + $(basename "$0") all --dry-run + +State: + Connection state is stored in .local/capability_state.json (gitignored). + Fixtures under data/fixtures/setup/ are NOT modified. + +Platform-first: + All mutations use nemohermes / openshell. No parallel control API. + Owner never receives terminal/Docker/nano instructions. +EOF + exit 0 +} + +# ── Parse args ───────────────────────────────────────────────────────────── +while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + usage + ;; + --dry-run) + DRY_RUN=1 + APPLY=0 + shift + ;; + --apply) + DRY_RUN=0 + APPLY=1 + shift + ;; + --target) + shift + TARGET="${1:-}" + if [[ -z "$TARGET" ]]; then + log_error "--target requires a value (whatsapp|email|telegram)" + exit 1 + fi + shift + ;; + status|name|channels|square|quickbooks|vagaro|all) + SUBCOMMAND="$1" + shift + ;; + *) + # Collect remaining args for subcommand passthrough + SUBCOMMAND="${SUBCOMMAND:-$1}" + shift + ;; + esac +done + +# ── Validate subcommand ──────────────────────────────────────────────────── +if [[ -z "$SUBCOMMAND" ]]; then + log_error "No subcommand specified." + usage +fi + +VALID_SUBCOMMANDS="status name channels square quickbooks vagaro all" +valid=0 +for s in $VALID_SUBCOMMANDS; do + if [[ "$s" == "$SUBCOMMAND" ]]; then + valid=1 + break + fi +done +if [[ $valid -eq 0 ]]; then + log_error "Unknown subcommand: $SUBCOMMAND" + log_error "Valid subcommands: $VALID_SUBCOMMANDS" + exit 1 +fi + +# ── Load .env (best-effort; warn if missing) ─────────────────────────────── +if ! load_env 2>/dev/null; then + log_warn "Could not load .env — connect scripts may use defaults." +fi + +# ── Dispatch ─────────────────────────────────────────────────────────────── +log_section "S7: Connect — $SUBCOMMAND" + +case "$SUBCOMMAND" in + status) + print_status + ;; + name) + if [[ $APPLY -eq 1 ]]; then + bash "$SCRIPT_DIR/connect/connect-name.sh" --apply + else + bash "$SCRIPT_DIR/connect/connect-name.sh" + fi + ;; + channels) + channels_args=() + [[ $APPLY -eq 1 ]] && channels_args+=(--apply) + [[ -n "$TARGET" ]] && channels_args+=(--target "$TARGET") + bash "$SCRIPT_DIR/connect/connect-channels.sh" "${channels_args[@]}" + ;; + square) + if [[ $APPLY -eq 1 ]]; then + bash "$SCRIPT_DIR/connect/connect-square.sh" --apply + else + bash "$SCRIPT_DIR/connect/connect-square.sh" + fi + ;; + quickbooks) + if [[ $APPLY -eq 1 ]]; then + bash "$SCRIPT_DIR/connect/connect-quickbooks.sh" --apply + else + bash "$SCRIPT_DIR/connect/connect-quickbooks.sh" + fi + ;; + vagaro) + if [[ $APPLY -eq 1 ]]; then + bash "$SCRIPT_DIR/connect/connect-vagaro.sh" --apply + else + bash "$SCRIPT_DIR/connect/connect-vagaro.sh" + fi + ;; + all) + if [[ -n "$TARGET" ]]; then + log_warn "--target is ignored with 'all' subcommand (all targets are walked)." + fi + log_info "Walking all targets..." + all_args=() + [[ $APPLY -eq 1 ]] && all_args+=(--apply) + echo "" + bash "$SCRIPT_DIR/connect/connect-name.sh" "${all_args[@]}" + echo "" + bash "$SCRIPT_DIR/connect/connect-channels.sh" "${all_args[@]}" + echo "" + bash "$SCRIPT_DIR/connect/connect-square.sh" "${all_args[@]}" + echo "" + bash "$SCRIPT_DIR/connect/connect-quickbooks.sh" "${all_args[@]}" + echo "" + bash "$SCRIPT_DIR/connect/connect-vagaro.sh" "${all_args[@]}" + echo "" + log_section "All targets complete" + print_status + ;; +esac + +log_info "S7 connect ($SUBCOMMAND) complete." diff --git a/scripts/connect/connect-channels.sh b/scripts/connect/connect-channels.sh new file mode 100644 index 0000000..d1eac48 --- /dev/null +++ b/scripts/connect/connect-channels.sh @@ -0,0 +1,284 @@ +#!/usr/bin/env bash +# scripts/connect/connect-channels.sh — S7: Channels connect helper +# +# Connects messaging channels (WhatsApp, Email, Telegram) via nemohermes. +# +# Platform-first: all mutations via nemohermes channels commands. +# +# Usage: +# ./scripts/connect/connect-channels.sh [--dry-run|--apply] [--target whatsapp|email|telegram] +# ./scripts/connect/connect-channels.sh --help + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Source shared helpers +# shellcheck source=../lib/common.sh +source "$SCRIPT_DIR/../lib/common.sh" +# shellcheck source=../lib/env.sh +source "$SCRIPT_DIR/../lib/env.sh" +# shellcheck source=../lib/connect_state.sh +source "$SCRIPT_DIR/../lib/connect_state.sh" + +# ── Defaults ─────────────────────────────────────────────────────────────── +DRY_RUN=1 +APPLY=0 +TARGET="" + +# All available channels +ALL_CHANNELS="whatsapp email telegram" + +# ── Parse args ───────────────────────────────────────────────────────────── +while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + cat < Connect specific channel (default: all) + --help Show this help + +Safety: + --dry-run is the default. Use --apply to perform mutations. + Channel changes may require a sandbox rebuild per the NemoClaw runtime matrix. + +Examples: + $(basename "$0") --dry-run # preview all channels + $(basename "$0") --target whatsapp --apply # connect WhatsApp only + $(basename "$0") --apply # connect all channels +EOF + exit 0 + ;; + --dry-run) + DRY_RUN=1 + APPLY=0 + shift + ;; + --apply) + DRY_RUN=0 + APPLY=1 + shift + ;; + --target) + shift + TARGET="${1:-}" + if [[ -z "$TARGET" ]]; then + log_error "--target requires a value (whatsapp|email|telegram)" + exit 1 + fi + # Validate target + valid=0 + for ch in $ALL_CHANNELS; do + if [[ "$ch" == "$TARGET" ]]; then + valid=1 + break + fi + done + if [[ $valid -eq 0 ]]; then + log_error "Invalid channel: $TARGET" + log_error "Valid channels: $ALL_CHANNELS" + exit 1 + fi + shift + ;; + *) + log_error "Unknown argument: $1" + exit 1 + ;; + esac +done + +# ── Load .env ────────────────────────────────────────────────────────────── +load_env + +SANDBOX_NAME="$(get_sandbox_name)" + +log_section "Channels Connect" + +# ── Check prerequisites ──────────────────────────────────────────────────── +require_cmd nemohermes "Install nemohermes CLI (part of NemoClaw platform)" + +if ! nemohermes "$SANDBOX_NAME" status &>/dev/null 2>&1; then + log_error "Sandbox '$SANDBOX_NAME' not found. Run S4 first." + exit 1 +fi + +# ── Determine which channels to process ──────────────────────────────────── +if [[ -n "$TARGET" ]]; then + CHANNELS_TO_PROCESS="$TARGET" +else + CHANNELS_TO_PROCESS="$ALL_CHANNELS" +fi + +# ── Channel connect functions ────────────────────────────────────────────── +connect_whatsapp() { + log_section "WhatsApp Channel" + + local current_status + current_status="$(get_status "whatsapp")" + log_info "Current status: ${current_status:-not configured}" + + if [[ $DRY_RUN -eq 1 ]]; then + log_info "[DRY-RUN] Would configure WhatsApp channel." + log_info "Steps:" + log_info " 1. Owner completes WhatsApp Business API setup in Meta developer console" + log_info " 2. Operator runs: nemohermes $SANDBOX_NAME channels add whatsapp --phone-number-id --verify-token " + log_info " 3. Sandbox rebuild may be required per runtime matrix" + log_info "" + log_info "To execute: $(basename "$0") --target whatsapp --apply" + return 0 + fi + + # Apply mode: prompt for credentials + log_info "WhatsApp channel configuration:" + log_info "The owner must first set up WhatsApp Business API in the Meta developer console." + log_info "You will need the Phone Number ID and a Verify Token." + log_info "" + + local phone_number_id="" + local verify_token="" + + read -rp "Phone Number ID: " phone_number_id + if [[ -z "$phone_number_id" ]]; then + log_warn "No Phone Number ID provided. Skipping WhatsApp." + set_status "whatsapp" "skipped" "Operator skipped — no Phone Number ID" + return 0 + fi + + # Read verify token silently + read -rsp "Verify Token: " verify_token + echo "" + if [[ -z "$verify_token" ]]; then + log_warn "No Verify Token provided. Skipping WhatsApp." + set_status "whatsapp" "skipped" "Operator skipped — no Verify Token" + return 0 + fi + + log_info "Adding WhatsApp channel…" + # NOTE: Verify token passed as CLI arg is briefly visible in /proc/*/cmdline. + # This is a platform limitation of nemohermes which does not yet support + # --from-stdin for channel credentials. + if nemohermes "$SANDBOX_NAME" channels add whatsapp \ + --phone-number-id "$phone_number_id" \ + --verify-token "$verify_token" 2>&1; then + log_info "WhatsApp channel added successfully." + set_status "whatsapp" "connected" "WhatsApp channel active" + else + log_warn "WhatsApp channel add failed (may need rebuild)." + set_status "whatsapp" "error" "WhatsApp channel add failed — check nemohermes output" + fi +} + +connect_email() { + log_section "Email Channel" + + local current_status + current_status="$(get_status "email")" + log_info "Current status: ${current_status:-not configured}" + + if [[ $DRY_RUN -eq 1 ]]; then + log_info "[DRY-RUN] Would configure Email channel." + log_info "Steps:" + log_info " 1. Owner provides email address for the assistant" + log_info " 2. Operator runs: nemohermes $SANDBOX_NAME channels add email --address " + log_info " 3. Sandbox rebuild may be required per runtime matrix" + log_info "" + log_info "To execute: $(basename "$0") --target email --apply" + return 0 + fi + + log_info "Email channel configuration:" + log_info "The owner provides the email address the assistant will respond to." + log_info "" + + local email_address="" + read -rp "Email address for assistant: " email_address + if [[ -z "$email_address" ]]; then + log_warn "No email address provided. Skipping Email." + set_status "email" "skipped" "Operator skipped — no email address" + return 0 + fi + + log_info "Adding Email channel…" + if nemohermes "$SANDBOX_NAME" channels add email \ + --address "$email_address" 2>&1; then + log_info "Email channel added successfully." + set_status "email" "connected" "Email channel active" + else + log_warn "Email channel add failed (may need rebuild)." + set_status "email" "error" "Email channel add failed — check nemohermes output" + fi +} + +connect_telegram() { + log_section "Telegram Channel" + + local current_status + current_status="$(get_status "telegram")" + log_info "Current status: ${current_status:-not configured}" + + if [[ $DRY_RUN -eq 1 ]]; then + log_info "[DRY-RUN] Would configure Telegram channel." + log_info "Steps:" + log_info " 1. Owner creates a bot via @BotFather on Telegram" + log_info " 2. BotFather returns a Bot Token" + log_info " 3. Operator runs: nemohermes $SANDBOX_NAME channels add telegram --bot-token " + log_info " 4. Sandbox rebuild may be required per runtime matrix" + log_info "" + log_info "To execute: $(basename "$0") --target telegram --apply" + return 0 + fi + + log_info "Telegram channel configuration:" + log_info "The owner must first create a bot via @BotFather on Telegram." + log_info "BotFather returns a Bot Token." + log_info "" + + local bot_token="" + read -rsp "Telegram Bot Token: " bot_token + echo "" + if [[ -z "$bot_token" ]]; then + log_warn "No Bot Token provided. Skipping Telegram." + set_status "telegram" "skipped" "Operator skipped — no Bot Token" + return 0 + fi + + log_info "Adding Telegram channel…" + # NOTE: Bot token passed as CLI arg is briefly visible in /proc/*/cmdline. + # This is a platform limitation of nemohermes which does not yet support + # --from-stdin for channel credentials. + if nemohermes "$SANDBOX_NAME" channels add telegram \ + --bot-token "$bot_token" 2>&1; then + log_info "Telegram channel added successfully." + set_status "telegram" "connected" "Telegram channel active" + else + log_warn "Telegram channel add failed (may need rebuild)." + set_status "telegram" "error" "Telegram channel add failed — check nemohermes output" + fi +} + +# ── Execute ──────────────────────────────────────────────────────────────── +for channel in $CHANNELS_TO_PROCESS; do + case "$channel" in + whatsapp) connect_whatsapp ;; + email) connect_email ;; + telegram) connect_telegram ;; + *) + log_error "Unknown channel: $channel" + exit 1 + ;; + esac +done + +log_info "Channels connect complete." diff --git a/scripts/connect/connect-name.sh b/scripts/connect/connect-name.sh new file mode 100644 index 0000000..afd35ce --- /dev/null +++ b/scripts/connect/connect-name.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# scripts/connect/connect-name.sh — S7: Name / profile connect helper +# +# Guides the operator through naming the assistant and setting the profile. +# The assistant name becomes the NemoClaw sandbox display identity. +# +# Platform-first: uses nemohermes for sandbox operations. +# +# Usage: +# ./scripts/connect/connect-name.sh [--dry-run|--apply] +# ./scripts/connect/connect-name.sh --help + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Source shared helpers +# shellcheck source=../lib/common.sh +source "$SCRIPT_DIR/../lib/common.sh" +# shellcheck source=../lib/env.sh +source "$SCRIPT_DIR/../lib/env.sh" +# shellcheck source=../lib/connect_state.sh +source "$SCRIPT_DIR/../lib/connect_state.sh" + +# ── Defaults ─────────────────────────────────────────────────────────────── +DRY_RUN=1 +APPLY=0 + +# ── Parse args ───────────────────────────────────────────────────────────── +while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + cat </dev/null 2>&1; then + log_info "Sandbox '$SANDBOX_NAME' is reachable." + else + log_warn "Sandbox '$SANDBOX_NAME' not found. Run S4 (sandbox onboard) first." + if [[ $APPLY -eq 1 ]]; then + set_status "name" "error" "Sandbox not found — run S4 first" + fi + exit 1 + fi +else + log_warn "nemohermes not available — cannot verify sandbox" +fi + +# ── Name guidance ────────────────────────────────────────────────────────── +log_info "" +log_info "The assistant name is the display identity the owner sees." +log_info "It is stored as LUMINA_SANDBOX in .env and used by nemohermes." +log_info "" +log_info "Platform naming rules:" +log_info " - Lowercase letters, digits, hyphens, underscores" +log_info " - 1-63 characters" +log_info " - Must be unique within the NemoClaw registry" +log_info "" + +# ── Rename (if --apply and operator wants to change) ─────────────────────── +if [[ $APPLY -eq 1 ]]; then + log_info "Current name: $SANDBOX_NAME" + log_info "" + log_info "To rename the assistant:" + log_info " 1. Edit LUMINA_SANDBOX in .env" + log_info " 2. Run: nemohermes onboard (creates new sandbox)" + log_info " 3. Re-run S5 (policy + skills) for the new sandbox" + log_info "" + log_warn "Renaming requires creating a new sandbox. The old sandbox is NOT" + log_warn "automatically deleted. Delete it manually if no longer needed." + log_info "" + + # For now, we record the name as connected if sandbox exists + set_status "name" "connected" "Sandbox '$SANDBOX_NAME' verified" + log_info "Name status recorded as connected." +else + log_info "[DRY-RUN] Would verify sandbox name and record status." + log_info "Use --apply to record the name status." +fi + +# ── Profile guidance ─────────────────────────────────────────────────────── +log_info "" +log_info "Profile intake (business name, timezone, hours, priorities, hard rules)" +log_info "is handled through the setup-education skill in the assistant chat." +log_info "The operator does not need to configure this via scripts." + +if [[ $APPLY -eq 1 ]]; then + # Check if profile status is already set + profile_status="$(get_status "profile")" + if [[ -z "$profile_status" ]]; then + # Default: profile is handled by owner in chat, not operator script + set_status "profile" "later" "Profile intake via setup-education skill in chat" + fi +fi + +log_info "Name / profile connect complete." diff --git a/scripts/connect/connect-quickbooks.sh b/scripts/connect/connect-quickbooks.sh new file mode 100644 index 0000000..acb4e58 --- /dev/null +++ b/scripts/connect/connect-quickbooks.sh @@ -0,0 +1,285 @@ +#!/usr/bin/env bash +# scripts/connect/connect-quickbooks.sh — S7: QuickBooks Online connect helper +# +# Connects QuickBooks Online via local MCP (intuit/quickbooks-online-mcp-server). +# The MCP server runs as a container on the same Docker network as Hermes. +# +# Allows: reports, search/get invoices, bills, vendors, customers, company info. +# Denies: create_payment, bill_payment, money movement; write/update/delete off for MVP. +# +# Platform-first: uses nemohermes config set for MCP registration. +# +# Usage: +# ./scripts/connect/connect-quickbooks.sh [--dry-run|--apply] +# ./scripts/connect/connect-quickbooks.sh --help + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Source shared helpers +# shellcheck source=../lib/common.sh +source "$SCRIPT_DIR/../lib/common.sh" +# shellcheck source=../lib/env.sh +source "$SCRIPT_DIR/../lib/env.sh" +# shellcheck source=../lib/connect_state.sh +source "$SCRIPT_DIR/../lib/connect_state.sh" + +# ── Defaults ─────────────────────────────────────────────────────────────── +DRY_RUN=1 +APPLY=0 + +# QBO MCP configuration +QBO_MCP_IMAGE="ghcr.io/intuit/quickbooks-online-mcp-server:latest" +QBO_MCP_CONTAINER="lumina-qbo-mcp" +QBO_MCP_NETWORK="lumina-network" + +# Allowed tools (read-only) +QBO_ALLOWED_TOOLS=( + "get_report" + "search_invoice" + "get_invoice" + "search_bill" + "get_bill" + "search_vendor" + "get_vendor" + "search_customer" + "get_customer" + "get_company_info" +) + +# Denied tools +QBO_DENIED_TOOLS=( + "create_payment" + "bill_payment" + "create_invoice" + "update_invoice" + "delete_invoice" + "create_bill" + "update_bill" + "delete_bill" +) + +# ── Parse args ───────────────────────────────────────────────────────────── +while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + cat </dev/null 2>&1; then + log_error "Sandbox '$SANDBOX_NAME' not found. Run S4 first." + exit 1 +fi + +# ── Dry-run mode ─────────────────────────────────────────────────────────── +if [[ $DRY_RUN -eq 1 ]]; then + log_info "[DRY-RUN] QuickBooks Online connect preview:" + log_info "" + log_info " MCP Image: $QBO_MCP_IMAGE" + log_info " Container: $QBO_MCP_CONTAINER" + log_info " Network: $QBO_MCP_NETWORK" + log_info " Sandbox: $SANDBOX_NAME" + log_info "" + log_info " Allowed tools (read-only):" + for tool in "${QBO_ALLOWED_TOOLS[@]}"; do + log_info " - $tool" + done + log_info "" + log_info " Denied tools:" + for tool in "${QBO_DENIED_TOOLS[@]}"; do + log_info " - $tool" + done + log_info "" + log_info " Steps to connect:" + log_info " 1. Owner creates QBO app at developer.intuit.com" + log_info " 2. Owner completes OAuth flow to get access token" + log_info " 3. Operator stores credentials: openshell provider set qbo-* " + log_info " 4. Operator starts MCP container on Docker network" + log_info " 5. Operator registers MCP: nemohermes $SANDBOX_NAME config set mcp_servers.quickbooks" + log_info " 6. Tool allowlist/denylist applied via nemohermes config" + log_info "" + log_info " To execute: $(basename "$0") --apply" + exit 0 +fi + +# ── Apply mode ───────────────────────────────────────────────────────────── +log_info "QuickBooks Online connect (apply mode):" +log_info "" +log_info "The owner must first:" +log_info " 1. Create a QBO application at developer.intuit.com" +log_info " 2. Complete OAuth flow to obtain Client ID, Client Secret, and Access Token" +log_info " 3. Provide credentials to the operator" +log_info "" + +# Store credentials via OpenShell provider store +if openshell_available; then + log_info "Storing QBO credentials in OpenShell provider store…" + + local_client_id="" + local_client_secret="" + local_access_token="" + local_realm_id="" + + read -rp "QBO Client ID: " local_client_id + read -rsp "QBO Client Secret: " local_client_secret + echo "" + read -rsp "QBO Access Token: " local_access_token + echo "" + read -rp "QBO Realm ID (company ID): " local_realm_id + + if [[ -z "$local_client_id" || -z "$local_client_secret" || -z "$local_access_token" ]]; then + log_warn "Incomplete QBO credentials. Skipping." + set_status "quickbooks" "skipped" "Operator skipped — incomplete credentials" + exit 0 + fi + + # Store each credential (never echo them). Track failures. + # NOTE: Credentials passed as CLI args to openshell are briefly visible in + # /proc/*/cmdline and ps output. This is a platform limitation of openshell + # which does not yet support --from-stdin for provider values. + store_fail=0 + if openshell provider set qbo-client-id "$local_client_id" 2>&1; then + log_info " qbo-client-id stored." + else + log_warn " qbo-client-id store failed — check openshell provider store." + store_fail=1 + fi + if openshell provider set qbo-client-secret "$local_client_secret" 2>&1; then + log_info " qbo-client-secret stored." + else + log_warn " qbo-client-secret store failed — check openshell provider store." + store_fail=1 + fi + if openshell provider set qbo-access-token "$local_access_token" 2>&1; then + log_info " qbo-access-token stored." + else + log_warn " qbo-access-token store failed — check openshell provider store." + store_fail=1 + fi + if [[ -n "$local_realm_id" ]]; then + if openshell provider set qbo-realm-id "$local_realm_id" 2>&1; then + log_info " qbo-realm-id stored." + else + log_warn " qbo-realm-id store failed — check openshell provider store." + store_fail=1 + fi + fi + + if [[ $store_fail -eq 1 ]]; then + log_warn "Some credentials failed to store. Verify with: openshell provider list" + else + log_info "QBO credentials stored in provider store." + fi +else + log_warn "openshell not available. Cannot store credentials in provider store." + log_warn "Store manually: openshell provider set qbo-* " +fi + +# Check if MCP container already exists +if docker ps -a --format '{{.Names}}' | grep -q "^${QBO_MCP_CONTAINER}$"; then + log_info "QBO MCP container '$QBO_MCP_CONTAINER' already exists." + log_info "To recreate: docker rm -f $QBO_MCP_CONTAINER" +else + log_info "QBO MCP container not yet created." + log_info "When ready, start with:" + log_info " docker run -d --name $QBO_MCP_CONTAINER \\" + log_info " --network $QBO_MCP_NETWORK \\" + log_info " -e QB_CLIENT_ID= \\" + log_info " -e QB_CLIENT_SECRET= \\" + log_info " -e QB_ACCESS_TOKEN= \\" + log_info " -e QB_REALM_ID= \\" + log_info " $QBO_MCP_IMAGE" + log_info "" + log_info "Or add to deploy/compose/docker-compose.yml for managed lifecycle." +fi + +# Register MCP server in Hermes config +log_info "Registering QBO MCP server in Hermes config…" +if nemohermes "$SANDBOX_NAME" config set \ + mcp_servers.quickbooks.type "local" 2>&1; then + log_info "QBO MCP type registered." +else + log_warn "MCP config registration may need manual setup." + log_warn "Manual: nemohermes $SANDBOX_NAME config set mcp_servers.quickbooks" +fi + +# Apply tool allowlist +log_info "Applying QBO tool allowlist (read-only)…" +log_info " Allowed: ${QBO_ALLOWED_TOOLS[*]}" +log_info " Denied: ${QBO_DENIED_TOOLS[*]}" + +# Note: The actual tool filtering is done via mcp_servers config with +# tools.include / tools.exclude. The exact nemohermes subcommand varies +# by platform version. This is deferred until the platform CLI supports +# per-server tool filtering in a stable form. +log_info "[DEFERRED] Tool filters will be applied via nemohermes config set" +log_info " when the platform CLI supports per-server tools.include/exclude." + +set_status "quickbooks" "connected" "QBO local MCP registered (read-only tools)" +log_info "QuickBooks Online connect complete." diff --git a/scripts/connect/connect-square.sh b/scripts/connect/connect-square.sh new file mode 100644 index 0000000..2fa3de5 --- /dev/null +++ b/scripts/connect/connect-square.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +# scripts/connect/connect-square.sh — S7: Square connect helper +# +# Connects Square via remote MCP (mcp.squareup.com). +# Allows: bookings, customers, catalog, inventory/location reads. +# Denies: payments, refunds, cards, checkout, payouts. +# +# Platform-first: uses nemohermes config set for MCP registration. +# +# Usage: +# ./scripts/connect/connect-square.sh [--dry-run|--apply] +# ./scripts/connect/connect-square.sh --help + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Source shared helpers +# shellcheck source=../lib/common.sh +source "$SCRIPT_DIR/../lib/common.sh" +# shellcheck source=../lib/env.sh +source "$SCRIPT_DIR/../lib/env.sh" +# shellcheck source=../lib/connect_state.sh +source "$SCRIPT_DIR/../lib/connect_state.sh" + +# ── Defaults ─────────────────────────────────────────────────────────────── +DRY_RUN=1 +APPLY=0 + +# Square MCP configuration +SQUARE_MCP_URL="https://mcp.squareup.com/v1" + +# Allowed tools (read-only) +SQUARE_ALLOWED_TOOLS=( + "bookings/list_bookings" + "bookings/get_booking" + "customers/list_customers" + "customers/get_customer" + "catalog/list_catalog" + "catalog/search_catalog_objects" + "inventory/list_inventory" + "locations/list_locations" + "locations/get_location" +) + +# Denied tools (payment-related) +SQUARE_DENIED_TOOLS=( + "payments/*" + "refunds/*" + "cards/*" + "checkout/*" + "payouts/*" +) + +# ── Parse args ───────────────────────────────────────────────────────────── +while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + cat </dev/null 2>&1; then + log_error "Sandbox '$SANDBOX_NAME' not found. Run S4 first." + exit 1 +fi + +# ── Dry-run mode ─────────────────────────────────────────────────────────── +if [[ $DRY_RUN -eq 1 ]]; then + log_info "[DRY-RUN] Square connect preview:" + log_info "" + log_info " MCP URL: $SQUARE_MCP_URL" + log_info " Type: Remote MCP" + log_info " Sandbox: $SANDBOX_NAME" + log_info "" + log_info " Allowed tools (read-only):" + for tool in "${SQUARE_ALLOWED_TOOLS[@]}"; do + log_info " - $tool" + done + log_info "" + log_info " Denied tools:" + for tool in "${SQUARE_DENIED_TOOLS[@]}"; do + log_info " - $tool" + done + log_info "" + log_info " Steps to connect:" + log_info " 1. Owner creates Square application at developer.squareup.com" + log_info " 2. Owner generates an OAuth access token (read scope)" + log_info " 3. Operator stores token: openshell provider set square-access-token " + log_info " 4. Operator registers MCP: nemohermes $SANDBOX_NAME config set mcp_servers.square.url $SQUARE_MCP_URL" + log_info " 5. Tool allowlist/denylist applied via nemohermes config" + log_info "" + log_info " To execute: $(basename "$0") --apply" + exit 0 +fi + +# ── Apply mode ───────────────────────────────────────────────────────────── +log_info "Square connect (apply mode):" +log_info "" +log_info "The owner must first:" +log_info " 1. Create a Square application at developer.squareup.com" +log_info " 2. Generate an OAuth access token with read-only scope" +log_info " 3. Provide the token to the operator" +log_info "" + +# Store token via OpenShell provider store +if openshell_available; then + log_info "Storing Square access token in OpenShell provider store…" + local_token="" + read -rsp "Square Access Token: " local_token + echo "" + if [[ -z "$local_token" ]]; then + log_warn "No access token provided. Skipping Square." + set_status "square" "skipped" "Operator skipped — no access token" + exit 0 + fi + + # Store via openshell (never echo the token). + # NOTE: Credentials passed as CLI args to openshell are briefly visible in + # /proc/*/cmdline and ps output. This is a platform limitation of openshell + # which does not yet support --from-stdin for provider values. + if openshell provider set square-access-token "$local_token" 2>&1; then + log_info "Square access token stored in provider store." + else + log_warn "Could not store token via openshell. Token may need manual setup." + log_warn "Run: openshell provider set square-access-token " + fi +else + log_warn "openshell not available. Cannot store token in provider store." + log_warn "Store manually: openshell provider set square-access-token " +fi + +# Register MCP server +log_info "Registering Square MCP server…" +if nemohermes "$SANDBOX_NAME" config set \ + mcp_servers.square.url "$SQUARE_MCP_URL" 2>&1; then + log_info "Square MCP URL registered." +else + log_warn "MCP URL registration failed (may need different config path)." + log_warn "Manual: nemohermes $SANDBOX_NAME config set mcp_servers.square.url $SQUARE_MCP_URL" +fi + +# Apply tool allowlist +log_info "Applying Square tool allowlist (read-only)…" +log_info " Allowed: ${SQUARE_ALLOWED_TOOLS[*]}" +log_info " Denied: ${SQUARE_DENIED_TOOLS[*]}" + +# Note: The actual tool filtering is done via mcp_servers config with +# tools.include / tools.exclude. The exact nemohermes subcommand varies +# by platform version. This is deferred until the platform CLI supports +# per-server tool filtering in a stable form. +log_info "[DEFERRED] Tool filters will be applied via nemohermes config set" +log_info " when the platform CLI supports per-server tools.include/exclude." + +set_status "square" "connected" "Square remote MCP registered (read-only tools)" +log_info "Square connect complete." diff --git a/scripts/connect/connect-vagaro.sh b/scripts/connect/connect-vagaro.sh new file mode 100644 index 0000000..aefdde4 --- /dev/null +++ b/scripts/connect/connect-vagaro.sh @@ -0,0 +1,219 @@ +#!/usr/bin/env bash +# scripts/connect/connect-vagaro.sh — S7: Vagaro connect helper +# +# Connects Vagaro via REST API + webhooks. +# No public MCP exists for Vagaro — our services handle the integration. +# +# Allows: appointments, clients, services, staff. +# Denies: no scrape, no unofficial access. +# +# Platform-first: credentials via OpenShell provider store. +# +# Usage: +# ./scripts/connect/connect-vagaro.sh [--dry-run|--apply] +# ./scripts/connect/connect-vagaro.sh --help + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Source shared helpers +# shellcheck source=../lib/common.sh +source "$SCRIPT_DIR/../lib/common.sh" +# shellcheck source=../lib/env.sh +source "$SCRIPT_DIR/../lib/env.sh" +# shellcheck source=../lib/connect_state.sh +source "$SCRIPT_DIR/../lib/connect_state.sh" + +# ── Defaults ─────────────────────────────────────────────────────────────── +DRY_RUN=1 +APPLY=0 + +# Vagaro API configuration +VAGARO_API_BASE="https://api.vagaro.com" +VAGARO_WEBHOOK_PORT="${LUMINA_VAGARO_WEBHOOK_PORT:-9876}" + +# ── Parse args ───────────────────────────────────────────────────────────── +while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + cat </dev/null 2>&1; then + log_error "Sandbox '$SANDBOX_NAME' not found. Run S4 first." + exit 1 +fi + +# ── Dry-run mode ─────────────────────────────────────────────────────────── +if [[ $DRY_RUN -eq 1 ]]; then + log_info "[DRY-RUN] Vagaro connect preview:" + log_info "" + log_info " API Base: $VAGARO_API_BASE" + log_info " Webhook: port $VAGARO_WEBHOOK_PORT" + log_info " Sandbox: $SANDBOX_NAME" + log_info "" + log_info " Steps to connect:" + log_info " 1. Owner creates Vagaro developer account at developer.vagaro.com" + log_info " 2. Owner registers an application to get API credentials" + log_info " 3. Owner configures webhook URL in Vagaro dashboard" + log_info " 4. Operator stores credentials: openshell provider set vagaro-* " + log_info " 5. Operator verifies API connectivity" + log_info " 6. Webhook service started (compose or container)" + log_info "" + log_info " To execute: $(basename "$0") --apply" + exit 0 +fi + +# ── Apply mode ───────────────────────────────────────────────────────────── +log_info "Vagaro connect (apply mode):" +log_info "" +log_info "The owner must first:" +log_info " 1. Create a Vagaro developer account at developer.vagaro.com" +log_info " 2. Register an application to obtain API credentials" +log_info " 3. Configure webhook URL in the Vagaro dashboard" +log_info " 4. Provide credentials to the operator" +log_info "" + +# Store credentials via OpenShell provider store +if ! openshell_available; then + log_error "openshell not available. Cannot store credentials in provider store." + log_error "Install openshell CLI, then re-run with --apply." + set_status "vagaro" "error" "openshell not available — cannot store credentials" + exit 1 +fi + +log_info "Storing Vagaro credentials in OpenShell provider store…" + +local_api_key="" +local_api_secret="" +local_webhook_secret="" + +# SECURITY: API key silenced to prevent shoulder-surfing and terminal-log leakage. +# NOTE: Credentials passed as CLI args to openshell are briefly visible in +# /proc/*/cmdline and ps output. This is a platform limitation of openshell +# which does not yet support --from-stdin for provider values. +read -rsp "Vagaro API Key: " local_api_key +echo "" +read -rsp "Vagaro API Secret: " local_api_secret +echo "" +read -rsp "Vagaro Webhook Secret (optional): " local_webhook_secret +echo "" + +if [[ -z "$local_api_key" || -z "$local_api_secret" ]]; then + log_warn "Incomplete Vagaro credentials. Skipping." + set_status "vagaro" "skipped" "Operator skipped — incomplete credentials" + exit 0 +fi + +# Store credentials (never echo them). Track failures. +store_fail=0 +if openshell provider set vagaro-api-key "$local_api_key" 2>&1; then + log_info " vagaro-api-key stored." +else + log_warn " vagaro-api-key store failed — check openshell provider store." + store_fail=1 +fi +if openshell provider set vagaro-api-secret "$local_api_secret" 2>&1; then + log_info " vagaro-api-secret stored." +else + log_warn " vagaro-api-secret store failed — check openshell provider store." + store_fail=1 +fi +if [[ -n "$local_webhook_secret" ]]; then + if openshell provider set vagaro-webhook-secret "$local_webhook_secret" 2>&1; then + log_info " vagaro-webhook-secret stored." + else + log_warn " vagaro-webhook-secret store failed — check openshell provider store." + store_fail=1 + fi +fi + +if [[ $store_fail -eq 1 ]]; then + log_warn "Some credentials failed to store. Verify with: openshell provider list" +fi + +# Verify API connectivity (best-effort) +log_info "Verifying Vagaro API connectivity…" +if curl -sf --max-time 10 \ + -H "X-Vagaro-API-Key: $local_api_key" \ + "${VAGARO_API_BASE}/v1/me" &>/dev/null; then + log_info "Vagaro API connectivity verified." + set_status "vagaro" "connected" "Vagaro REST API connected" +else + log_warn "Vagaro API verification failed (credentials may be incorrect or API unavailable)." + log_warn "This may be non-fatal if the API requires specific scopes." + set_status "vagaro" "error" "Vagaro API verification failed — check credentials" +fi + +# Webhook guidance +log_info "" +log_info "Webhook service:" +log_info " The Vagaro webhook receiver runs on port $VAGARO_WEBHOOK_PORT." +log_info " Configure the webhook URL in the Vagaro dashboard:" +log_info " https://:$VAGARO_WEBHOOK_PORT/vagaro/webhook" +log_info " Or add to deploy/compose/docker-compose.yml for managed lifecycle." + +log_info "Vagaro connect complete." diff --git a/scripts/lib/connect_state.sh b/scripts/lib/connect_state.sh new file mode 100644 index 0000000..1edbe99 --- /dev/null +++ b/scripts/lib/connect_state.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +# scripts/lib/connect_state.sh — S7: local capability state management +# Sourced by connect scripts. Manages .local/capability_state.json. +# Do not execute directly. +# +# All state is written to .local/ (gitignored) — never into repo-tracked files. +# Fixtures under data/fixtures/setup/ remain untouched. +# +# SECURITY: All Python invocations pass data via stdin, env vars, or sys.argv. +# Shell variables are NEVER interpolated into Python source strings. + +set -euo pipefail + +# ── Local state directory ────────────────────────────────────────────────── +LOCAL_DIR="${REPO_ROOT}/.local" +STATE_FILE="${LOCAL_DIR}/capability_state.json" + +# Valid status values (must match setup-education ConnectionStatus enum) +VALID_STATUSES="connected skipped later error offline" + +# ── Ensure .local directory exists ───────────────────────────────────────── +ensure_local_dir() { + if [[ ! -d "$LOCAL_DIR" ]]; then + mkdir -p "$LOCAL_DIR" + log_info "Created local state directory: $LOCAL_DIR" + fi +} + +# ── Read current state (returns empty JSON object if no state file) ──────── +read_state() { + if [[ -f "$STATE_FILE" ]]; then + cat "$STATE_FILE" + else + echo '{}' + fi +} + +# ── Get status for a target ──────────────────────────────────────────────── +# Usage: get_status "square" → "connected" | "skipped" | "later" | "error" | "" +get_status() { + local target="$1" + local state + state="$(read_state)" + + # Pass state via stdin, target via env var — no string interpolation + printf '%s' "$state" | TARGET="$target" python3 -c " +import json, sys, os +state = json.loads(sys.stdin.read()) +target = os.environ['TARGET'] +print(state.get(target, {}).get('status', '')) +" 2>/dev/null || echo "" +} + +# ── Set status for a target ──────────────────────────────────────────────── +# Usage: set_status "square" "connected" "Square MCP registered" +set_status() { + local target="$1" + local status="$2" + local details="${3:-}" + + # Validate status + local valid=0 + for s in $VALID_STATUSES; do + if [[ "$s" == "$status" ]]; then + valid=1 + break + fi + done + if [[ $valid -eq 0 ]]; then + log_error "Invalid status '$status'. Must be one of: $VALID_STATUSES" + return 1 + fi + + ensure_local_dir + + local state + state="$(read_state)" + + # Pass state via stdin, target/status/details via env vars — no interpolation + printf '%s' "$state" | \ + CONNECT_TARGET="$target" \ + CONNECT_STATUS="$status" \ + CONNECT_DETAILS="$details" \ + python3 -c " +import json, sys, os, datetime + +state = json.loads(sys.stdin.read()) +target = os.environ['CONNECT_TARGET'] +status = os.environ['CONNECT_STATUS'] +details = os.environ['CONNECT_DETAILS'] + +state[target] = { + 'status': status, + 'details': details, + 'updated_at': datetime.datetime.now(datetime.timezone.utc).isoformat() +} + +# Ensure top-level metadata +if 'generated_at' not in state: + state['generated_at'] = datetime.datetime.now(datetime.timezone.utc).isoformat() +if 'is_fixture' not in state: + state['is_fixture'] = False + +json.dump(state, sys.stdout, indent=2) +" > "${STATE_FILE}.tmp" 2>/dev/null + + mv "${STATE_FILE}.tmp" "$STATE_FILE" + chmod 600 "$STATE_FILE" + log_info "State updated: ${target} → ${status}" +} + +# ── Print status summary (human-readable) ────────────────────────────────── +print_status() { + local state + state="$(read_state)" + + if [[ "$state" == "{}" ]]; then + log_info "No connection state recorded yet." + log_info "Run connect commands to establish integrations." + return 0 + fi + + log_section "Connection Status" + + # Pass state via stdin — no interpolation + printf '%s' "$state" | python3 -c " +import json, sys + +state = json.loads(sys.stdin.read()) +# Remove metadata keys +meta_keys = {'generated_at', 'is_fixture'} +targets = {k: v for k, v in state.items() if k not in meta_keys} + +if not targets: + print(' No targets configured yet.') +else: + status_icons = { + 'connected': '✅', + 'skipped': '⏭️', + 'later': '⏳', + 'error': '❌', + 'offline': '📋', + } + for target, info in sorted(targets.items()): + status = info.get('status', 'unknown') + icon = status_icons.get(status, '❓') + details = info.get('details', '') + updated = info.get('updated_at', '') + display = target.replace('_', ' ').title() + print(f' {icon} {display:25s} {status:10s} {details}') + if updated: + print(f' Updated: {updated}') +" 2>/dev/null + + echo "" +} + +# ── Export state as capability report JSON (compatible with setup-education) ─ +# Produces the same schema as data/fixtures/setup/capability_matrix.json +export_capability_report() { + local output_file="${1:-}" + local state + state="$(read_state)" + + if [[ "$state" == "{}" ]]; then + log_warn "No connection state to export." + return 1 + fi + + local sandbox_name + sandbox_name="$(get_sandbox_name)" + + # Pass state via stdin, sandbox_name via env var — no interpolation + local report + report="$(printf '%s' "$state" | \ + CONNECT_SANDBOX="$sandbox_name" \ + python3 -c " +import json, sys, os, datetime + +state = json.loads(sys.stdin.read()) +sandbox_name = os.environ['CONNECT_SANDBOX'] +meta_keys = {'generated_at', 'is_fixture'} +targets = {k: v for k, v in state.items() if k not in meta_keys} + +capabilities = [] +area_map = { + 'name': 'identity', + 'profile': 'profile', + 'whatsapp': 'channels', + 'email': 'channels', + 'telegram': 'channels', + 'square': 'scheduling', + 'quickbooks': 'books', + 'vagaro': 'scheduling', +} + +for target, info in sorted(targets.items()): + area = area_map.get(target, 'other') + capabilities.append({ + 'area': area, + 'provider': target, + 'status': info.get('status', 'offline'), + 'details': info.get('details', ''), + }) + +report = { + 'salon_name': sandbox_name, + 'is_fixture': False, + 'generated_at': datetime.datetime.now(datetime.timezone.utc).isoformat(), + 'capabilities': capabilities, +} + +json.dump(report, sys.stdout, indent=2) +" 2>/dev/null)" + + if [[ -n "$output_file" ]]; then + echo "$report" > "$output_file" + log_info "Capability report exported to $output_file" + else + echo "$report" + fi +} diff --git a/tests/unit/test_connect_state.py b/tests/unit/test_connect_state.py new file mode 100644 index 0000000..82c20d1 --- /dev/null +++ b/tests/unit/test_connect_state.py @@ -0,0 +1,276 @@ +"""Tests for S7 connect state management and capability report schema. + +Validates: + - Capability state JSON schema matches setup-education fixture format + - Status values are valid (connected | skipped | later | error | offline) + - State file operations (create, read, update) + - Exported capability report matches fixture schema +""" + +from __future__ import annotations + +import json +import os +import pathlib +import tempfile + +import pytest + +# ── Paths ────────────────────────────────────────────────────────────────── +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +_FIXTURES_DIR = _REPO_ROOT / "data" / "fixtures" / "setup" + +# ── Valid status values ──────────────────────────────────────────────────── +VALID_STATUSES = {"connected", "skipped", "later", "error", "offline"} + +# ── Required capability entry fields ─────────────────────────────────────── +REQUIRED_ENTRY_FIELDS = {"area", "provider", "status", "details"} + +# ── Required top-level report fields ─────────────────────────────────────── +REQUIRED_REPORT_FIELDS = {"salon_name", "is_fixture", "capabilities"} + + +# ── Fixture loading tests ────────────────────────────────────────────────── + +@pytest.mark.parametrize("fixture_name", [ + "capability_matrix.json", + "capability_matrix_all_connected.json", + "capability_matrix_with_errors.json", +]) +def test_fixture_schema(fixture_name: str): + """Each fixture file has valid schema.""" + fixture_path = _FIXTURES_DIR / fixture_name + assert fixture_path.exists(), f"Fixture not found: {fixture_path}" + + data = json.loads(fixture_path.read_text(encoding="utf-8")) + + # Top-level fields + for field in REQUIRED_REPORT_FIELDS: + assert field in data, f"Missing required field: {field}" + + # Capabilities array + assert isinstance(data["capabilities"], list) + assert len(data["capabilities"]) > 0 + + # Each capability entry + for i, entry in enumerate(data["capabilities"]): + for field in REQUIRED_ENTRY_FIELDS: + assert field in entry, f"Entry {i} missing field: {field}" + assert entry["status"] in VALID_STATUSES, ( + f"Entry {i} invalid status: {entry['status']!r}" + ) + + +def test_fixture_is_fixture_flag(): + """All fixtures have is_fixture: true.""" + for fixture_path in _FIXTURES_DIR.glob("capability_matrix*.json"): + data = json.loads(fixture_path.read_text(encoding="utf-8")) + assert data.get("is_fixture") is True, ( + f"{fixture_path.name} should have is_fixture: true" + ) + + +# ── Capability state schema tests ────────────────────────────────────────── + +def test_capability_state_schema(): + """Validate capability state JSON schema.""" + state = { + "generated_at": "2026-07-27T00:00:00+00:00", + "is_fixture": False, + "square": { + "status": "connected", + "details": "Square remote MCP registered", + "updated_at": "2026-07-27T00:00:00+00:00", + }, + "whatsapp": { + "status": "connected", + "details": "WhatsApp channel active", + "updated_at": "2026-07-27T00:00:00+00:00", + }, + "quickbooks": { + "status": "skipped", + "details": "Operator skipped", + "updated_at": "2026-07-27T00:00:00+00:00", + }, + "vagaro": { + "status": "error", + "details": "API verification failed", + "updated_at": "2026-07-27T00:00:00+00:00", + }, + } + + # Validate each target + meta_keys = {"generated_at", "is_fixture"} + for key, value in state.items(): + if key in meta_keys: + continue + assert "status" in value, f"Target {key} missing status" + assert value["status"] in VALID_STATUSES, ( + f"Target {key} invalid status: {value['status']!r}" + ) + assert "details" in value, f"Target {key} missing details" + assert "updated_at" in value, f"Target {key} missing updated_at" + + +def test_capability_state_json_serializable(): + """State must be JSON-serializable.""" + state = { + "square": {"status": "connected", "details": "OK", "updated_at": "2026-01-01T00:00:00Z"}, + "whatsapp": {"status": "skipped", "details": "Skipped", "updated_at": "2026-01-01T00:00:00Z"}, + } + # Should not raise + json.dumps(state) + + +# ── Status value tests ───────────────────────────────────────────────────── + +@pytest.mark.parametrize("status", VALID_STATUSES) +def test_valid_status_values(status: str): + """All valid status values are recognized.""" + assert status in VALID_STATUSES + + +@pytest.mark.parametrize("invalid_status", ["pending", "unknown", "active", ""]) +def test_invalid_status_values(invalid_status: str): + """Invalid status values are not in the valid set.""" + assert invalid_status not in VALID_STATUSES + + +# ── Area mapping tests ───────────────────────────────────────────────────── + +AREA_MAP = { + "name": "identity", + "profile": "profile", + "whatsapp": "channels", + "email": "channels", + "telegram": "channels", + "square": "scheduling", + "quickbooks": "books", + "vagaro": "scheduling", +} + + +@pytest.mark.parametrize("target,expected_area", AREA_MAP.items()) +def test_area_mapping(target: str, expected_area: str): + """Each target maps to the correct area.""" + assert AREA_MAP[target] == expected_area + + +# ── .local directory tests ───────────────────────────────────────────────── + +def test_local_dir_gitignored(): + """.local/ must be in .gitignore.""" + gitignore = _REPO_ROOT / ".gitignore" + content = gitignore.read_text(encoding="utf-8") + assert ".local/" in content, ".local/ should be in .gitignore" + + +def test_local_dir_not_tracked(): + """.local/ should not be in git.""" + import subprocess + result = subprocess.run( + ["git", "ls-files", ".local/"], + capture_output=True, text=True, cwd=str(_REPO_ROOT), + ) + assert result.stdout.strip() == "", ".local/ should not be tracked by git" + + +# ── Owner-safe keyword tests ─────────────────────────────────────────────── + +FORBIDDEN_OWNER_KEYWORDS = [ + "terminal", "docker", "nano", "shell", "bash", "sudo", + "apt-get", "yum", "dnf", "pip install", "npm install", +] + + +def test_connect_scripts_no_owner_keywords_in_help(): + """Connect script help output must not contain forbidden keywords as instructions. + + Safety guarantees like 'Owner never receives terminal instructions' are allowed + because they describe what the owner does NOT receive, not instructions to follow. + """ + import subprocess + + script = _REPO_ROOT / "scripts" / "connect.sh" + result = subprocess.run( + ["bash", str(script), "--help"], + capture_output=True, text=True, cwd=str(_REPO_ROOT), + ) + output = result.stdout.lower() + + for keyword in FORBIDDEN_OWNER_KEYWORDS: + # Allow "shell" in the context of "OpenShell" (platform CLI name) + if keyword == "shell": + import re + matches = re.findall(r'(?