Compare commits
2 Commits
69b67498ff
...
d6b74c42f6
| Author | SHA1 | Date | |
|---|---|---|---|
| d6b74c42f6 | |||
| 76df8b7ab6 |
@@ -16,6 +16,9 @@ state/
|
|||||||
*.log
|
*.log
|
||||||
logs/
|
logs/
|
||||||
|
|
||||||
|
# Local operator state (S7 connect state — gitignored)
|
||||||
|
.local/
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
# Approved structure: document targets only; do not invoke unimplemented scripts.
|
# 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 \
|
.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:
|
help:
|
||||||
@echo "Salon_Assistant (Lumina) — $(shell cat VERSION 2>/dev/null || echo 'unknown')"
|
@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-s4 - S4 only: sandbox verify/onboard"
|
||||||
@echo " make install-s5 - S5 only: policy overlays + skills sync"
|
@echo " make install-s5 - S5 only: policy overlays + skills sync"
|
||||||
@echo ""
|
@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 "Not yet implemented (stubbed):"
|
||||||
@echo " make upgrade - product upgrade"
|
@echo " make upgrade - product upgrade"
|
||||||
@echo " make sync-design - list design pack paths"
|
@echo " make sync-design - list design pack paths"
|
||||||
@@ -66,6 +78,32 @@ install-s5:
|
|||||||
doctor:
|
doctor:
|
||||||
@bash scripts/doctor.sh
|
@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 ────────────────────────────────────────────────────────
|
# ── Stubbed targets ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
upgrade:
|
upgrade:
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"salon_name": "Lumina Hair Studio & Spa",
|
||||||
|
"is_fixture": true,
|
||||||
|
"generated_at": "2026-07-27T00:00:00Z",
|
||||||
|
"note": "Fixture data for demo capability report — not live connection state",
|
||||||
|
"capabilities": [
|
||||||
|
{
|
||||||
|
"area": "identity",
|
||||||
|
"provider": "assistant_name",
|
||||||
|
"status": "connected",
|
||||||
|
"details": "Assistant named 'Lumina'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "profile",
|
||||||
|
"provider": "owner_profile",
|
||||||
|
"status": "connected",
|
||||||
|
"details": "Business name, timezone, hours, and hard rules configured"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "channels",
|
||||||
|
"provider": "whatsapp",
|
||||||
|
"status": "connected",
|
||||||
|
"details": "WhatsApp channel active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "channels",
|
||||||
|
"provider": "email",
|
||||||
|
"status": "skipped",
|
||||||
|
"details": "Owner chose to skip email channel for now"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "channels",
|
||||||
|
"provider": "telegram",
|
||||||
|
"status": "later",
|
||||||
|
"details": "Planned for future setup"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "scheduling",
|
||||||
|
"provider": "vagaro",
|
||||||
|
"status": "offline",
|
||||||
|
"details": "Not yet connected — using fixture appointment data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "scheduling",
|
||||||
|
"provider": "square",
|
||||||
|
"status": "skipped",
|
||||||
|
"details": "Owner uses Vagaro, not Square"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "books",
|
||||||
|
"provider": "quickbooks_online",
|
||||||
|
"status": "offline",
|
||||||
|
"details": "Not yet connected — using fixture financial data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "expectations",
|
||||||
|
"provider": "boundaries",
|
||||||
|
"status": "connected",
|
||||||
|
"details": "Owner reviewed draft-only, no-pay, no-publish boundaries"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"salon_name": "Lumina Hair Studio & Spa",
|
||||||
|
"is_fixture": true,
|
||||||
|
"generated_at": "2026-07-27T00:00:00Z",
|
||||||
|
"note": "Fixture data — all capabilities connected (demo scenario)",
|
||||||
|
"capabilities": [
|
||||||
|
{
|
||||||
|
"area": "identity",
|
||||||
|
"provider": "assistant_name",
|
||||||
|
"status": "connected",
|
||||||
|
"details": "Assistant named 'Lumina'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "profile",
|
||||||
|
"provider": "owner_profile",
|
||||||
|
"status": "connected",
|
||||||
|
"details": "Full profile configured"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "channels",
|
||||||
|
"provider": "whatsapp",
|
||||||
|
"status": "connected",
|
||||||
|
"details": "WhatsApp channel active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "channels",
|
||||||
|
"provider": "email",
|
||||||
|
"status": "connected",
|
||||||
|
"details": "Email channel active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "channels",
|
||||||
|
"provider": "telegram",
|
||||||
|
"status": "connected",
|
||||||
|
"details": "Telegram channel active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "scheduling",
|
||||||
|
"provider": "vagaro",
|
||||||
|
"status": "connected",
|
||||||
|
"details": "Vagaro API connected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "books",
|
||||||
|
"provider": "quickbooks_online",
|
||||||
|
"status": "connected",
|
||||||
|
"details": "QuickBooks Online read-only connected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "expectations",
|
||||||
|
"provider": "boundaries",
|
||||||
|
"status": "connected",
|
||||||
|
"details": "Boundaries reviewed and confirmed"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"salon_name": "Lumina Hair Studio & Spa",
|
||||||
|
"is_fixture": true,
|
||||||
|
"generated_at": "2026-07-27T00:00:00Z",
|
||||||
|
"note": "Fixture data — includes error state for testing",
|
||||||
|
"capabilities": [
|
||||||
|
{
|
||||||
|
"area": "identity",
|
||||||
|
"provider": "assistant_name",
|
||||||
|
"status": "connected",
|
||||||
|
"details": "Assistant named 'Lumina'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "profile",
|
||||||
|
"provider": "owner_profile",
|
||||||
|
"status": "connected",
|
||||||
|
"details": "Profile configured"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "channels",
|
||||||
|
"provider": "whatsapp",
|
||||||
|
"status": "error",
|
||||||
|
"details": "WhatsApp webhook not responding — operator needs to check"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "channels",
|
||||||
|
"provider": "email",
|
||||||
|
"status": "skipped",
|
||||||
|
"details": "Skipped"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "scheduling",
|
||||||
|
"provider": "vagaro",
|
||||||
|
"status": "offline",
|
||||||
|
"details": "Not yet connected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "books",
|
||||||
|
"provider": "quickbooks_online",
|
||||||
|
"status": "error",
|
||||||
|
"details": "QBO OAuth token expired — operator needs to refresh"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+87
-2
@@ -1,6 +1,6 @@
|
|||||||
# Install
|
# Install
|
||||||
|
|
||||||
**Status:** Stages S0–S6 implemented. S7 pending.
|
**Status:** Stages S0–S7 implemented.
|
||||||
|
|
||||||
## Stages
|
## Stages
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
| S4 | Host script | Sandbox verify (attach) or onboard (clean host) | ✅ Implemented |
|
| S4 | Host script | Sandbox verify (attach) or onboard (clean host) | ✅ Implemented |
|
||||||
| S5 | Host script | Policy overlays + skills sync via nemohermes | ✅ Implemented |
|
| S5 | Host script | Policy overlays + skills sync via nemohermes | ✅ Implemented |
|
||||||
| S6 | Host script | Doctor green | ✅ 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)
|
## Platform commands (normative)
|
||||||
|
|
||||||
@@ -165,6 +165,91 @@ make install-s3-s5
|
|||||||
- See [design/scenarios.md](../design/scenarios.md) (S1–S5) for operational scenarios.
|
- See [design/scenarios.md](../design/scenarios.md) (S1–S5) for operational scenarios.
|
||||||
- See [OPERATIONS.md](OPERATIONS.md) for day-2 operator commands.
|
- 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
|
## UAT host notes
|
||||||
|
|
||||||
This repository was tested on a live host with:
|
This repository was tested on a live host with:
|
||||||
|
|||||||
+30
-10
@@ -1,27 +1,47 @@
|
|||||||
# Owner Setup UX (post-install)
|
# Owner Setup UX (post-install)
|
||||||
|
|
||||||
**Status:** Outline. Scenarios: [design/scenarios.md](../design/scenarios.md).
|
**Status:** Skill implemented (fixtures only). See [skills/setup-education/](../skills/setup-education/). Scenarios: [design/scenarios.md](../design/scenarios.md).
|
||||||
|
|
||||||
## Prerequisite
|
## 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
|
## Flow
|
||||||
|
|
||||||
1. **Name the assistant** → profile/sandbox name.
|
1. **Name the assistant** → profile/sandbox name.
|
||||||
2. **Profile intake** — business, timezone, hours, priorities, hard rules.
|
2. **Profile intake** — business, timezone, hours, priorities, hard rules.
|
||||||
3. **Channels** — WhatsApp, Email, Telegram (connect / skip / later).
|
3. **Channels** — WhatsApp, Email, Telegram (connect / skip / later).
|
||||||
4. **Scheduling** — owner’s Vagaro and/or Square.
|
4. **Scheduling** — owner's Vagaro and/or Square.
|
||||||
5. **Books** — owner’s QuickBooks Online.
|
5. **Books** — owner's QuickBooks Online.
|
||||||
6. **Expectations** — draft-only client send; no auto-publish; no inventing live data.
|
6. **Expectations** — draft-only client send; no auto-publish; no inventing live data.
|
||||||
7. **Capability report** — plain language.
|
7. **Capability report** — plain language.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- Owner never receives terminal/Docker/editor instructions.
|
- Owner never receives terminal/Docker/editor instructions.
|
||||||
- Secrets via operator `scripts/connect-*.sh` + OpenShell providers / NemoClaw channel flows.
|
- Secrets via operator `scripts/connect-*.sh` + OpenShell providers / NemoClaw channel flows.
|
||||||
- Agent explains vendor browser steps only.
|
- 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
|
## Related
|
||||||
|
|
||||||
[design/use-cases.md](../design/use-cases.md) family E · [design/mcp-integrations.md](../design/mcp-integrations.md)
|
[design/use-cases.md](../design/use-cases.md) family E · [design/mcp-integrations.md](../design/mcp-integrations.md)
|
||||||
|
|||||||
@@ -1,10 +1,47 @@
|
|||||||
# Owner channels (WhatsApp, Email, Telegram)
|
# Owner channels (WhatsApp, Email, Telegram)
|
||||||
|
|
||||||
**Status:** Outline.
|
**Status:** Connect script implemented.
|
||||||
|
|
||||||
- MVP owner ↔ assistant channels: **WhatsApp, Email, Telegram**.
|
- MVP owner ↔ assistant channels: **WhatsApp, Email, Telegram**.
|
||||||
- Configured via **NemoClaw Hermes channel commands** (`nemohermes … channels add`, rebuild when required).
|
- Configured via **NemoClaw Hermes channel commands** (`nemohermes … channels add`, rebuild when required).
|
||||||
- Allowlists for who may talk to the bot.
|
- Allowlists for who may talk to the bot.
|
||||||
- Owner never configures via host shell recipes in chat.
|
- 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 <sandbox> channels add <channel>` 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).
|
See [design/use-cases.md](../../design/use-cases.md) family C · [docs/SETUP_UX.md](../SETUP_UX.md).
|
||||||
|
|||||||
@@ -1,8 +1,59 @@
|
|||||||
# QuickBooks Online
|
# 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.
|
- **Agent path:** local QBO MCP on Docker network with Hermes.
|
||||||
- **Allow:** P&L/reports, invoice/bill/vendor/customer read, company info.
|
- **Allow:** P&L/reports, invoice/bill/vendor/customer read, company info.
|
||||||
- **Deny:** payment/bill_payment tools; write/update/delete off for MVP.
|
- **Deny:** payment/bill_payment tools; write/update/delete off for MVP.
|
||||||
- **Connect:** operator `scripts/connect/connect-quickbooks.sh` (build).
|
- **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`
|
||||||
|
|||||||
@@ -1,8 +1,48 @@
|
|||||||
# Square
|
# 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.
|
- **Agent path:** remote Square MCP (`mcp.squareup.com`).
|
||||||
- **Allow:** bookings, customers, catalog, inventory/location reads.
|
- **Allow:** bookings, customers, catalog, inventory/location reads.
|
||||||
- **Deny:** payments, refunds, cards, checkout, payouts.
|
- **Deny:** payments, refunds, cards, checkout, payouts.
|
||||||
- **Connect:** operator `scripts/connect/connect-square.sh` (build) + owner browser OAuth.
|
- **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` | |
|
||||||
|
|||||||
@@ -1,8 +1,41 @@
|
|||||||
# Vagaro
|
# 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.
|
- **No public MCP** — REST + webhooks.
|
||||||
- Appointments, clients, services, staff.
|
- Appointments, clients, services, staff.
|
||||||
- No unofficial scrape.
|
- No unofficial scrape.
|
||||||
- **Connect:** operator `scripts/connect/connect-vagaro.sh` (build).
|
- **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://<your-host>:9876/vagaro/webhook`
|
||||||
|
|||||||
+29
-3
@@ -1,6 +1,6 @@
|
|||||||
# Host scripts
|
# Host scripts
|
||||||
|
|
||||||
**Status:** S0b–S6 implemented. S7 pending.
|
**Status:** S0b–S7 implemented.
|
||||||
|
|
||||||
All scripts wrap **`nemohermes` / `openshell` / Docker**. No parallel control API.
|
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.sh` | Staged installer (S0b–S5) | ✅ S0b–S5 |
|
||||||
| `install/s1-env.sh` | S1: create/validate `.env` | ✅ S1 |
|
| `install/s1-env.sh` | S1: create/validate `.env` | ✅ S1 |
|
||||||
| `install/s2-models.sh` | S2: model + vision config + smoke | ✅ S2 |
|
| `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 |
|
| `install/s5-policy-skills.sh` | S5: policy overlays + skills sync | ✅ S5 |
|
||||||
| `doctor.sh` | Health checks (Docker, CLIs, sandbox, policy, skills, inference) | ✅ S6 |
|
| `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 |
|
| `upgrade.sh` | Snapshot, pull pins, migrate, re-apply policy, doctor | ⏳ Pending |
|
||||||
| `connect/*.sh` | Operator connect helpers (Square, QBO, Vagaro, channels) | ⏳ Pending |
|
|
||||||
|
|
||||||
## Shared library
|
## 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/common.sh` | Logging, CLI detection, env loading, repo root |
|
||||||
| `lib/env.sh` | `.env` validation and creation helpers |
|
| `lib/env.sh` | `.env` validation and creation helpers |
|
||||||
| `lib/vision_smoke.sh` | Vision capability smoke test |
|
| `lib/vision_smoke.sh` | Vision capability smoke test |
|
||||||
|
| `lib/connect_state.sh` | S7: Local capability state management |
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -46,6 +52,14 @@ All scripts wrap **`nemohermes` / `openshell` / Docker**. No parallel control AP
|
|||||||
./scripts/doctor.sh # human-readable
|
./scripts/doctor.sh # human-readable
|
||||||
./scripts/doctor.sh --json # machine-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
|
# Or via Make
|
||||||
make bootstrap
|
make bootstrap
|
||||||
make install
|
make install
|
||||||
@@ -54,8 +68,20 @@ make install-s2
|
|||||||
make install-s3-s5
|
make install-s3-s5
|
||||||
make install-s5
|
make install-s5
|
||||||
make doctor
|
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
|
## Design reference
|
||||||
|
|
||||||
See [docs/INSTALL.md](../docs/INSTALL.md), [docs/UPGRADE.md](../docs/UPGRADE.md), [design/updates-lifecycle.md](../design/updates-lifecycle.md).
|
See [docs/INSTALL.md](../docs/INSTALL.md), [docs/UPGRADE.md](../docs/UPGRADE.md), [design/updates-lifecycle.md](../design/updates-lifecycle.md).
|
||||||
|
|||||||
@@ -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 <<EOF
|
||||||
|
Usage: $(basename "$0") [SUBCOMMAND] [OPTIONS]
|
||||||
|
|
||||||
|
S7: Operator connect helpers — wire the owner's SaaS and channels.
|
||||||
|
|
||||||
|
Safety model: --dry-run is the default. Use --apply to perform mutations.
|
||||||
|
--dry-run Preview actions without executing (default)
|
||||||
|
--apply Execute mutations (prompts for credentials when needed)
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
status Show current connection status
|
||||||
|
name [--dry-run|--apply] Name / profile setup (sandbox rename guidance)
|
||||||
|
channels [--target <ch>] 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 <name> 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."
|
||||||
@@ -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 <<EOF
|
||||||
|
Usage: $(basename "$0") [OPTIONS]
|
||||||
|
|
||||||
|
Connect messaging channels via nemohermes.
|
||||||
|
|
||||||
|
Channels:
|
||||||
|
whatsapp WhatsApp Business API channel
|
||||||
|
email Email channel (thread-based interaction)
|
||||||
|
telegram Telegram bot channel
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--dry-run Preview only (default)
|
||||||
|
--apply Execute mutations
|
||||||
|
--target <channel> 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 <ID> --verify-token <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 <EMAIL>"
|
||||||
|
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 <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."
|
||||||
@@ -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 <<EOF
|
||||||
|
Usage: $(basename "$0") [OPTIONS]
|
||||||
|
|
||||||
|
Name / profile connect helper.
|
||||||
|
|
||||||
|
Sets the assistant name (sandbox display identity) and verifies the profile
|
||||||
|
is configured. The assistant name is stored in LUMINA_SANDBOX in .env.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--dry-run Preview only (default)
|
||||||
|
--apply Execute mutations
|
||||||
|
--help Show this help
|
||||||
|
|
||||||
|
Safety:
|
||||||
|
--dry-run is the default. Use --apply to perform mutations.
|
||||||
|
Renaming an existing sandbox may require a rebuild.
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--dry-run)
|
||||||
|
DRY_RUN=1
|
||||||
|
APPLY=0
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--apply)
|
||||||
|
DRY_RUN=0
|
||||||
|
APPLY=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log_error "Unknown argument: $1"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Load .env ──────────────────────────────────────────────────────────────
|
||||||
|
load_env
|
||||||
|
|
||||||
|
SANDBOX_NAME="$(get_sandbox_name)"
|
||||||
|
|
||||||
|
log_section "Name / Profile Setup"
|
||||||
|
|
||||||
|
# ── Current state ──────────────────────────────────────────────────────────
|
||||||
|
current_status="$(get_status "name")"
|
||||||
|
|
||||||
|
if [[ -n "$current_status" ]]; then
|
||||||
|
log_info "Current name status: $current_status"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Current sandbox name: $SANDBOX_NAME"
|
||||||
|
|
||||||
|
# ── Check sandbox exists ───────────────────────────────────────────────────
|
||||||
|
if nemohermes_available; then
|
||||||
|
if nemohermes "$SANDBOX_NAME" status &>/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 <new-name> 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."
|
||||||
@@ -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 <<EOF
|
||||||
|
Usage: $(basename "$0") [OPTIONS]
|
||||||
|
|
||||||
|
Connect QuickBooks Online via local MCP.
|
||||||
|
|
||||||
|
QBO MCP:
|
||||||
|
Image: $QBO_MCP_IMAGE
|
||||||
|
Container: $QBO_MCP_CONTAINER
|
||||||
|
Network: $QBO_MCP_NETWORK (same Docker network as Hermes)
|
||||||
|
Type: Local MCP (stdio or network-attached)
|
||||||
|
|
||||||
|
Allowed tools (read-only):
|
||||||
|
reports, search/get invoices, bills, vendors, customers, company info
|
||||||
|
|
||||||
|
Denied tools:
|
||||||
|
create_payment, bill_payment, write/update/delete operations
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--dry-run Preview only (default)
|
||||||
|
--apply Execute mutations (prompts for QBO credentials)
|
||||||
|
--help Show this help
|
||||||
|
|
||||||
|
Safety:
|
||||||
|
--dry-run is the default. Use --apply to perform mutations.
|
||||||
|
QBO credentials are stored via OpenShell provider store — never in .env.
|
||||||
|
Payment tools are explicitly denied in the MCP tool filter.
|
||||||
|
Write/update/delete operations are disabled for MVP.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
$(basename "$0") --dry-run
|
||||||
|
$(basename "$0") --apply
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--dry-run)
|
||||||
|
DRY_RUN=1
|
||||||
|
APPLY=0
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--apply)
|
||||||
|
DRY_RUN=0
|
||||||
|
APPLY=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log_error "Unknown argument: $1"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Load .env ──────────────────────────────────────────────────────────────
|
||||||
|
load_env
|
||||||
|
|
||||||
|
SANDBOX_NAME="$(get_sandbox_name)"
|
||||||
|
|
||||||
|
log_section "QuickBooks Online Connect"
|
||||||
|
|
||||||
|
# ── Current state ──────────────────────────────────────────────────────────
|
||||||
|
current_status="$(get_status "quickbooks")"
|
||||||
|
if [[ -n "$current_status" ]]; then
|
||||||
|
log_info "Current status: $current_status"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Check prerequisites ────────────────────────────────────────────────────
|
||||||
|
require_cmd nemohermes "Install nemohermes CLI (part of NemoClaw platform)"
|
||||||
|
require_cmd docker "Docker required for local MCP container"
|
||||||
|
|
||||||
|
if ! nemohermes "$SANDBOX_NAME" status &>/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-* <VALUES>"
|
||||||
|
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-* <VALUES>"
|
||||||
|
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=<CLIENT_ID> \\"
|
||||||
|
log_info " -e QB_CLIENT_SECRET=<CLIENT_SECRET> \\"
|
||||||
|
log_info " -e QB_ACCESS_TOKEN=<ACCESS_TOKEN> \\"
|
||||||
|
log_info " -e QB_REALM_ID=<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."
|
||||||
@@ -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 <<EOF
|
||||||
|
Usage: $(basename "$0") [OPTIONS]
|
||||||
|
|
||||||
|
Connect Square via remote MCP.
|
||||||
|
|
||||||
|
Square MCP:
|
||||||
|
URL: $SQUARE_MCP_URL
|
||||||
|
Type: Remote MCP (HTTP/SSE)
|
||||||
|
|
||||||
|
Allowed tools (read-only):
|
||||||
|
bookings, customers, catalog, inventory, locations
|
||||||
|
|
||||||
|
Denied tools:
|
||||||
|
payments, refunds, cards, checkout, payouts
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--dry-run Preview only (default)
|
||||||
|
--apply Execute mutations (prompts for Square access token)
|
||||||
|
--help Show this help
|
||||||
|
|
||||||
|
Safety:
|
||||||
|
--dry-run is the default. Use --apply to perform mutations.
|
||||||
|
The Square access token is stored via OpenShell provider store — never in .env.
|
||||||
|
Payment tools are explicitly denied in the MCP tool filter.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
$(basename "$0") --dry-run
|
||||||
|
$(basename "$0") --apply
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--dry-run)
|
||||||
|
DRY_RUN=1
|
||||||
|
APPLY=0
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--apply)
|
||||||
|
DRY_RUN=0
|
||||||
|
APPLY=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log_error "Unknown argument: $1"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Load .env ──────────────────────────────────────────────────────────────
|
||||||
|
load_env
|
||||||
|
|
||||||
|
SANDBOX_NAME="$(get_sandbox_name)"
|
||||||
|
|
||||||
|
log_section "Square Connect"
|
||||||
|
|
||||||
|
# ── Current state ──────────────────────────────────────────────────────────
|
||||||
|
current_status="$(get_status "square")"
|
||||||
|
if [[ -n "$current_status" ]]; then
|
||||||
|
log_info "Current status: $current_status"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 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
|
||||||
|
|
||||||
|
# ── 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 <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 <TOKEN>"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_warn "openshell not available. Cannot store token in provider store."
|
||||||
|
log_warn "Store manually: openshell provider set square-access-token <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."
|
||||||
@@ -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 <<EOF
|
||||||
|
Usage: $(basename "$0") [OPTIONS]
|
||||||
|
|
||||||
|
Connect Vagaro via REST API + webhooks.
|
||||||
|
|
||||||
|
Vagaro integration:
|
||||||
|
API Base: $VAGARO_API_BASE
|
||||||
|
Type: REST + webhooks (no public MCP)
|
||||||
|
Webhook port: $VAGARO_WEBHOOK_PORT
|
||||||
|
|
||||||
|
Allowed operations:
|
||||||
|
appointments, clients, services, staff
|
||||||
|
|
||||||
|
Denied:
|
||||||
|
No scrape, no unofficial access
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--dry-run Preview only (default)
|
||||||
|
--apply Execute mutations (prompts for Vagaro credentials)
|
||||||
|
--help Show this help
|
||||||
|
|
||||||
|
Safety:
|
||||||
|
--dry-run is the default. Use --apply to perform mutations.
|
||||||
|
Vagaro credentials are stored via OpenShell provider store — never in .env.
|
||||||
|
No unofficial scraping — only documented REST API.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
$(basename "$0") --dry-run
|
||||||
|
$(basename "$0") --apply
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--dry-run)
|
||||||
|
DRY_RUN=1
|
||||||
|
APPLY=0
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--apply)
|
||||||
|
DRY_RUN=0
|
||||||
|
APPLY=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log_error "Unknown argument: $1"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Load .env ──────────────────────────────────────────────────────────────
|
||||||
|
load_env
|
||||||
|
|
||||||
|
SANDBOX_NAME="$(get_sandbox_name)"
|
||||||
|
|
||||||
|
log_section "Vagaro Connect"
|
||||||
|
|
||||||
|
# ── Current state ──────────────────────────────────────────────────────────
|
||||||
|
current_status="$(get_status "vagaro")"
|
||||||
|
if [[ -n "$current_status" ]]; then
|
||||||
|
log_info "Current status: $current_status"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 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
|
||||||
|
|
||||||
|
# ── 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-* <VALUES>"
|
||||||
|
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://<your-host>:$VAGARO_WEBHOOK_PORT/vagaro/webhook"
|
||||||
|
log_info " Or add to deploy/compose/docker-compose.yml for managed lifecycle."
|
||||||
|
|
||||||
|
log_info "Vagaro connect complete."
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -13,6 +13,9 @@ Shared deterministic library used by Salon_Assistant skills. All code here is
|
|||||||
| `domain.py` | ✅ | Domain types: `Appointment`, `Gap`, `DayBoard`, `AppointmentStatus` |
|
| `domain.py` | ✅ | Domain types: `Appointment`, `Gap`, `DayBoard`, `AppointmentStatus` |
|
||||||
| `board_builder.py` | ✅ | Deterministic board builder: gaps, confirmation flags, formatting |
|
| `board_builder.py` | ✅ | Deterministic board builder: gaps, confirmation flags, formatting |
|
||||||
| `providers/scheduling/fixture_provider.py` | ✅ | Fixture JSON loader for scheduling data |
|
| `providers/scheduling/fixture_provider.py` | ✅ | Fixture JSON loader for scheduling data |
|
||||||
|
| `setup/capability_report.py` | ✅ | Capability report domain model + builder (E6) |
|
||||||
|
| `setup/lesson_catalog.py` | ✅ | Static setup education lessons (E1) |
|
||||||
|
| `providers/setup/fixture_provider.py` | ✅ | Fixture JSON loader for capability state |
|
||||||
| `providers/scheduling/` | ⏳ | Vagaro / Square adapters (future) |
|
| `providers/scheduling/` | ⏳ | Vagaro / Square adapters (future) |
|
||||||
| `providers/books/` | ⏳ | QuickBooks Online adapters (future) |
|
| `providers/books/` | ⏳ | QuickBooks Online adapters (future) |
|
||||||
| `providers/mcp/` | ⏳ | MCP client helpers / allowlist metadata (future) |
|
| `providers/mcp/` | ⏳ | MCP client helpers / allowlist metadata (future) |
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Setup education providers — fixture loaders for capability state."""
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""Fixture provider for setup/capability data.
|
||||||
|
|
||||||
|
Loads capability state fixtures from JSON files under data/fixtures/setup/.
|
||||||
|
All output is labeled `is_fixture: True` so the owner never sees
|
||||||
|
silent fake live data.
|
||||||
|
|
||||||
|
This is the *only* data source for the capability report until live
|
||||||
|
connection state is available.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from lumina_skills.setup.capability_report import (
|
||||||
|
CapabilityEntry,
|
||||||
|
CapabilityReport,
|
||||||
|
ConnectionStatus,
|
||||||
|
build_capability_report,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mapping from fixture status strings to domain enum.
|
||||||
|
_STATUS_MAP: dict[str, ConnectionStatus] = {
|
||||||
|
"connected": ConnectionStatus.CONNECTED,
|
||||||
|
"skipped": ConnectionStatus.SKIPPED,
|
||||||
|
"later": ConnectionStatus.LATER,
|
||||||
|
"error": ConnectionStatus.ERROR,
|
||||||
|
"offline": ConnectionStatus.OFFLINE,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_status(raw: str) -> ConnectionStatus:
|
||||||
|
"""Convert a fixture status string to ConnectionStatus.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the status string is not recognized.
|
||||||
|
"""
|
||||||
|
key = raw.lower().strip()
|
||||||
|
if key not in _STATUS_MAP:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown capability status {raw!r}. "
|
||||||
|
f"Expected one of: {', '.join(sorted(_STATUS_MAP))}"
|
||||||
|
)
|
||||||
|
return _STATUS_MAP[key]
|
||||||
|
|
||||||
|
|
||||||
|
def load_capability_fixture(fixture_path: str | pathlib.Path) -> CapabilityReport:
|
||||||
|
"""Load a capability report from a fixture JSON file.
|
||||||
|
|
||||||
|
Expected top-level shape:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"salon_name": "Lumina Hair Studio & Spa",
|
||||||
|
"is_fixture": true,
|
||||||
|
"capabilities": [
|
||||||
|
{
|
||||||
|
"area": "channels",
|
||||||
|
"provider": "whatsapp",
|
||||||
|
"status": "connected",
|
||||||
|
"details": "WhatsApp channel active"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fixture_path: Path to a JSON fixture file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A fully populated CapabilityReport.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: If the fixture file does not exist.
|
||||||
|
ValueError: If the fixture JSON is malformed or has unknown status.
|
||||||
|
"""
|
||||||
|
path = pathlib.Path(fixture_path)
|
||||||
|
if not path.exists():
|
||||||
|
raise FileNotFoundError(f"Fixture not found: {path}")
|
||||||
|
|
||||||
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
salon_name = raw.get("salon_name", "Unknown Salon")
|
||||||
|
is_fixture = raw.get("is_fixture", True)
|
||||||
|
|
||||||
|
capabilities: list[CapabilityEntry] = []
|
||||||
|
for i, cap_raw in enumerate(raw.get("capabilities", [])):
|
||||||
|
# Validate required fields with clear error messages.
|
||||||
|
for required_field in ("area", "provider", "status"):
|
||||||
|
if required_field not in cap_raw:
|
||||||
|
raise ValueError(
|
||||||
|
f"Capability entry {i} missing required field {required_field!r}. "
|
||||||
|
f"Each entry must have 'area', 'provider', and 'status'."
|
||||||
|
)
|
||||||
|
capabilities.append(CapabilityEntry(
|
||||||
|
area=cap_raw["area"],
|
||||||
|
provider=cap_raw["provider"],
|
||||||
|
status=_parse_status(cap_raw["status"]),
|
||||||
|
details=cap_raw.get("details", ""),
|
||||||
|
))
|
||||||
|
|
||||||
|
return build_capability_report(
|
||||||
|
capabilities=capabilities,
|
||||||
|
salon_name=salon_name,
|
||||||
|
is_fixture=is_fixture,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_fixture_metadata(fixture_path: str | pathlib.Path) -> dict[str, Any]:
|
||||||
|
"""Load non-capability metadata from a fixture file.
|
||||||
|
|
||||||
|
Returns salon_name, is_fixture, generated_at, note, etc.
|
||||||
|
"""
|
||||||
|
path = pathlib.Path(fixture_path)
|
||||||
|
if not path.exists():
|
||||||
|
raise FileNotFoundError(f"Fixture not found: {path}")
|
||||||
|
|
||||||
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
return {
|
||||||
|
"salon_name": raw.get("salon_name", "Unknown Salon"),
|
||||||
|
"is_fixture": raw.get("is_fixture", True),
|
||||||
|
"generated_at": raw.get("generated_at", ""),
|
||||||
|
"note": raw.get("note", ""),
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Setup education — deterministic modules for E1 setup-education and E6 capability report."""
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"""Deterministic capability report domain model and builder.
|
||||||
|
|
||||||
|
Covers use cases E6 (Capability report) and E7 (Degraded mode).
|
||||||
|
All logic is deterministic — no model inference, no network calls.
|
||||||
|
See design/det-vs-inf.md for the deterministic boundary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
# ── Status enum ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ConnectionStatus(str, Enum):
|
||||||
|
"""Plain-language connection status for a capability area.
|
||||||
|
|
||||||
|
These map directly to what the owner sees in the capability report.
|
||||||
|
"""
|
||||||
|
CONNECTED = "connected"
|
||||||
|
SKIPPED = "skipped"
|
||||||
|
LATER = "later"
|
||||||
|
ERROR = "error"
|
||||||
|
OFFLINE = "offline" # fixtures/demo mode — never silent as live
|
||||||
|
|
||||||
|
|
||||||
|
# ── Plain-language labels ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_STATUS_LABELS: dict[ConnectionStatus, str] = {
|
||||||
|
ConnectionStatus.CONNECTED: "✅ Connected",
|
||||||
|
ConnectionStatus.SKIPPED: "⏭️ Skipped",
|
||||||
|
ConnectionStatus.LATER: "⏳ Set up later",
|
||||||
|
ConnectionStatus.ERROR: "❌ Error — needs attention",
|
||||||
|
ConnectionStatus.OFFLINE: "📋 Offline / fixtures",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def status_label(status: ConnectionStatus) -> str:
|
||||||
|
"""Return the owner-facing emoji label for a connection status."""
|
||||||
|
return _STATUS_LABELS[status]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Domain types ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CapabilityEntry:
|
||||||
|
"""A single capability area in the report (e.g., scheduling, books, channels).
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
area: Category — "scheduling", "books", "channels", "profile", "identity".
|
||||||
|
provider: Specific provider name — "vagaro", "square", "qbo", "whatsapp", etc.
|
||||||
|
status: Current connection status.
|
||||||
|
details: Optional additional context for the owner.
|
||||||
|
"""
|
||||||
|
area: str
|
||||||
|
provider: str
|
||||||
|
status: ConnectionStatus
|
||||||
|
details: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def label(self) -> str:
|
||||||
|
"""Owner-facing status label with emoji."""
|
||||||
|
return status_label(self.status)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CapabilityReport:
|
||||||
|
"""The complete capability report for a salon.
|
||||||
|
|
||||||
|
This is the structured output that the setup-education skill presents.
|
||||||
|
All data is deterministic — no model inference.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
salon_name: Display name of the salon.
|
||||||
|
is_fixture: True when data comes from fixtures (demo mode).
|
||||||
|
capabilities: List of capability entries.
|
||||||
|
"""
|
||||||
|
salon_name: str
|
||||||
|
is_fixture: bool
|
||||||
|
capabilities: list[CapabilityEntry] = field(default_factory=list)
|
||||||
|
|
||||||
|
# ── Aggregation helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def connected_count(self) -> int:
|
||||||
|
"""Number of capabilities with CONNECTED status."""
|
||||||
|
return sum(1 for c in self.capabilities if c.status == ConnectionStatus.CONNECTED)
|
||||||
|
|
||||||
|
def offline_count(self) -> int:
|
||||||
|
"""Number of capabilities with OFFLINE status."""
|
||||||
|
return sum(1 for c in self.capabilities if c.status == ConnectionStatus.OFFLINE)
|
||||||
|
|
||||||
|
def skipped_count(self) -> int:
|
||||||
|
"""Number of capabilities with SKIPPED or LATER status."""
|
||||||
|
return sum(
|
||||||
|
1 for c in self.capabilities
|
||||||
|
if c.status in (ConnectionStatus.SKIPPED, ConnectionStatus.LATER)
|
||||||
|
)
|
||||||
|
|
||||||
|
def error_count(self) -> int:
|
||||||
|
"""Number of capabilities with ERROR status."""
|
||||||
|
return sum(1 for c in self.capabilities if c.status == ConnectionStatus.ERROR)
|
||||||
|
|
||||||
|
def all_connected(self) -> bool:
|
||||||
|
"""True if there is at least one capability and every capability is CONNECTED.
|
||||||
|
|
||||||
|
Returns False for an empty report — a salon with zero capabilities
|
||||||
|
is not "all connected."
|
||||||
|
"""
|
||||||
|
if not self.capabilities:
|
||||||
|
return False
|
||||||
|
return all(c.status == ConnectionStatus.CONNECTED for c in self.capabilities)
|
||||||
|
|
||||||
|
def has_errors(self) -> bool:
|
||||||
|
"""True if any capability has ERROR status."""
|
||||||
|
return any(c.status == ConnectionStatus.ERROR for c in self.capabilities)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Serialize to a plain dict for JSON output."""
|
||||||
|
return {
|
||||||
|
"salon_name": self.salon_name,
|
||||||
|
"is_fixture": self.is_fixture,
|
||||||
|
"capabilities": [
|
||||||
|
{
|
||||||
|
"area": c.area,
|
||||||
|
"provider": c.provider,
|
||||||
|
"status": c.status.value,
|
||||||
|
"label": c.label,
|
||||||
|
"details": c.details,
|
||||||
|
}
|
||||||
|
for c in self.capabilities
|
||||||
|
],
|
||||||
|
"summary": {
|
||||||
|
"connected": self.connected_count(),
|
||||||
|
"offline": self.offline_count(),
|
||||||
|
"skipped_or_later": self.skipped_count(),
|
||||||
|
"errors": self.error_count(),
|
||||||
|
"all_connected": self.all_connected(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Builder ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def build_capability_report(
|
||||||
|
capabilities: list[CapabilityEntry],
|
||||||
|
salon_name: str,
|
||||||
|
is_fixture: bool = False,
|
||||||
|
) -> CapabilityReport:
|
||||||
|
"""Build a CapabilityReport from a list of CapabilityEntry objects.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
capabilities: List of capability entries (from fixtures or live state).
|
||||||
|
salon_name: Display name of the salon.
|
||||||
|
is_fixture: True when data comes from fixtures (demo mode).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A fully populated CapabilityReport.
|
||||||
|
"""
|
||||||
|
return CapabilityReport(
|
||||||
|
salon_name=salon_name,
|
||||||
|
is_fixture=is_fixture,
|
||||||
|
capabilities=list(capabilities),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_capability_report_text(report: CapabilityReport) -> str:
|
||||||
|
"""Format a CapabilityReport as structured text for chat display.
|
||||||
|
|
||||||
|
This is deterministic formatting — no model inference.
|
||||||
|
The model may rephrase when presenting to the owner, but the
|
||||||
|
facts come from this function.
|
||||||
|
|
||||||
|
Output is owner-safe: no terminal, docker, nano, or shell instructions.
|
||||||
|
"""
|
||||||
|
lines: list[str] = []
|
||||||
|
|
||||||
|
# Header.
|
||||||
|
fixture_tag = " [📋 FIXTURE DATA]" if report.is_fixture else ""
|
||||||
|
lines.append(f"═══ Capability Report — {report.salon_name}{fixture_tag} ═══")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Group by area.
|
||||||
|
areas: dict[str, list[CapabilityEntry]] = {}
|
||||||
|
for cap in report.capabilities:
|
||||||
|
areas.setdefault(cap.area, []).append(cap)
|
||||||
|
|
||||||
|
# Define display order.
|
||||||
|
area_order = ["identity", "profile", "channels", "scheduling", "books"]
|
||||||
|
ordered_areas = [a for a in area_order if a in areas]
|
||||||
|
# Append any areas not in the predefined order.
|
||||||
|
for a in areas:
|
||||||
|
if a not in ordered_areas:
|
||||||
|
ordered_areas.append(a)
|
||||||
|
|
||||||
|
for area in ordered_areas:
|
||||||
|
entries = areas[area]
|
||||||
|
area_display = area.replace("_", " ").title()
|
||||||
|
lines.append(f"── {area_display} ──")
|
||||||
|
for entry in entries:
|
||||||
|
provider_display = entry.provider.replace("_", " ").title()
|
||||||
|
lines.append(f" {entry.label} {provider_display}")
|
||||||
|
if entry.details:
|
||||||
|
lines.append(f" {entry.details}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Summary.
|
||||||
|
lines.append("── Summary ──")
|
||||||
|
lines.append(f" Connected: {report.connected_count()} | "
|
||||||
|
f"Offline/fixtures: {report.offline_count()} | "
|
||||||
|
f"Skipped/later: {report.skipped_count()} | "
|
||||||
|
f"Errors: {report.error_count()}")
|
||||||
|
|
||||||
|
if report.has_errors():
|
||||||
|
lines.append("")
|
||||||
|
lines.append("⚠️ Some connections need attention. Ask your operator to check the error details.")
|
||||||
|
|
||||||
|
if report.is_fixture:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("📋 This report uses fixture (demo) data. Real statuses appear after connections are live.")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
"""Static lesson catalog for setup education (E1).
|
||||||
|
|
||||||
|
Each lesson corresponds to a step in docs/SETUP_UX.md.
|
||||||
|
All text is owner-safe: browser/vendor UI steps only.
|
||||||
|
No terminal, docker, nano, or shell instructions.
|
||||||
|
|
||||||
|
This is deterministic data — no model inference.
|
||||||
|
See design/det-vs-inf.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
# ── Forbidden keywords ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Owner-safe text must NEVER contain these keywords.
|
||||||
|
#
|
||||||
|
# Single-word keywords are matched with word boundaries (\b...\b).
|
||||||
|
# Multi-word phrases are matched as literal phrases with word boundaries.
|
||||||
|
# This prevents false positives like "brew installation" matching "brew install".
|
||||||
|
FORBIDDEN_KEYWORDS = frozenset([
|
||||||
|
"terminal",
|
||||||
|
"docker",
|
||||||
|
"nano",
|
||||||
|
"vim",
|
||||||
|
"emacs",
|
||||||
|
"shell",
|
||||||
|
"bash",
|
||||||
|
"sudo",
|
||||||
|
"apt-get",
|
||||||
|
"brew install",
|
||||||
|
"pip install",
|
||||||
|
"npm install",
|
||||||
|
"curl",
|
||||||
|
"wget",
|
||||||
|
"chmod",
|
||||||
|
"ssh",
|
||||||
|
"rsync",
|
||||||
|
"scp",
|
||||||
|
"docker-compose",
|
||||||
|
"docker run",
|
||||||
|
"docker exec",
|
||||||
|
"kubectl",
|
||||||
|
"make install",
|
||||||
|
"git clone",
|
||||||
|
"git push",
|
||||||
|
"git pull",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _keyword_matches(text_lower: str, keyword: str) -> bool:
|
||||||
|
"""Check if a keyword matches in text using word-boundary-aware matching.
|
||||||
|
|
||||||
|
Single-word keywords use \\b boundaries. Multi-word phrases use
|
||||||
|
\\b at the start and end of the full phrase to avoid partial matches
|
||||||
|
like "brew installation" matching "brew install".
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text_lower: Lowercased text to search.
|
||||||
|
keyword: The forbidden keyword or phrase to search for.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the keyword was found with proper word boundaries.
|
||||||
|
"""
|
||||||
|
escaped = re.escape(keyword)
|
||||||
|
pattern = r'\b' + escaped + r'\b'
|
||||||
|
return bool(re.search(pattern, text_lower))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Lesson:
|
||||||
|
"""A single setup education lesson.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
step: Step number (1–7, matching SETUP_UX.md).
|
||||||
|
title: Short title for the lesson.
|
||||||
|
description: What this step accomplishes.
|
||||||
|
instructions: Owner-safe instructions (browser/vendor UI only).
|
||||||
|
what_it_enables: What capabilities become available after this step.
|
||||||
|
possible_outcomes: List of possible end states.
|
||||||
|
area: Capability area this lesson belongs to.
|
||||||
|
"""
|
||||||
|
step: int
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
instructions: str
|
||||||
|
what_it_enables: str
|
||||||
|
possible_outcomes: list[str] = field(default_factory=list)
|
||||||
|
area: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Lesson catalog ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
LESSON_CATALOG: list[Lesson] = [
|
||||||
|
Lesson(
|
||||||
|
step=1,
|
||||||
|
title="Name the assistant",
|
||||||
|
description="Choose a name for your salon assistant. This becomes how the assistant identifies itself in conversations.",
|
||||||
|
instructions=(
|
||||||
|
"Tell the assistant what you'd like to call it — for example 'Lumina', "
|
||||||
|
"'Salon Helper', or any name you prefer. The assistant will use this name "
|
||||||
|
"in all conversations going forward."
|
||||||
|
),
|
||||||
|
what_it_enables="Personalized assistant identity across all channels.",
|
||||||
|
possible_outcomes=["connected", "skipped"],
|
||||||
|
area="identity",
|
||||||
|
),
|
||||||
|
Lesson(
|
||||||
|
step=2,
|
||||||
|
title="Profile intake",
|
||||||
|
description="Share your business details so the assistant can tailor its help to your salon.",
|
||||||
|
instructions=(
|
||||||
|
"Answer a few questions about your salon: business name, timezone, "
|
||||||
|
"business hours, staff names, and any hard rules you want the assistant "
|
||||||
|
"to follow (e.g., 'never send messages without my approval')."
|
||||||
|
),
|
||||||
|
what_it_enables="Context-aware responses, correct timezone handling, staff-aware boards.",
|
||||||
|
possible_outcomes=["connected", "skipped", "later"],
|
||||||
|
area="profile",
|
||||||
|
),
|
||||||
|
Lesson(
|
||||||
|
step=3,
|
||||||
|
title="Connect channels",
|
||||||
|
description="Choose which messaging channels you want to use with the assistant.",
|
||||||
|
instructions=(
|
||||||
|
"Decide which channels to use:\n"
|
||||||
|
" • WhatsApp — chat with the assistant on your phone\n"
|
||||||
|
" • Email — thread-based conversations\n"
|
||||||
|
" • Telegram — bot-style chat\n\n"
|
||||||
|
"Your operator will configure the channels you choose. You can skip any "
|
||||||
|
"channel now and add it later."
|
||||||
|
),
|
||||||
|
what_it_enables="Talk to the assistant on your preferred messaging apps.",
|
||||||
|
possible_outcomes=["connected", "skipped", "later", "error"],
|
||||||
|
area="channels",
|
||||||
|
),
|
||||||
|
Lesson(
|
||||||
|
step=4,
|
||||||
|
title="Connect scheduling",
|
||||||
|
description="Link your scheduling system (Vagaro and/or Square) so the assistant can read your appointments.",
|
||||||
|
instructions=(
|
||||||
|
"If you use Vagaro:\n"
|
||||||
|
" 1. Log in to your Vagaro account in your browser.\n"
|
||||||
|
" 2. Go to Settings → Integrations and generate an API key.\n"
|
||||||
|
" 3. Share the API key with your operator.\n\n"
|
||||||
|
"If you use Square:\n"
|
||||||
|
" 1. Log in to the Square Developer Portal in your browser.\n"
|
||||||
|
" 2. Create an application and generate an access token.\n"
|
||||||
|
" 3. Share the token with your operator.\n\n"
|
||||||
|
"The assistant reads your schedule — it never modifies bookings or charges clients."
|
||||||
|
),
|
||||||
|
what_it_enables="Daily board, appointment gaps, confirmation flags, client prep cards.",
|
||||||
|
possible_outcomes=["connected", "skipped", "later", "error"],
|
||||||
|
area="scheduling",
|
||||||
|
),
|
||||||
|
Lesson(
|
||||||
|
step=5,
|
||||||
|
title="Connect books",
|
||||||
|
description="Link QuickBooks Online so the assistant can show you a read-only financial picture.",
|
||||||
|
instructions=(
|
||||||
|
"1. Log in to QuickBooks Online in your browser.\n"
|
||||||
|
"2. Authorize the assistant's read-only access when prompted.\n"
|
||||||
|
"3. Your operator will complete the connection on their end.\n\n"
|
||||||
|
"The assistant reads your books — it never pays bills, creates charges, "
|
||||||
|
"or modifies financial records."
|
||||||
|
),
|
||||||
|
what_it_enables="Books snapshot, open invoices, bills due, vendor spend lookup.",
|
||||||
|
possible_outcomes=["connected", "skipped", "later", "error"],
|
||||||
|
area="books",
|
||||||
|
),
|
||||||
|
Lesson(
|
||||||
|
step=6,
|
||||||
|
title="Set expectations",
|
||||||
|
description="Understand what the assistant can and cannot do.",
|
||||||
|
instructions=(
|
||||||
|
"The assistant is designed with these boundaries:\n"
|
||||||
|
" • Drafts messages — you send them (no silent auto-send)\n"
|
||||||
|
" • Drafts social posts — you publish them (no auto-publish)\n"
|
||||||
|
" • Reads your books — never pays bills or charges cards\n"
|
||||||
|
" • Reads your schedule — never modifies bookings\n"
|
||||||
|
" • Labels demo data clearly — never shows fake data as real\n\n"
|
||||||
|
"These boundaries are built in and cannot be turned off."
|
||||||
|
),
|
||||||
|
what_it_enables="Clear understanding of assistant capabilities and safety boundaries.",
|
||||||
|
possible_outcomes=["connected"],
|
||||||
|
area="expectations",
|
||||||
|
),
|
||||||
|
Lesson(
|
||||||
|
step=7,
|
||||||
|
title="Review capability report",
|
||||||
|
description="See a summary of what is connected, what is offline, and what was skipped.",
|
||||||
|
instructions=(
|
||||||
|
"Ask the assistant for a capability report. It will show:\n"
|
||||||
|
" • ✅ Connected — working integrations\n"
|
||||||
|
" • 📋 Offline/fixtures — demo data (not live)\n"
|
||||||
|
" • ⏭️ Skipped — you chose to skip this step\n"
|
||||||
|
" • ⏳ Set up later — planned for future\n"
|
||||||
|
" • ❌ Error — needs operator attention\n\n"
|
||||||
|
"This report updates as you connect more services."
|
||||||
|
),
|
||||||
|
what_it_enables="Clear picture of what works and what needs attention.",
|
||||||
|
possible_outcomes=["connected"],
|
||||||
|
area="report",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_lesson(step: int) -> Optional[Lesson]:
|
||||||
|
"""Get a lesson by step number (1–7).
|
||||||
|
|
||||||
|
Returns None if the step number is not found.
|
||||||
|
"""
|
||||||
|
for lesson in LESSON_CATALOG:
|
||||||
|
if lesson.step == step:
|
||||||
|
return lesson
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_lessons_by_area(area: str) -> list[Lesson]:
|
||||||
|
"""Get all lessons for a given capability area."""
|
||||||
|
return [l for l in LESSON_CATALOG if l.area == area]
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_lessons() -> list[Lesson]:
|
||||||
|
"""Return the full lesson catalog in step order."""
|
||||||
|
return list(LESSON_CATALOG)
|
||||||
|
|
||||||
|
|
||||||
|
def format_lesson_text(lesson: Lesson) -> str:
|
||||||
|
"""Format a single lesson as structured text for chat display.
|
||||||
|
|
||||||
|
This is deterministic formatting — no model inference.
|
||||||
|
"""
|
||||||
|
lines: list[str] = []
|
||||||
|
lines.append(f"Step {lesson.step}: {lesson.title}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(lesson.description)
|
||||||
|
lines.append("")
|
||||||
|
lines.append("What to do:")
|
||||||
|
lines.append(lesson.instructions)
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"This enables: {lesson.what_it_enables}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"Possible outcomes: {', '.join(lesson.possible_outcomes)}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def format_all_lessons_text() -> str:
|
||||||
|
"""Format the full lesson catalog as structured text."""
|
||||||
|
lines: list[str] = []
|
||||||
|
lines.append("═══ Setup Education — All Steps ═══")
|
||||||
|
lines.append("")
|
||||||
|
for lesson in LESSON_CATALOG:
|
||||||
|
lines.append(format_lesson_text(lesson))
|
||||||
|
lines.append("")
|
||||||
|
lines.append("─" * 50)
|
||||||
|
lines.append("")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def is_owner_safe(text: str) -> bool:
|
||||||
|
"""Check that text does not contain forbidden keywords.
|
||||||
|
|
||||||
|
Owner-safe text must never contain terminal, docker, nano, or shell
|
||||||
|
instructions. This is a deterministic check using word-boundary-aware
|
||||||
|
matching to avoid false positives on multi-word phrases.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text to validate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the text is owner-safe (no forbidden keywords found).
|
||||||
|
"""
|
||||||
|
text_lower = text.lower()
|
||||||
|
found = [kw for kw in FORBIDDEN_KEYWORDS if _keyword_matches(text_lower, kw)]
|
||||||
|
return len(found) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def validate_lesson_owner_safe(lesson: Lesson) -> list[str]:
|
||||||
|
"""Validate that a lesson contains no forbidden keywords.
|
||||||
|
|
||||||
|
Checks title, description, instructions, what_it_enables, and
|
||||||
|
possible_outcomes.
|
||||||
|
|
||||||
|
Returns a list of forbidden keywords found (empty if clean).
|
||||||
|
"""
|
||||||
|
all_text = (
|
||||||
|
f"{lesson.title} {lesson.description} {lesson.instructions} "
|
||||||
|
f"{lesson.what_it_enables} {' '.join(lesson.possible_outcomes)}"
|
||||||
|
)
|
||||||
|
text_lower = all_text.lower()
|
||||||
|
return [kw for kw in FORBIDDEN_KEYWORDS if _keyword_matches(text_lower, kw)]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_catalog_owner_safe() -> dict[int, list[str]]:
|
||||||
|
"""Validate the entire lesson catalog for owner-safe text.
|
||||||
|
|
||||||
|
Returns a dict mapping step numbers to lists of forbidden keywords found.
|
||||||
|
Empty dict means all lessons are clean.
|
||||||
|
"""
|
||||||
|
violations: dict[int, list[str]] = {}
|
||||||
|
for lesson in LESSON_CATALOG:
|
||||||
|
found = validate_lesson_owner_safe(lesson)
|
||||||
|
if found:
|
||||||
|
violations[lesson.step] = found
|
||||||
|
return violations
|
||||||
@@ -1,5 +1,46 @@
|
|||||||
# `setup-education` (scaffold)
|
# `setup-education`
|
||||||
|
|
||||||
**Status:** Not implemented. Implementation requires explicit **build**.
|
**Status:** Implemented (fixtures only).
|
||||||
|
|
||||||
Intent: see [design/use-cases.md](../../design/use-cases.md) and [design/scenarios.md](../../design/scenarios.md).
|
Owner-safe connect education and capability report for use cases E1, E6, and E7.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- **7 setup lessons** — step-by-step education for connecting the assistant
|
||||||
|
(name, profile, channels, scheduling, books, expectations, capability report)
|
||||||
|
- **Capability report** — plain-language summary of what is connected, offline,
|
||||||
|
skipped, or in error
|
||||||
|
- **Owner-safe** — all education text validated to never contain terminal,
|
||||||
|
docker, nano, or shell instructions
|
||||||
|
- **Fixture-labeled** — all demo data clearly marked `📋 FIXTURE DATA`
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Capability report (default demo data)
|
||||||
|
python skills/setup-education/scripts/build_capability_report.py
|
||||||
|
|
||||||
|
# JSON output
|
||||||
|
python skills/setup-education/scripts/build_capability_report.py --format json
|
||||||
|
|
||||||
|
# Show lesson 4 (connect scheduling)
|
||||||
|
python skills/setup-education/scripts/build_capability_report.py --lesson 4
|
||||||
|
|
||||||
|
# Show all lessons
|
||||||
|
python skills/setup-education/scripts/build_capability_report.py --all-lessons
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fixtures
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `data/fixtures/setup/capability_matrix.json` | Demo: mixed states (connected, offline, skipped, later) |
|
||||||
|
| `data/fixtures/setup/capability_matrix_all_connected.json` | Demo: all connected |
|
||||||
|
| `data/fixtures/setup/capability_matrix_with_errors.json` | Demo: includes error states |
|
||||||
|
|
||||||
|
## Design references
|
||||||
|
|
||||||
|
- Use case: [E1 — Educational setup](../../design/use-cases.md)
|
||||||
|
- Use case: [E6 — Capability report](../../design/use-cases.md)
|
||||||
|
- Use case: [E7 — Degraded mode](../../design/use-cases.md)
|
||||||
|
- Setup UX: [docs/SETUP_UX.md](../../docs/SETUP_UX.md)
|
||||||
|
|||||||
@@ -10,9 +10,126 @@ Owner-safe connect education and capability report.
|
|||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
Guides the owner through connecting their SaaS integrations (Square, QBO, Vagaro) and messaging channels. Provides a capability report showing what is connected.
|
Guides the owner through connecting their SaaS integrations (Square, QBO, Vagaro)
|
||||||
|
and messaging channels. Provides a capability report showing what is connected,
|
||||||
|
what is offline/fixtures, and what was skipped.
|
||||||
|
|
||||||
|
All education text is **owner-safe**: browser/vendor UI steps only. Never
|
||||||
|
terminal, docker, nano, or shell instructions.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- Presents 7 setup education lessons (matching `docs/SETUP_UX.md` steps 1–7)
|
||||||
|
- Builds a capability report from fixture or live connection state
|
||||||
|
- Labels all fixture data as `📋 FIXTURE DATA` — never silent fake live data
|
||||||
|
- Validates that education text never contains forbidden keywords
|
||||||
|
- Outputs structured text or JSON
|
||||||
|
|
||||||
|
## Data sources
|
||||||
|
|
||||||
|
| Source | Status | Label in output |
|
||||||
|
|--------|--------|-----------------|
|
||||||
|
| Fixtures (JSON) | ✅ Implemented | `📋 FIXTURE DATA` |
|
||||||
|
| Live connection state | Not yet | `LIVE DATA` (future) |
|
||||||
|
|
||||||
## Constraints
|
## Constraints
|
||||||
|
|
||||||
- Owner-safe: no terminal instructions.
|
- Deterministic facts from fixtures; no model inference for capability data.
|
||||||
- Browser/vendor UI steps only.
|
- Owner-safe: no terminal/docker/nano/shell instructions in education text.
|
||||||
|
- Fixtures/stubs only — no real OAuth, no live secrets.
|
||||||
|
- Fixture `is_fixture` flag always `true` — never silent as live.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build capability report from fixtures (default demo data)
|
||||||
|
python skills/setup-education/scripts/build_capability_report.py
|
||||||
|
|
||||||
|
# JSON output
|
||||||
|
python skills/setup-education/scripts/build_capability_report.py --format json
|
||||||
|
|
||||||
|
# Show a specific lesson
|
||||||
|
python skills/setup-education/scripts/build_capability_report.py --lesson 4
|
||||||
|
|
||||||
|
# Show all lessons
|
||||||
|
python skills/setup-education/scripts/build_capability_report.py --all-lessons
|
||||||
|
|
||||||
|
# Use a different fixture
|
||||||
|
python skills/setup-education/scripts/build_capability_report.py \
|
||||||
|
--fixtures data/fixtures/setup/capability_matrix_all_connected.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Programmatic
|
||||||
|
|
||||||
|
```python
|
||||||
|
from lumina_skills.providers.setup.fixture_provider import load_capability_fixture
|
||||||
|
from lumina_skills.setup.capability_report import format_capability_report_text
|
||||||
|
from lumina_skills.setup.lesson_catalog import get_lesson, format_lesson_text
|
||||||
|
|
||||||
|
# Capability report
|
||||||
|
report = load_capability_fixture("data/fixtures/setup/capability_matrix.json")
|
||||||
|
print(format_capability_report_text(report))
|
||||||
|
|
||||||
|
# Individual lesson
|
||||||
|
lesson = get_lesson(4)
|
||||||
|
print(format_lesson_text(lesson))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output format
|
||||||
|
|
||||||
|
### Text (default)
|
||||||
|
|
||||||
|
Structured text grouped by area (identity, profile, channels, scheduling, books)
|
||||||
|
with emoji status labels and a summary line.
|
||||||
|
|
||||||
|
### JSON
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"salon_name": "Lumina Hair Studio & Spa",
|
||||||
|
"is_fixture": true,
|
||||||
|
"capabilities": [
|
||||||
|
{
|
||||||
|
"area": "channels",
|
||||||
|
"provider": "whatsapp",
|
||||||
|
"status": "connected",
|
||||||
|
"label": "✅ Connected",
|
||||||
|
"details": "WhatsApp channel active"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": {
|
||||||
|
"connected": 4,
|
||||||
|
"offline": 2,
|
||||||
|
"skipped_or_later": 2,
|
||||||
|
"errors": 0,
|
||||||
|
"all_connected": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `SKILL.md` | Skill spec and usage |
|
||||||
|
| `scripts/build_capability_report.py` | CLI entrypoint |
|
||||||
|
| `../../skills/_lib/lumina_skills/setup/capability_report.py` | Domain model + builder |
|
||||||
|
| `../../skills/_lib/lumina_skills/setup/lesson_catalog.py` | Static lesson steps |
|
||||||
|
| `../../skills/_lib/lumina_skills/providers/setup/fixture_provider.py` | Fixture loader |
|
||||||
|
| `../../data/fixtures/setup/` | Fixture JSON files |
|
||||||
|
|
||||||
|
## Design references
|
||||||
|
|
||||||
|
- Use case: [E1 — Educational setup](../../design/use-cases.md)
|
||||||
|
- Use case: [E6 — Capability report](../../design/use-cases.md)
|
||||||
|
- Use case: [E7 — Degraded mode](../../design/use-cases.md)
|
||||||
|
- Setup UX: [docs/SETUP_UX.md](../../docs/SETUP_UX.md)
|
||||||
|
- Deterministic boundary: [design/det-vs-inf.md](../../design/det-vs-inf.md)
|
||||||
|
|
||||||
|
## Future
|
||||||
|
|
||||||
|
- Live connection state provider (replaces fixtures)
|
||||||
|
- Per-lesson progress tracking
|
||||||
|
- Automated lesson sequencing
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build a capability report from setup fixtures.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python build_capability_report.py [--fixtures PATH] [--salon NAME] [--format text|json]
|
||||||
|
|
||||||
|
All data is labeled as fixture/offline — never silent fake live data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Ensure the _lib package is importable regardless of cwd.
|
||||||
|
_REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
|
||||||
|
sys.path.insert(0, str(_REPO_ROOT / "skills" / "_lib"))
|
||||||
|
|
||||||
|
from lumina_skills.providers.setup.fixture_provider import (
|
||||||
|
load_capability_fixture,
|
||||||
|
load_fixture_metadata,
|
||||||
|
)
|
||||||
|
from lumina_skills.setup.capability_report import format_capability_report_text
|
||||||
|
from lumina_skills.setup.lesson_catalog import (
|
||||||
|
format_all_lessons_text,
|
||||||
|
format_lesson_text,
|
||||||
|
get_lesson,
|
||||||
|
get_all_lessons,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Default fixture: Claire Bennett demo capability matrix.
|
||||||
|
_DEFAULT_FIXTURE = _REPO_ROOT / "data" / "fixtures" / "setup" / "capability_matrix.json"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Build a capability report from setup fixture data.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--fixtures",
|
||||||
|
type=pathlib.Path,
|
||||||
|
default=_DEFAULT_FIXTURE,
|
||||||
|
help="Path to capability fixture JSON file.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--salon",
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
help="Override salon name.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--format",
|
||||||
|
choices=["text", "json"],
|
||||||
|
default="text",
|
||||||
|
help="Output format (default: text).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--lesson",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="Show a specific setup lesson (1-7) instead of the capability report.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--all-lessons",
|
||||||
|
action="store_true",
|
||||||
|
help="Show all setup lessons instead of the capability report.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Lesson mode.
|
||||||
|
if args.lesson is not None:
|
||||||
|
lesson = get_lesson(args.lesson)
|
||||||
|
if lesson is None:
|
||||||
|
print(f"Error: No lesson found for step {args.lesson}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(format_lesson_text(lesson))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.all_lessons:
|
||||||
|
print(format_all_lessons_text())
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Capability report mode.
|
||||||
|
try:
|
||||||
|
report = load_capability_fixture(args.fixtures)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
print(f"Error: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
except (json.JSONDecodeError, KeyError, ValueError) as exc:
|
||||||
|
print(f"Error parsing fixture: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Override salon name if requested.
|
||||||
|
if args.salon:
|
||||||
|
from lumina_skills.setup.capability_report import build_capability_report
|
||||||
|
report = build_capability_report(
|
||||||
|
capabilities=report.capabilities,
|
||||||
|
salon_name=args.salon,
|
||||||
|
is_fixture=report.is_fixture,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Output.
|
||||||
|
if args.format == "json":
|
||||||
|
print(json.dumps(report.to_dict(), indent=2))
|
||||||
|
else:
|
||||||
|
print(format_capability_report_text(report))
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""Tests for lumina_skills.setup.capability_report domain model and builder."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "skills" / "_lib"))
|
||||||
|
|
||||||
|
from lumina_skills.setup.capability_report import (
|
||||||
|
CapabilityEntry,
|
||||||
|
CapabilityReport,
|
||||||
|
ConnectionStatus,
|
||||||
|
build_capability_report,
|
||||||
|
format_capability_report_text,
|
||||||
|
status_label,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── ConnectionStatus ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_connection_status_values():
|
||||||
|
"""All status enum values are correct."""
|
||||||
|
assert ConnectionStatus.CONNECTED.value == "connected"
|
||||||
|
assert ConnectionStatus.SKIPPED.value == "skipped"
|
||||||
|
assert ConnectionStatus.LATER.value == "later"
|
||||||
|
assert ConnectionStatus.ERROR.value == "error"
|
||||||
|
assert ConnectionStatus.OFFLINE.value == "offline"
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_labels():
|
||||||
|
"""Each status has an emoji label."""
|
||||||
|
assert "✅" in status_label(ConnectionStatus.CONNECTED)
|
||||||
|
assert "⏭️" in status_label(ConnectionStatus.SKIPPED)
|
||||||
|
assert "⏳" in status_label(ConnectionStatus.LATER)
|
||||||
|
assert "❌" in status_label(ConnectionStatus.ERROR)
|
||||||
|
assert "📋" in status_label(ConnectionStatus.OFFLINE)
|
||||||
|
|
||||||
|
|
||||||
|
# ── CapabilityEntry ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_capability_entry_label():
|
||||||
|
"""Entry label uses status_label."""
|
||||||
|
entry = CapabilityEntry(
|
||||||
|
area="channels",
|
||||||
|
provider="whatsapp",
|
||||||
|
status=ConnectionStatus.CONNECTED,
|
||||||
|
)
|
||||||
|
assert "✅" in entry.label
|
||||||
|
|
||||||
|
|
||||||
|
def test_capability_entry_frozen():
|
||||||
|
"""CapabilityEntry is immutable."""
|
||||||
|
entry = CapabilityEntry(
|
||||||
|
area="channels",
|
||||||
|
provider="whatsapp",
|
||||||
|
status=ConnectionStatus.CONNECTED,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
entry.status = ConnectionStatus.ERROR
|
||||||
|
assert False, "Should not be able to modify frozen dataclass"
|
||||||
|
except Exception:
|
||||||
|
pass # Expected
|
||||||
|
|
||||||
|
|
||||||
|
# ── CapabilityReport ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _make_entries(*statuses: ConnectionStatus) -> list[CapabilityEntry]:
|
||||||
|
"""Helper to create CapabilityEntry objects with given statuses."""
|
||||||
|
providers = ["whatsapp", "email", "telegram", "vagaro", "qbo"]
|
||||||
|
areas = ["channels", "channels", "channels", "scheduling", "books"]
|
||||||
|
entries = []
|
||||||
|
for i, status in enumerate(statuses):
|
||||||
|
entries.append(CapabilityEntry(
|
||||||
|
area=areas[i],
|
||||||
|
provider=providers[i],
|
||||||
|
status=status,
|
||||||
|
))
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_connected_count():
|
||||||
|
entries = _make_entries(
|
||||||
|
ConnectionStatus.CONNECTED,
|
||||||
|
ConnectionStatus.CONNECTED,
|
||||||
|
ConnectionStatus.SKIPPED,
|
||||||
|
ConnectionStatus.OFFLINE,
|
||||||
|
ConnectionStatus.ERROR,
|
||||||
|
)
|
||||||
|
report = build_capability_report(entries, "Test Salon")
|
||||||
|
assert report.connected_count() == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_offline_count():
|
||||||
|
entries = _make_entries(
|
||||||
|
ConnectionStatus.CONNECTED,
|
||||||
|
ConnectionStatus.OFFLINE,
|
||||||
|
ConnectionStatus.OFFLINE,
|
||||||
|
ConnectionStatus.SKIPPED,
|
||||||
|
ConnectionStatus.ERROR,
|
||||||
|
)
|
||||||
|
report = build_capability_report(entries, "Test Salon")
|
||||||
|
assert report.offline_count() == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_skipped_count():
|
||||||
|
entries = _make_entries(
|
||||||
|
ConnectionStatus.CONNECTED,
|
||||||
|
ConnectionStatus.SKIPPED,
|
||||||
|
ConnectionStatus.LATER,
|
||||||
|
ConnectionStatus.OFFLINE,
|
||||||
|
ConnectionStatus.ERROR,
|
||||||
|
)
|
||||||
|
report = build_capability_report(entries, "Test Salon")
|
||||||
|
assert report.skipped_count() == 2 # SKIPPED + LATER
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_error_count():
|
||||||
|
entries = _make_entries(
|
||||||
|
ConnectionStatus.CONNECTED,
|
||||||
|
ConnectionStatus.ERROR,
|
||||||
|
ConnectionStatus.ERROR,
|
||||||
|
ConnectionStatus.SKIPPED,
|
||||||
|
ConnectionStatus.OFFLINE,
|
||||||
|
)
|
||||||
|
report = build_capability_report(entries, "Test Salon")
|
||||||
|
assert report.error_count() == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_all_connected():
|
||||||
|
entries = _make_entries(
|
||||||
|
ConnectionStatus.CONNECTED,
|
||||||
|
ConnectionStatus.CONNECTED,
|
||||||
|
ConnectionStatus.CONNECTED,
|
||||||
|
ConnectionStatus.CONNECTED,
|
||||||
|
ConnectionStatus.CONNECTED,
|
||||||
|
)
|
||||||
|
report = build_capability_report(entries, "Test Salon")
|
||||||
|
assert report.all_connected() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_not_all_connected():
|
||||||
|
entries = _make_entries(
|
||||||
|
ConnectionStatus.CONNECTED,
|
||||||
|
ConnectionStatus.CONNECTED,
|
||||||
|
ConnectionStatus.SKIPPED,
|
||||||
|
ConnectionStatus.CONNECTED,
|
||||||
|
ConnectionStatus.CONNECTED,
|
||||||
|
)
|
||||||
|
report = build_capability_report(entries, "Test Salon")
|
||||||
|
assert report.all_connected() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_has_errors():
|
||||||
|
entries = _make_entries(
|
||||||
|
ConnectionStatus.CONNECTED,
|
||||||
|
ConnectionStatus.ERROR,
|
||||||
|
ConnectionStatus.SKIPPED,
|
||||||
|
)
|
||||||
|
report = build_capability_report(entries, "Test Salon")
|
||||||
|
assert report.has_errors() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_no_errors():
|
||||||
|
entries = _make_entries(
|
||||||
|
ConnectionStatus.CONNECTED,
|
||||||
|
ConnectionStatus.SKIPPED,
|
||||||
|
ConnectionStatus.OFFLINE,
|
||||||
|
)
|
||||||
|
report = build_capability_report(entries, "Test Salon")
|
||||||
|
assert report.has_errors() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_empty():
|
||||||
|
report = build_capability_report([], "Empty Salon")
|
||||||
|
assert report.connected_count() == 0
|
||||||
|
assert report.offline_count() == 0
|
||||||
|
assert report.skipped_count() == 0
|
||||||
|
assert report.error_count() == 0
|
||||||
|
assert report.all_connected() is False # empty report is not "all connected"
|
||||||
|
assert report.has_errors() is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── to_dict ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_report_to_dict():
|
||||||
|
entries = _make_entries(
|
||||||
|
ConnectionStatus.CONNECTED,
|
||||||
|
ConnectionStatus.OFFLINE,
|
||||||
|
ConnectionStatus.SKIPPED,
|
||||||
|
)
|
||||||
|
report = build_capability_report(entries, "Test Salon", is_fixture=True)
|
||||||
|
d = report.to_dict()
|
||||||
|
|
||||||
|
assert d["salon_name"] == "Test Salon"
|
||||||
|
assert d["is_fixture"] is True
|
||||||
|
assert len(d["capabilities"]) == 3
|
||||||
|
assert d["capabilities"][0]["status"] == "connected"
|
||||||
|
assert d["capabilities"][1]["status"] == "offline"
|
||||||
|
assert d["capabilities"][2]["status"] == "skipped"
|
||||||
|
assert d["summary"]["connected"] == 1
|
||||||
|
assert d["summary"]["offline"] == 1
|
||||||
|
assert d["summary"]["skipped_or_later"] == 1
|
||||||
|
assert d["summary"]["errors"] == 0
|
||||||
|
assert d["summary"]["all_connected"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_to_dict_serializable():
|
||||||
|
"""to_dict output must be JSON-serializable."""
|
||||||
|
entries = _make_entries(
|
||||||
|
ConnectionStatus.CONNECTED,
|
||||||
|
ConnectionStatus.OFFLINE,
|
||||||
|
)
|
||||||
|
report = build_capability_report(entries, "Test Salon")
|
||||||
|
d = report.to_dict()
|
||||||
|
# Should not raise.
|
||||||
|
json.dumps(d)
|
||||||
|
|
||||||
|
|
||||||
|
# ── format_capability_report_text ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_format_text_includes_salon_name():
|
||||||
|
entries = _make_entries(ConnectionStatus.CONNECTED)
|
||||||
|
report = build_capability_report(entries, "Lumina Hair Studio & Spa")
|
||||||
|
text = format_capability_report_text(report)
|
||||||
|
assert "Lumina Hair Studio & Spa" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_text_fixture_label():
|
||||||
|
entries = _make_entries(ConnectionStatus.OFFLINE)
|
||||||
|
report = build_capability_report(entries, "Test Salon", is_fixture=True)
|
||||||
|
text = format_capability_report_text(report)
|
||||||
|
assert "FIXTURE DATA" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_text_no_fixture_label_when_live():
|
||||||
|
entries = _make_entries(ConnectionStatus.CONNECTED)
|
||||||
|
report = build_capability_report(entries, "Test Salon", is_fixture=False)
|
||||||
|
text = format_capability_report_text(report)
|
||||||
|
assert "FIXTURE DATA" not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_text_includes_summary():
|
||||||
|
entries = _make_entries(
|
||||||
|
ConnectionStatus.CONNECTED,
|
||||||
|
ConnectionStatus.OFFLINE,
|
||||||
|
ConnectionStatus.SKIPPED,
|
||||||
|
)
|
||||||
|
report = build_capability_report(entries, "Test Salon")
|
||||||
|
text = format_capability_report_text(report)
|
||||||
|
assert "Summary" in text
|
||||||
|
assert "Connected:" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_text_error_warning():
|
||||||
|
entries = _make_entries(
|
||||||
|
ConnectionStatus.CONNECTED,
|
||||||
|
ConnectionStatus.ERROR,
|
||||||
|
)
|
||||||
|
report = build_capability_report(entries, "Test Salon")
|
||||||
|
text = format_capability_report_text(report)
|
||||||
|
assert "needs attention" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_text_fixture_disclaimer():
|
||||||
|
entries = _make_entries(ConnectionStatus.OFFLINE)
|
||||||
|
report = build_capability_report(entries, "Test Salon", is_fixture=True)
|
||||||
|
text = format_capability_report_text(report)
|
||||||
|
assert "fixture" in text.lower() or "demo" in text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_text_groups_by_area():
|
||||||
|
entries = [
|
||||||
|
CapabilityEntry("channels", "whatsapp", ConnectionStatus.CONNECTED),
|
||||||
|
CapabilityEntry("channels", "email", ConnectionStatus.SKIPPED),
|
||||||
|
CapabilityEntry("scheduling", "vagaro", ConnectionStatus.OFFLINE),
|
||||||
|
CapabilityEntry("books", "quickbooks_online", ConnectionStatus.OFFLINE),
|
||||||
|
]
|
||||||
|
report = build_capability_report(entries, "Test Salon")
|
||||||
|
text = format_capability_report_text(report)
|
||||||
|
assert "Channels" in text
|
||||||
|
assert "Scheduling" in text
|
||||||
|
assert "Books" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_text_owner_safe():
|
||||||
|
"""Formatted text must never contain forbidden keywords."""
|
||||||
|
forbidden = ["terminal", "docker", "nano", "shell", "bash", "sudo"]
|
||||||
|
entries = _make_entries(
|
||||||
|
ConnectionStatus.CONNECTED,
|
||||||
|
ConnectionStatus.OFFLINE,
|
||||||
|
ConnectionStatus.SKIPPED,
|
||||||
|
ConnectionStatus.ERROR,
|
||||||
|
ConnectionStatus.LATER,
|
||||||
|
)
|
||||||
|
report = build_capability_report(entries, "Test Salon", is_fixture=True)
|
||||||
|
text = format_capability_report_text(report).lower()
|
||||||
|
for kw in forbidden:
|
||||||
|
assert kw not in text, f"Formatted text contains forbidden keyword: {kw}"
|
||||||
@@ -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'(?<!open)shell', output)
|
||||||
|
assert len(matches) == 0, (
|
||||||
|
f"Help output contains forbidden keyword: {keyword}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Allow keywords that appear in safety guarantee contexts
|
||||||
|
# (e.g., "Owner never receives terminal/Docker/nano instructions")
|
||||||
|
# These are guarantees about what the owner does NOT receive.
|
||||||
|
safety_patterns = [
|
||||||
|
"never receives",
|
||||||
|
"never gets",
|
||||||
|
"owner never",
|
||||||
|
"no owner",
|
||||||
|
"owner-safe",
|
||||||
|
]
|
||||||
|
is_safety_context = any(
|
||||||
|
pattern in output and keyword in output
|
||||||
|
for pattern in safety_patterns
|
||||||
|
)
|
||||||
|
if is_safety_context:
|
||||||
|
# Verify the keyword appears in a safety guarantee, not an instruction
|
||||||
|
import re
|
||||||
|
# Check that the keyword is NOT preceded by instruction-like verbs
|
||||||
|
instruction_patterns = [
|
||||||
|
r'run\s+.*' + keyword,
|
||||||
|
r'execute\s+.*' + keyword,
|
||||||
|
r'open\s+.*' + keyword,
|
||||||
|
r'type\s+.*' + keyword,
|
||||||
|
r'install\s+.*' + keyword,
|
||||||
|
]
|
||||||
|
has_instruction = any(
|
||||||
|
re.search(pattern, output) for pattern in instruction_patterns
|
||||||
|
)
|
||||||
|
assert not has_instruction, (
|
||||||
|
f"Help output contains instruction with forbidden keyword: {keyword}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
assert keyword not in output, (
|
||||||
|
f"Help output contains forbidden keyword: {keyword}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_capability_report_no_owner_keywords():
|
||||||
|
"""Capability report text must not contain forbidden keywords."""
|
||||||
|
# Import the capability report formatter
|
||||||
|
sys_path = str(_REPO_ROOT / "skills" / "_lib")
|
||||||
|
import sys
|
||||||
|
if sys_path not in sys.path:
|
||||||
|
sys.path.insert(0, sys_path)
|
||||||
|
|
||||||
|
from lumina_skills.setup.capability_report import (
|
||||||
|
CapabilityEntry,
|
||||||
|
ConnectionStatus,
|
||||||
|
build_capability_report,
|
||||||
|
format_capability_report_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
entries = [
|
||||||
|
CapabilityEntry("channels", "whatsapp", ConnectionStatus.CONNECTED),
|
||||||
|
CapabilityEntry("channels", "email", ConnectionStatus.SKIPPED),
|
||||||
|
CapabilityEntry("scheduling", "vagaro", ConnectionStatus.OFFLINE),
|
||||||
|
CapabilityEntry("books", "quickbooks_online", ConnectionStatus.ERROR),
|
||||||
|
]
|
||||||
|
report = build_capability_report(entries, "Test Salon", is_fixture=True)
|
||||||
|
text = format_capability_report_text(report).lower()
|
||||||
|
|
||||||
|
for keyword in FORBIDDEN_OWNER_KEYWORDS:
|
||||||
|
assert keyword not in text, (
|
||||||
|
f"Capability report contains forbidden keyword: {keyword}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
"""Tests for lumina_skills.setup.lesson_catalog."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "skills" / "_lib"))
|
||||||
|
|
||||||
|
from lumina_skills.setup.lesson_catalog import (
|
||||||
|
FORBIDDEN_KEYWORDS,
|
||||||
|
Lesson,
|
||||||
|
LESSON_CATALOG,
|
||||||
|
format_lesson_text,
|
||||||
|
format_all_lessons_text,
|
||||||
|
get_lesson,
|
||||||
|
get_lessons_by_area,
|
||||||
|
get_all_lessons,
|
||||||
|
is_owner_safe,
|
||||||
|
validate_lesson_owner_safe,
|
||||||
|
validate_catalog_owner_safe,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Lesson catalog structure ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_catalog_has_seven_lessons():
|
||||||
|
"""Catalog must have exactly 7 lessons (matching SETUP_UX steps 1-7)."""
|
||||||
|
assert len(LESSON_CATALOG) == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_lesson_steps_are_sequential():
|
||||||
|
"""Lesson steps must be 1 through 7."""
|
||||||
|
steps = [l.step for l in LESSON_CATALOG]
|
||||||
|
assert steps == list(range(1, 8))
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_lessons_returns_all():
|
||||||
|
assert len(get_all_lessons()) == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_lesson_by_step():
|
||||||
|
lesson = get_lesson(1)
|
||||||
|
assert lesson is not None
|
||||||
|
assert lesson.step == 1
|
||||||
|
assert "name" in lesson.title.lower() or "assistant" in lesson.title.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_lesson_out_of_range():
|
||||||
|
assert get_lesson(0) is None
|
||||||
|
assert get_lesson(8) is None
|
||||||
|
assert get_lesson(-1) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_lessons_by_area():
|
||||||
|
channel_lessons = get_lessons_by_area("channels")
|
||||||
|
assert len(channel_lessons) >= 1
|
||||||
|
assert all(l.area == "channels" for l in channel_lessons)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_lessons_by_area_empty():
|
||||||
|
assert get_lessons_by_area("nonexistent_area") == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── Lesson content ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_lesson_1_identity():
|
||||||
|
lesson = get_lesson(1)
|
||||||
|
assert lesson is not None
|
||||||
|
assert lesson.area == "identity"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lesson_2_profile():
|
||||||
|
lesson = get_lesson(2)
|
||||||
|
assert lesson is not None
|
||||||
|
assert lesson.area == "profile"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lesson_3_channels():
|
||||||
|
lesson = get_lesson(3)
|
||||||
|
assert lesson is not None
|
||||||
|
assert lesson.area == "channels"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lesson_4_scheduling():
|
||||||
|
lesson = get_lesson(4)
|
||||||
|
assert lesson is not None
|
||||||
|
assert lesson.area == "scheduling"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lesson_5_books():
|
||||||
|
lesson = get_lesson(5)
|
||||||
|
assert lesson is not None
|
||||||
|
assert lesson.area == "books"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lesson_6_expectations():
|
||||||
|
lesson = get_lesson(6)
|
||||||
|
assert lesson is not None
|
||||||
|
assert lesson.area == "expectations"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lesson_7_report():
|
||||||
|
lesson = get_lesson(7)
|
||||||
|
assert lesson is not None
|
||||||
|
assert lesson.area == "report"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lessons_have_non_empty_fields():
|
||||||
|
"""All lessons must have non-empty title, description, and instructions."""
|
||||||
|
for lesson in LESSON_CATALOG:
|
||||||
|
assert lesson.title.strip(), f"Lesson {lesson.step} has empty title"
|
||||||
|
assert lesson.description.strip(), f"Lesson {lesson.step} has empty description"
|
||||||
|
assert lesson.instructions.strip(), f"Lesson {lesson.step} has empty instructions"
|
||||||
|
assert lesson.what_it_enables.strip(), f"Lesson {lesson.step} has empty what_it_enables"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lessons_have_possible_outcomes():
|
||||||
|
"""All lessons must have at least one possible outcome."""
|
||||||
|
for lesson in LESSON_CATALOG:
|
||||||
|
assert len(lesson.possible_outcomes) >= 1, f"Lesson {lesson.step} has no outcomes"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Owner-safe validation ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_forbidden_keywords_not_empty():
|
||||||
|
"""FORBIDDEN_KEYWORDS must contain expected keywords."""
|
||||||
|
assert "terminal" in FORBIDDEN_KEYWORDS
|
||||||
|
assert "docker" in FORBIDDEN_KEYWORDS
|
||||||
|
assert "nano" in FORBIDDEN_KEYWORDS
|
||||||
|
assert "bash" in FORBIDDEN_KEYWORDS
|
||||||
|
assert "sudo" in FORBIDDEN_KEYWORDS
|
||||||
|
assert "shell" in FORBIDDEN_KEYWORDS
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_owner_safe_clean_text():
|
||||||
|
assert is_owner_safe("Log in to your Vagaro account in your browser.") is True
|
||||||
|
assert is_owner_safe("Choose a name for your assistant.") is True
|
||||||
|
assert is_owner_safe("Share the API key with your operator.") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_owner_safe_forbidden_text():
|
||||||
|
assert is_owner_safe("Run docker-compose up") is False
|
||||||
|
assert is_owner_safe("Open a terminal and type") is False
|
||||||
|
assert is_owner_safe("Edit with nano") is False
|
||||||
|
assert is_owner_safe("Execute the bash script") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_owner_safe_multi_word_no_false_positive():
|
||||||
|
"""Multi-word keywords must not false-positive on unrelated text."""
|
||||||
|
# "brew install" should NOT match "brew installation" or "homebrew installed"
|
||||||
|
assert is_owner_safe("We use homebrew installed packages") is True
|
||||||
|
assert is_owner_safe("The brew installation completed") is True
|
||||||
|
assert is_owner_safe("I will install the app manually") is True
|
||||||
|
# "pip install" should NOT match "pip installed" or "install pip"
|
||||||
|
assert is_owner_safe("pip installed successfully") is True
|
||||||
|
assert is_owner_safe("install pip from the store") is True
|
||||||
|
# "git clone" should NOT match "clone git" (reversed)
|
||||||
|
assert is_owner_safe("clone git repository") is True
|
||||||
|
# "docker run" should NOT match "docker running" — but "docker" alone
|
||||||
|
# IS a single-word forbidden keyword, so we test the multi-word phrase
|
||||||
|
# in isolation by checking the phrase itself doesn't match a variant:
|
||||||
|
assert is_owner_safe("the container is running in background") is True
|
||||||
|
# "make install" should NOT match "make installation"
|
||||||
|
assert is_owner_safe("make installation directory") is True
|
||||||
|
# "npm install" should NOT match "npm installed"
|
||||||
|
assert is_owner_safe("npm installed globally") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_owner_safe_multi_word_true_positive():
|
||||||
|
"""Multi-word keywords must still match the exact phrase."""
|
||||||
|
assert is_owner_safe("brew install python") is False
|
||||||
|
assert is_owner_safe("pip install requests") is False
|
||||||
|
assert is_owner_safe("npm install express") is False
|
||||||
|
assert is_owner_safe("git clone https://example.com") is False
|
||||||
|
assert is_owner_safe("docker run nginx") is False
|
||||||
|
assert is_owner_safe("make install all") is False
|
||||||
|
assert is_owner_safe("docker exec container") is False
|
||||||
|
assert is_owner_safe("git push origin main") is False
|
||||||
|
assert is_owner_safe("git pull origin main") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_owner_safe_single_word_boundaries():
|
||||||
|
"""Single-word keywords use word boundaries."""
|
||||||
|
# "docker" should match standalone but not inside unrelated words
|
||||||
|
assert is_owner_safe("Use docker to containerize") is False
|
||||||
|
# "nano" should match standalone
|
||||||
|
assert is_owner_safe("Edit with nano") is False
|
||||||
|
# "bash" should match standalone
|
||||||
|
assert is_owner_safe("Run in bash") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_owner_safe_case_insensitive():
|
||||||
|
assert is_owner_safe("Use DOCKER to run") is False
|
||||||
|
assert is_owner_safe("Open TERMINAL") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_lesson_owner_safe_clean():
|
||||||
|
"""All catalog lessons must pass owner-safe validation."""
|
||||||
|
for lesson in LESSON_CATALOG:
|
||||||
|
violations = validate_lesson_owner_safe(lesson)
|
||||||
|
assert violations == [], (
|
||||||
|
f"Lesson {lesson.step} ({lesson.title}) contains forbidden keywords: {violations}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_catalog_owner_safe():
|
||||||
|
"""Full catalog validation must return empty dict."""
|
||||||
|
violations = validate_catalog_owner_safe()
|
||||||
|
assert violations == {}, f"Catalog has violations: {violations}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_lesson_owner_safe_detects_forbidden():
|
||||||
|
"""Validation correctly detects forbidden keywords."""
|
||||||
|
bad_lesson = Lesson(
|
||||||
|
step=99,
|
||||||
|
title="Bad Lesson",
|
||||||
|
description="Run docker-compose up in your terminal",
|
||||||
|
instructions="Open bash and type sudo nano config.yml",
|
||||||
|
what_it_enables="Nothing",
|
||||||
|
)
|
||||||
|
violations = validate_lesson_owner_safe(bad_lesson)
|
||||||
|
assert "docker" in violations
|
||||||
|
assert "terminal" in violations
|
||||||
|
assert "bash" in violations
|
||||||
|
assert "sudo" in violations
|
||||||
|
assert "nano" in violations
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_lesson_owner_safe_includes_possible_outcomes():
|
||||||
|
"""Validation checks possible_outcomes field for forbidden keywords."""
|
||||||
|
lesson_with_bad_outcome = Lesson(
|
||||||
|
step=99,
|
||||||
|
title="Good Lesson",
|
||||||
|
description="Clean description",
|
||||||
|
instructions="Clean instructions",
|
||||||
|
what_it_enables="Clean enables",
|
||||||
|
possible_outcomes=["connected", "docker"], # "docker" in outcomes
|
||||||
|
)
|
||||||
|
violations = validate_lesson_owner_safe(lesson_with_bad_outcome)
|
||||||
|
assert "docker" in violations, "possible_outcomes should be checked"
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_lesson_owner_safe_clean_outcomes():
|
||||||
|
"""Validation passes when possible_outcomes are clean."""
|
||||||
|
clean_lesson = Lesson(
|
||||||
|
step=99,
|
||||||
|
title="Good Lesson",
|
||||||
|
description="Clean description",
|
||||||
|
instructions="Clean instructions",
|
||||||
|
what_it_enables="Clean enables",
|
||||||
|
possible_outcomes=["connected", "skipped", "later", "error"],
|
||||||
|
)
|
||||||
|
violations = validate_lesson_owner_safe(clean_lesson)
|
||||||
|
assert violations == [], f"Clean lesson should have no violations: {violations}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Formatting ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_format_lesson_text_includes_step():
|
||||||
|
lesson = get_lesson(1)
|
||||||
|
text = format_lesson_text(lesson)
|
||||||
|
assert "Step 1" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_lesson_text_includes_title():
|
||||||
|
lesson = get_lesson(1)
|
||||||
|
text = format_lesson_text(lesson)
|
||||||
|
assert lesson.title in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_lesson_text_includes_instructions():
|
||||||
|
lesson = get_lesson(4)
|
||||||
|
text = format_lesson_text(lesson)
|
||||||
|
assert "What to do:" in text
|
||||||
|
assert lesson.instructions in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_lesson_text_owner_safe():
|
||||||
|
"""Formatted lesson text must be owner-safe."""
|
||||||
|
for lesson in LESSON_CATALOG:
|
||||||
|
text = format_lesson_text(lesson)
|
||||||
|
assert is_owner_safe(text), (
|
||||||
|
f"Lesson {lesson.step} formatted text contains forbidden keywords"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_all_lessons_text_owner_safe():
|
||||||
|
"""Full lessons text must be owner-safe."""
|
||||||
|
text = format_all_lessons_text()
|
||||||
|
assert is_owner_safe(text)
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_all_lessons_text_includes_all_steps():
|
||||||
|
text = format_all_lessons_text()
|
||||||
|
for i in range(1, 8):
|
||||||
|
assert f"Step {i}" in text
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
"""Tests for lumina_skills.providers.setup.fixture_provider."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "skills" / "_lib"))
|
||||||
|
|
||||||
|
from lumina_skills.setup.capability_report import (
|
||||||
|
CapabilityReport,
|
||||||
|
ConnectionStatus,
|
||||||
|
)
|
||||||
|
from lumina_skills.providers.setup.fixture_provider import (
|
||||||
|
load_capability_fixture,
|
||||||
|
load_fixture_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Paths to real fixture files.
|
||||||
|
_FIXTURE_PATH = pathlib.Path(__file__).resolve().parents[2] / "data" / "fixtures" / "setup" / "capability_matrix.json"
|
||||||
|
_ALL_CONNECTED_PATH = pathlib.Path(__file__).resolve().parents[2] / "data" / "fixtures" / "setup" / "capability_matrix_all_connected.json"
|
||||||
|
_ERRORS_PATH = pathlib.Path(__file__).resolve().parents[2] / "data" / "fixtures" / "setup" / "capability_matrix_with_errors.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_fixture_file(tmp_path: pathlib.Path, data: dict) -> pathlib.Path:
|
||||||
|
"""Write a fixture dict to a temp JSON file."""
|
||||||
|
p = tmp_path / "test_fixture.json"
|
||||||
|
p.write_text(json.dumps(data), encoding="utf-8")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
# ── load_capability_fixture ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_load_default_fixture():
|
||||||
|
"""Load the default capability matrix fixture."""
|
||||||
|
report = load_capability_fixture(_FIXTURE_PATH)
|
||||||
|
assert isinstance(report, CapabilityReport)
|
||||||
|
assert report.salon_name == "Lumina Hair Studio & Spa"
|
||||||
|
assert report.is_fixture is True
|
||||||
|
assert len(report.capabilities) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_fixture_statuses():
|
||||||
|
"""Fixture statuses are correctly parsed."""
|
||||||
|
report = load_capability_fixture(_FIXTURE_PATH)
|
||||||
|
statuses = {(c.area, c.provider): c.status for c in report.capabilities}
|
||||||
|
assert statuses[("identity", "assistant_name")] == ConnectionStatus.CONNECTED
|
||||||
|
assert statuses[("channels", "whatsapp")] == ConnectionStatus.CONNECTED
|
||||||
|
assert statuses[("channels", "email")] == ConnectionStatus.SKIPPED
|
||||||
|
assert statuses[("channels", "telegram")] == ConnectionStatus.LATER
|
||||||
|
assert statuses[("scheduling", "vagaro")] == ConnectionStatus.OFFLINE
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_fixture_all_connected():
|
||||||
|
"""All-connected fixture has all CONNECTED statuses."""
|
||||||
|
report = load_capability_fixture(_ALL_CONNECTED_PATH)
|
||||||
|
assert report.all_connected() is True
|
||||||
|
assert report.is_fixture is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_fixture_with_errors():
|
||||||
|
"""Error fixture has ERROR statuses and reports has_errors."""
|
||||||
|
report = load_capability_fixture(_ERRORS_PATH)
|
||||||
|
assert report.has_errors() is True
|
||||||
|
assert report.error_count() >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_fixture_is_fixture_flag():
|
||||||
|
"""Fixture reports always have is_fixture=True."""
|
||||||
|
report = load_capability_fixture(_FIXTURE_PATH)
|
||||||
|
assert report.is_fixture is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_fixture_missing_file(tmp_path: pathlib.Path):
|
||||||
|
"""FileNotFoundError for missing fixture."""
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
load_capability_fixture(tmp_path / "nonexistent.json")
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_fixture_empty_capabilities(tmp_path: pathlib.Path):
|
||||||
|
"""Empty capabilities list returns empty report."""
|
||||||
|
data = {
|
||||||
|
"salon_name": "Empty Salon",
|
||||||
|
"is_fixture": True,
|
||||||
|
"capabilities": [],
|
||||||
|
}
|
||||||
|
path = _make_fixture_file(tmp_path, data)
|
||||||
|
report = load_capability_fixture(path)
|
||||||
|
assert len(report.capabilities) == 0
|
||||||
|
assert report.salon_name == "Empty Salon"
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_fixture_unknown_status_raises(tmp_path: pathlib.Path):
|
||||||
|
"""Unknown status string raises ValueError."""
|
||||||
|
data = {
|
||||||
|
"salon_name": "Test",
|
||||||
|
"is_fixture": True,
|
||||||
|
"capabilities": [{
|
||||||
|
"area": "channels",
|
||||||
|
"provider": "whatsapp",
|
||||||
|
"status": "typo_status",
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
path = _make_fixture_file(tmp_path, data)
|
||||||
|
with pytest.raises(ValueError, match="Unknown capability status"):
|
||||||
|
load_capability_fixture(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_fixture_missing_area_field(tmp_path: pathlib.Path):
|
||||||
|
"""Missing 'area' field raises ValueError with clear message."""
|
||||||
|
data = {
|
||||||
|
"salon_name": "Test",
|
||||||
|
"is_fixture": True,
|
||||||
|
"capabilities": [{
|
||||||
|
"provider": "whatsapp",
|
||||||
|
"status": "connected",
|
||||||
|
# Missing "area"
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
path = _make_fixture_file(tmp_path, data)
|
||||||
|
with pytest.raises(ValueError, match="Capability entry 0 missing required field 'area'"):
|
||||||
|
load_capability_fixture(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_fixture_missing_provider_field(tmp_path: pathlib.Path):
|
||||||
|
"""Missing 'provider' field raises ValueError with clear message."""
|
||||||
|
data = {
|
||||||
|
"salon_name": "Test",
|
||||||
|
"is_fixture": True,
|
||||||
|
"capabilities": [{
|
||||||
|
"area": "channels",
|
||||||
|
"status": "connected",
|
||||||
|
# Missing "provider"
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
path = _make_fixture_file(tmp_path, data)
|
||||||
|
with pytest.raises(ValueError, match="Capability entry 0 missing required field 'provider'"):
|
||||||
|
load_capability_fixture(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_fixture_missing_status_field(tmp_path: pathlib.Path):
|
||||||
|
"""Missing 'status' field raises ValueError with clear message."""
|
||||||
|
data = {
|
||||||
|
"salon_name": "Test",
|
||||||
|
"is_fixture": True,
|
||||||
|
"capabilities": [{
|
||||||
|
"area": "channels",
|
||||||
|
"provider": "whatsapp",
|
||||||
|
# Missing "status"
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
path = _make_fixture_file(tmp_path, data)
|
||||||
|
with pytest.raises(ValueError, match="Capability entry 0 missing required field 'status'"):
|
||||||
|
load_capability_fixture(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_fixture_missing_field_reports_index(tmp_path: pathlib.Path):
|
||||||
|
"""Missing field error message includes the entry index."""
|
||||||
|
data = {
|
||||||
|
"salon_name": "Test",
|
||||||
|
"is_fixture": True,
|
||||||
|
"capabilities": [
|
||||||
|
{
|
||||||
|
"area": "channels",
|
||||||
|
"provider": "whatsapp",
|
||||||
|
"status": "connected",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "books",
|
||||||
|
# Missing "provider" and "status" in entry 1
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
path = _make_fixture_file(tmp_path, data)
|
||||||
|
with pytest.raises(ValueError, match="Capability entry 1 missing required field 'provider'"):
|
||||||
|
load_capability_fixture(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_fixture_malformed_json(tmp_path: pathlib.Path):
|
||||||
|
"""ValueError for invalid JSON."""
|
||||||
|
p = tmp_path / "bad.json"
|
||||||
|
p.write_text("{not valid json}", encoding="utf-8")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
load_capability_fixture(p)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_fixture_default_salon_name(tmp_path: pathlib.Path):
|
||||||
|
"""Missing salon_name defaults to 'Unknown Salon'."""
|
||||||
|
data = {
|
||||||
|
"is_fixture": True,
|
||||||
|
"capabilities": [],
|
||||||
|
}
|
||||||
|
path = _make_fixture_file(tmp_path, data)
|
||||||
|
report = load_capability_fixture(path)
|
||||||
|
assert report.salon_name == "Unknown Salon"
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_fixture_details():
|
||||||
|
"""Details field is loaded from fixture."""
|
||||||
|
report = load_capability_fixture(_FIXTURE_PATH)
|
||||||
|
details = {(c.area, c.provider): c.details for c in report.capabilities}
|
||||||
|
assert "fixture" in details[("scheduling", "vagaro")].lower() or "not yet" in details[("scheduling", "vagaro")].lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fixture labeling: offline/fixture never silent as live ─────────────────
|
||||||
|
|
||||||
|
def test_fixture_never_silent_as_live():
|
||||||
|
"""Fixture reports must have is_fixture=True — never silent as live."""
|
||||||
|
report = load_capability_fixture(_FIXTURE_PATH)
|
||||||
|
assert report.is_fixture is True, "Fixture report must always be labeled as fixture"
|
||||||
|
|
||||||
|
report2 = load_capability_fixture(_ALL_CONNECTED_PATH)
|
||||||
|
assert report2.is_fixture is True
|
||||||
|
|
||||||
|
report3 = load_capability_fixture(_ERRORS_PATH)
|
||||||
|
assert report3.is_fixture is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_fixture_explicit_false_still_respected():
|
||||||
|
"""If fixture explicitly sets is_fixture=false, it is loaded as-is."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
tmp_path = pathlib.Path(tmp)
|
||||||
|
data = {
|
||||||
|
"salon_name": "Test",
|
||||||
|
"is_fixture": False,
|
||||||
|
"capabilities": [{
|
||||||
|
"area": "channels",
|
||||||
|
"provider": "whatsapp",
|
||||||
|
"status": "connected",
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
path = _make_fixture_file(tmp_path, data)
|
||||||
|
report = load_capability_fixture(path)
|
||||||
|
# The loader respects the explicit value.
|
||||||
|
assert report.is_fixture is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── load_fixture_metadata ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_load_metadata():
|
||||||
|
meta = load_fixture_metadata(_FIXTURE_PATH)
|
||||||
|
assert meta["salon_name"] == "Lumina Hair Studio & Spa"
|
||||||
|
assert meta["is_fixture"] is True
|
||||||
|
assert "generated_at" in meta
|
||||||
|
assert "note" in meta
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_metadata_missing_file(tmp_path: pathlib.Path):
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
load_fixture_metadata(tmp_path / "nonexistent.json")
|
||||||
Reference in New Issue
Block a user