Compare commits
4 Commits
15f6a6c713
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 00addc540d | |||
| d6b74c42f6 | |||
| 76df8b7ab6 | |||
| 69b67498ff |
@@ -16,6 +16,9 @@ state/
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Local operator state (S7 connect state — gitignored)
|
||||
.local/
|
||||
|
||||
# Python
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
# Approved structure: document targets only; do not invoke unimplemented scripts.
|
||||
|
||||
.PHONY: help bootstrap install install-s0-s2 install-s3-s5 upgrade doctor verify sync-design \
|
||||
install-s0b install-s1 install-s2 install-s3 install-s4 install-s5
|
||||
install-s0b install-s1 install-s2 install-s3 install-s4 install-s5 \
|
||||
connect connect-status connect-name connect-channels connect-square \
|
||||
connect-quickbooks connect-vagaro connect-all
|
||||
|
||||
help:
|
||||
@echo "Salon_Assistant (Lumina) — $(shell cat VERSION 2>/dev/null || echo 'unknown')"
|
||||
@@ -23,6 +25,16 @@ help:
|
||||
@echo " make install-s4 - S4 only: sandbox verify/onboard"
|
||||
@echo " make install-s5 - S5 only: policy overlays + skills sync"
|
||||
@echo ""
|
||||
@echo "S7: Connect (operator helpers — default --dry-run):"
|
||||
@echo " make connect - show connect help"
|
||||
@echo " make connect-status - show connection status"
|
||||
@echo " make connect-name - name / profile setup"
|
||||
@echo " make connect-channels - connect messaging channels"
|
||||
@echo " make connect-square - connect Square (remote MCP)"
|
||||
@echo " make connect-quickbooks - connect QuickBooks Online (local MCP)"
|
||||
@echo " make connect-vagaro - connect Vagaro (REST + webhooks)"
|
||||
@echo " make connect-all - walk all targets"
|
||||
@echo ""
|
||||
@echo "Not yet implemented (stubbed):"
|
||||
@echo " make upgrade - product upgrade"
|
||||
@echo " make sync-design - list design pack paths"
|
||||
@@ -66,6 +78,32 @@ install-s5:
|
||||
doctor:
|
||||
@bash scripts/doctor.sh
|
||||
|
||||
# ── S7: Connect targets ────────────────────────────────────────────────────
|
||||
|
||||
connect:
|
||||
@bash scripts/connect.sh --help
|
||||
|
||||
connect-status:
|
||||
@bash scripts/connect.sh status
|
||||
|
||||
connect-name:
|
||||
@bash scripts/connect.sh name --dry-run
|
||||
|
||||
connect-channels:
|
||||
@bash scripts/connect.sh channels --dry-run
|
||||
|
||||
connect-square:
|
||||
@bash scripts/connect.sh square --dry-run
|
||||
|
||||
connect-quickbooks:
|
||||
@bash scripts/connect.sh quickbooks --dry-run
|
||||
|
||||
connect-vagaro:
|
||||
@bash scripts/connect.sh vagaro --dry-run
|
||||
|
||||
connect-all:
|
||||
@bash scripts/connect.sh all --dry-run
|
||||
|
||||
# ── Stubbed targets ────────────────────────────────────────────────────────
|
||||
|
||||
upgrade:
|
||||
|
||||
@@ -4,16 +4,86 @@ Docker-based **NemoClaw + Hermes** personal ops assistant for a salon/spa owner-
|
||||
|
||||
This directory is the **product seed** for the Gitea repository `Ty_Tech/Salon_Assistant`.
|
||||
|
||||
## Status
|
||||
---
|
||||
|
||||
| Artifact | Location |
|
||||
|----------|----------|
|
||||
| Design plan (SSOT) | [`design/DESIGN_PLAN.md`](design/DESIGN_PLAN.md) |
|
||||
| Use cases (SSOT) | [`design/use-cases.md`](design/use-cases.md) |
|
||||
| Scenarios | [`design/scenarios.md`](design/scenarios.md) |
|
||||
| Resolved decisions | [`design/DECISIONS.md`](design/DECISIONS.md) |
|
||||
| Operator runbooks | [`docs/`](docs/) |
|
||||
| Implementation | **Not started** until explicit **build** / **implement** |
|
||||
## What this is
|
||||
|
||||
Lumina packages a minimal Hermes sandbox so a salon owner can ask, in plain language on WhatsApp/Email/Telegram, what's happening today — appointments, gaps, bills due, stock low — and get draft messages to clients or vendors. The assistant reads from the owner's existing SaaS (Vagaro, Square, QuickBooks Online) via MCP or REST. It never sends or publishes on the owner's behalf; it drafts, the owner decides.
|
||||
|
||||
All privileged mutations (sandbox, policy, credentials, channels, inference) go through **`nemohermes` / `openshell`** host CLIs. Product scripts wrap those CLIs. There is no custom control API.
|
||||
|
||||
---
|
||||
|
||||
## How the project flows
|
||||
|
||||
```
|
||||
design/ docs/ scripts/ skills/
|
||||
(why + what) (how-to) (host wrappers) (behavior)
|
||||
│ │ │ │
|
||||
├─ DESIGN_PLAN.md ├─ INSTALL.md ├─ bootstrap.sh ├─ daily-board/
|
||||
├─ use-cases.md ├─ UPGRADE.md ├─ install.sh ├─ availability/
|
||||
├─ scenarios.md ├─ OPERATIONS.md ├─ doctor.sh ├─ client-card/
|
||||
├─ DECISIONS.md ├─ SETUP_UX.md ├─ upgrade.sh ├─ ...
|
||||
└─ specialty docs └─ providers/ └─ connect/*.sh └─ _lib/
|
||||
```
|
||||
|
||||
### Narrative flow
|
||||
|
||||
```
|
||||
Owner says "build"
|
||||
→ Agent reads design/ (SSOT) for the slice to implement
|
||||
→ Agent implements: scripts, skills, tests
|
||||
→ Agent runs review + check gates
|
||||
→ Code lands on a branch (commit only when asked; push only when asked)
|
||||
|
||||
Deployer (operator) on host
|
||||
→ make bootstrap (S0b: Docker if missing)
|
||||
→ make install (S1–S5: env, models, sandbox, policy, skills)
|
||||
→ make doctor (S6: health checks)
|
||||
→ connect helpers (S7: register owner's SaaS — pending)
|
||||
|
||||
Runtime
|
||||
→ OpenShell gateway (credentials, L7 policy, sandbox lifecycle)
|
||||
→ Hermes sandbox (skills, channels, MCP clients — allowlisted)
|
||||
→ External model endpoint (inference, vision aux)
|
||||
|
||||
Owner chats
|
||||
→ WhatsApp / Email / Telegram
|
||||
→ Assistant answers from deterministic facts (tools/fixtures)
|
||||
→ Model ranks and words; drafts outbound; owner sends
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Strategy (plain language)
|
||||
|
||||
- **Platform-first.** All sandbox, policy, credential, channel, and inference mutations go through `nemohermes` / `openshell`. Product scripts wrap those CLIs. No parallel control API.
|
||||
- **Two actors.** Technical operator runs host scripts (install, connect, upgrade, doctor). Salon owner chats only — never sees terminal, Docker, or editor instructions.
|
||||
- **Fixtures-first.** Skills use JSON fixtures until live SaaS connect (S7 / MCP). Output always labels fixture data so the owner never sees silent fake live data.
|
||||
- **Draft-only outbound.** The assistant drafts client/vendor messages; the owner sends or posts. No silent send, publish, or pay.
|
||||
- **Deterministic facts, model for wording.** Code computes appointments, gaps, thresholds, and JSON→domain objects. The model ranks, paraphrases, and generates drafts within style constraints.
|
||||
- **No agent pay.** Refused by OpenShell policy + skill hard-fail + model refusal.
|
||||
- **Auto-updates on by default.** Owner-transparent; operator can disable. Rollback available.
|
||||
|
||||
---
|
||||
|
||||
## Install stages
|
||||
|
||||
| Stage | What | Status |
|
||||
|-------|------|--------|
|
||||
| S0 | Host baselining (human) | ✅ Procedural |
|
||||
| S0b | Docker bootstrap | ✅ Implemented |
|
||||
| S1 | `.env` from `.env.example` | ✅ Implemented |
|
||||
| S2 | Model + vision config; vision smoke | ✅ Implemented |
|
||||
| S3 | Stack alignment (OpenShell owns sandbox) | ✅ Implemented |
|
||||
| S4 | Sandbox verify (attach) or onboard | ✅ Implemented |
|
||||
| S5 | Policy overlays + skills sync | ✅ Implemented |
|
||||
| S6 | Doctor (health checks) | ✅ Implemented |
|
||||
| S7 | Owner connect + operator connect helpers | ⏳ Pending |
|
||||
|
||||
Full install guide: [docs/INSTALL.md](docs/INSTALL.md) · Script details: [scripts/README.md](scripts/README.md)
|
||||
|
||||
---
|
||||
|
||||
## Product planes
|
||||
|
||||
@@ -24,25 +94,61 @@ This directory is the **product seed** for the Gitea repository `Ty_Tech/Salon_A
|
||||
| Owner messaging | WhatsApp, Email, Telegram |
|
||||
| Client / social drafts | Draft only; owner sends/posts |
|
||||
| Social craft | Owner photos/video + vision aux |
|
||||
| Setup / ops | Install, connect SaaS, automatic updates (on by default) |
|
||||
| Identity & memory | Named assistant; confirmed preferences |
|
||||
| Setup & education | Install, then connect their SaaS |
|
||||
| Control | OpenShell policy + Hermes security + skill contracts |
|
||||
| Observability | Logs, health, structured events, redaction |
|
||||
|
||||
## Doc placement
|
||||
Full detail: [design/planes.md](design/planes.md)
|
||||
|
||||
| Folder | Purpose |
|
||||
|--------|---------|
|
||||
---
|
||||
|
||||
## Doc map
|
||||
|
||||
| Location | Purpose |
|
||||
|----------|---------|
|
||||
| **`design/`** | Product design SSOT — plan, use cases, scenarios, decisions, MCP strategy, update lifecycle |
|
||||
| **`docs/`** | Operator/user manuals — install, upgrade, providers, ops |
|
||||
| **`docs/`** | Operator runbooks — install, upgrade, providers, ops |
|
||||
| **`AGENTS.md`** | Agent rails — execution loop, Git hygiene, orchestrator/worker split |
|
||||
| **`scripts/`** | Host wrappers around Docker + `nemohermes`/`openshell` |
|
||||
| **`skills/`** | Product behavior — deterministic scripts, fixtures, SKILL.md contracts |
|
||||
|
||||
## Platform-first rule
|
||||
---
|
||||
|
||||
All sandbox, policy, credential, channel, and inference mutations go through **`nemohermes` / `openshell`**. Product scripts (when built) wrap those CLIs. No parallel control API.
|
||||
## Implementation progress
|
||||
|
||||
## Layout (scaffolds)
|
||||
Implementation queue: [design/IMPLEMENT_QUEUE.md](design/IMPLEMENT_QUEUE.md)
|
||||
|
||||
Code under `skills/`, `scripts/`, `services/`, `policy/`, etc. is **scaffold only** until **build**. See each area’s `README.md`.
|
||||
| Task | Slice | Status |
|
||||
|------|-------|--------|
|
||||
| Task 1 | S0–S2 (bootstrap, env, models, vision smoke) | ✅ Done |
|
||||
| Task 2 | S3–S5 (stack, sandbox, policy, skills sync) | ✅ Done |
|
||||
| Task 3 | S6 (doctor health checks) | ✅ Done |
|
||||
| Task 4 | A1 daily-board (fixtures-only) | ✅ Done |
|
||||
| Task 5 | E1 setup-education | ⏳ Next |
|
||||
|
||||
## Remote
|
||||
---
|
||||
|
||||
- Gitea: `Ty_Tech/Salon_Assistant` (public)
|
||||
- Git MCP for this product: **`gitea_vps`** only
|
||||
- **Agents:** mandatory **execution loop**, **local-worker split** (Grok orchestrates; **Primary Subagent** / `primary-subagent` on `:8083` does product work), and **Git hygiene** in [`AGENTS.md`](AGENTS.md) (also `CLAUDE.md`, `.grok/rules/*`). Agents must follow the rails without the user restating process. See [`CONTRIBUTING.md`](CONTRIBUTING.md) for branch/PR conventions.
|
||||
## Repository layout
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `design/` | Design SSOT (plan, use cases, scenarios, decisions, planes, det-vs-inf, MCP, updates) |
|
||||
| `docs/` | Operator manuals (install, upgrade, operations, providers, policy, setup UX) |
|
||||
| `scripts/` | Host scripts: bootstrap, install stages, doctor, upgrade, connect helpers |
|
||||
| `skills/` | Skill directories (daily-board implemented; others scaffolded) + `_lib/` shared code |
|
||||
| `policy/openshell/` | Policy overlays applied during S5 |
|
||||
| `agents/hermes/` | Agent package manifest, identity templates, config fragments |
|
||||
| `deploy/compose/` | Docker Compose (optional; OpenShell owns sandbox) |
|
||||
| `data/fixtures/` | JSON fixtures for skills (scheduling, books, etc.) |
|
||||
| `tests/` | Unit tests for deterministic code |
|
||||
| `observability/` | Structured event definitions |
|
||||
| `migrations/` | State migrations for upgrades |
|
||||
|
||||
---
|
||||
|
||||
## Remote / agent notes
|
||||
|
||||
- **Gitea:** `Ty_Tech/Salon_Assistant`
|
||||
- **Git MCP:** `gitea_vps` only (never `gitea_mcp_for_ty` or localhost Git MCP)
|
||||
- **Agent rails:** Mandatory execution loop, local-worker split (Grok orchestrates; Primary Subagent on `:8083` does product work), and Git hygiene — all in [`AGENTS.md`](AGENTS.md). See [`CONTRIBUTING.md`](CONTRIBUTING.md) for branch/PR conventions.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+51
-13
@@ -1,18 +1,56 @@
|
||||
# Design pack (product SSOT)
|
||||
|
||||
This folder is the **product design source of truth** for Salon_Assistant / Lumina.
|
||||
This folder is the **product design source of truth** for Salon_Assistant / Lumina. Operator runbooks live under [`../docs/`](../docs/) and link here for design rationale.
|
||||
|
||||
| Document | Purpose |
|
||||
|----------|---------|
|
||||
| [DESIGN_PLAN.md](DESIGN_PLAN.md) | Architecture + implementation plan (NemoClaw/Hermes, install, MCP, updates) |
|
||||
| [use-cases.md](use-cases.md) | Use-case catalog (SSOT — do not duplicate full matrices elsewhere) |
|
||||
| [scenarios.md](scenarios.md) | Narrative scenarios for design and future tests |
|
||||
| [DECISIONS.md](DECISIONS.md) | Resolved decisions from planning |
|
||||
| [planes.md](planes.md) | Capability planes |
|
||||
| [det-vs-inf.md](det-vs-inf.md) | Deterministic code vs model inference |
|
||||
| [mcp-integrations.md](mcp-integrations.md) | Square / QBO / Vagaro / channels |
|
||||
| [updates-lifecycle.md](updates-lifecycle.md) | Software update model and vectors |
|
||||
---
|
||||
|
||||
Operator runbooks live under [`../docs/`](../docs/) and **link here** for design rationale.
|
||||
## Reading order
|
||||
|
||||
**Implementation** of code under `skills/`, `scripts/`, etc. requires an explicit **build** / **implement** order.
|
||||
1. **[DESIGN_PLAN.md](DESIGN_PLAN.md)** — Architecture, install stages, MCP strategy, update lifecycle, success criteria. Read this first.
|
||||
2. **[use-cases.md](use-cases.md)** — Use-case catalog (SSOT — do not duplicate full matrices elsewhere).
|
||||
3. **[scenarios.md](scenarios.md)** — Narrative scenarios for design validation and future tests.
|
||||
4. **[DECISIONS.md](DECISIONS.md)** — Resolved decisions from planning.
|
||||
5. **Specialty docs** (as needed):
|
||||
|
||||
| Document | When to read |
|
||||
|----------|-------------|
|
||||
| [planes.md](planes.md) | Capability planes (scheduling, books, comms, drafts, social, identity, setup, control, observability) |
|
||||
| [det-vs-inf.md](det-vs-inf.md) | Deterministic code vs model inference boundary |
|
||||
| [mcp-integrations.md](mcp-integrations.md) | Square / QBO / Vagaro / channel integration details |
|
||||
| [updates-lifecycle.md](updates-lifecycle.md) | Software update model, vectors, rollback |
|
||||
|
||||
---
|
||||
|
||||
## IMPLEMENT_QUEUE.md
|
||||
|
||||
[IMPLEMENT_QUEUE.md](IMPLEMENT_QUEUE.md) contains the first 5 implementation tasks. Paste **one line at a time** into the orchestrator (Grok Build). Do not paste the whole file.
|
||||
|
||||
| Task | Slice | Status |
|
||||
|------|-------|--------|
|
||||
| Task 1 | S0–S2 (bootstrap, env, models, vision smoke) | ✅ Done |
|
||||
| Task 2 | S3–S5 (stack, sandbox, policy, skills sync) | ✅ Done |
|
||||
| Task 3 | S6 (doctor health checks) | ✅ Done |
|
||||
| Task 4 | A1 daily-board (fixtures-only) | ✅ Done |
|
||||
| Task 5 | E1 setup-education | ⏳ Next |
|
||||
|
||||
---
|
||||
|
||||
## What each file is for
|
||||
|
||||
- **DESIGN_PLAN.md** — The master plan. Architecture, install stages S0–S7, MCP strategy, update lifecycle, success criteria, non-goals. If you need to know "how does this product work at a high level," start here.
|
||||
- **use-cases.md** — Every use case the product supports. This is the catalog that skills implement against. Do not copy full use-case matrices into other docs.
|
||||
- **scenarios.md** — Narrative walkthroughs (e.g., "S8: Morning board on WhatsApp"). Used for design validation and as the basis for future integration tests.
|
||||
- **DECISIONS.md** — Decisions already made during planning. Read before proposing alternatives.
|
||||
- **planes.md** — Capability planes: what each domain (scheduling, books, comms, etc.) covers and which systems it touches.
|
||||
- **det-vs-inf.md** — The boundary between deterministic code (always correct, testable offline) and model inference (ranking, wording, drafts). Skills must respect this split.
|
||||
- **mcp-integrations.md** — How each SaaS (Square, QBO, Vagaro) connects: remote vs local MCP, allow/deny lists.
|
||||
- **updates-lifecycle.md** — Full update model: 30 content vectors, rollback, suppressing Hermes interactive updates, instrumentation.
|
||||
|
||||
---
|
||||
|
||||
## Current implementation state (high level)
|
||||
|
||||
- **Install stages S0–S6:** Implemented (scripts, `make install`, `make doctor`). S7 pending.
|
||||
- **Skills:** `daily-board/` + `_lib/` implemented (fixtures-only, with unit tests). All other skill directories have SKILL.md scaffolds only.
|
||||
- **Upgrade:** `upgrade.sh` pending.
|
||||
- **Connect helpers:** `connect/*.sh` pending.
|
||||
|
||||
@@ -12,6 +12,10 @@ Host (bootstrap, Docker, product scripts, nemohermes/openshell CLIs)
|
||||
→ External OpenAI-compatible model (outside containers OK)
|
||||
```
|
||||
|
||||
## How it flows
|
||||
|
||||
For the end-to-end narrative (design → implement → install → runtime → owner chat), see the **[root README](../README.md)** section "How the project flows."
|
||||
|
||||
## Related design docs
|
||||
|
||||
- [Planes](../design/planes.md)
|
||||
|
||||
+87
-2
@@ -1,6 +1,6 @@
|
||||
# Install
|
||||
|
||||
**Status:** Stages S0–S6 implemented. S7 pending.
|
||||
**Status:** Stages S0–S7 implemented.
|
||||
|
||||
## Stages
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
| S4 | Host script | Sandbox verify (attach) or onboard (clean host) | ✅ Implemented |
|
||||
| S5 | Host script | Policy overlays + skills sync via nemohermes | ✅ Implemented |
|
||||
| S6 | Host script | Doctor green | ✅ Implemented |
|
||||
| S7 | Owner + operator connect helpers | Name assistant; connect **their** SaaS/channels | ⏳ Pending |
|
||||
| S7 | Owner + operator connect helpers | Name assistant; connect **their** SaaS/channels | ✅ Implemented |
|
||||
|
||||
## Platform commands (normative)
|
||||
|
||||
@@ -165,6 +165,91 @@ make install-s3-s5
|
||||
- See [design/scenarios.md](../design/scenarios.md) (S1–S5) for operational scenarios.
|
||||
- See [OPERATIONS.md](OPERATIONS.md) for day-2 operator commands.
|
||||
|
||||
## S7: Owner messaging + operator connect scripts
|
||||
|
||||
After install stages S0–S6 are complete, the operator connects the owner's SaaS
|
||||
integrations and messaging channels.
|
||||
|
||||
**Safety model:** `--dry-run` is the default. Use `--apply` to perform mutations.
|
||||
All mutations use `nemohermes` / `openshell`. No parallel control API.
|
||||
|
||||
### Connect dispatcher
|
||||
|
||||
```bash
|
||||
./scripts/connect.sh --help
|
||||
./scripts/connect.sh status
|
||||
./scripts/connect.sh all --dry-run # preview all targets
|
||||
./scripts/connect.sh all --apply # execute all targets
|
||||
```
|
||||
|
||||
### Per-target connect
|
||||
|
||||
```bash
|
||||
# Name / profile (sandbox identity)
|
||||
./scripts/connect.sh name --dry-run
|
||||
./scripts/connect.sh name --apply
|
||||
|
||||
# Messaging channels (WhatsApp, Email, Telegram)
|
||||
./scripts/connect.sh channels --target whatsapp --dry-run
|
||||
./scripts/connect.sh channels --target whatsapp --apply
|
||||
./scripts/connect.sh channels --target telegram --apply
|
||||
./scripts/connect.sh channels --target email --apply
|
||||
|
||||
# Square (remote MCP — read-only tools)
|
||||
./scripts/connect.sh square --dry-run
|
||||
./scripts/connect.sh square --apply
|
||||
|
||||
# QuickBooks Online (local MCP — read-only tools)
|
||||
./scripts/connect.sh quickbooks --dry-run
|
||||
./scripts/connect.sh quickbooks --apply
|
||||
|
||||
# Vagaro (REST + webhooks)
|
||||
./scripts/connect.sh vagaro --dry-run
|
||||
./scripts/connect.sh vagaro --apply
|
||||
```
|
||||
|
||||
### Make targets
|
||||
|
||||
```bash
|
||||
make connect # show connect help
|
||||
make connect-status # show connection status
|
||||
make connect-name # name / profile (--dry-run)
|
||||
make connect-channels # channels (--dry-run)
|
||||
make connect-square # Square (--dry-run)
|
||||
make connect-quickbooks # QuickBooks (--dry-run)
|
||||
make connect-vagaro # Vagaro (--dry-run)
|
||||
make connect-all # all targets (--dry-run)
|
||||
```
|
||||
|
||||
### Connection state
|
||||
|
||||
State is stored in `.local/capability_state.json` (gitignored). Status values:
|
||||
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| `connected` | Integration is active and verified |
|
||||
| `skipped` | Operator or owner chose to skip |
|
||||
| `later` | Planned for future setup |
|
||||
| `error` | Connection attempt failed — needs attention |
|
||||
| `offline` | Not yet connected — using fixtures |
|
||||
|
||||
### Capability report
|
||||
|
||||
After connecting, export a capability report compatible with the setup-education skill:
|
||||
|
||||
```bash
|
||||
# Via the connect state library (source in a script)
|
||||
source scripts/lib/connect_state.sh
|
||||
export_capability_report > /tmp/capability_report.json
|
||||
```
|
||||
|
||||
### Owner-safe messaging
|
||||
|
||||
The owner never receives terminal, Docker, or editor instructions. All connect
|
||||
work is done by the operator using these scripts. The owner interacts with the
|
||||
assistant through connected channels (WhatsApp, Email, Telegram) and completes
|
||||
vendor browser steps (OAuth consent, BotFather, etc.) on their own devices.
|
||||
|
||||
## UAT host notes
|
||||
|
||||
This repository was tested on a live host with:
|
||||
|
||||
+27
-7
@@ -2,16 +2,36 @@
|
||||
|
||||
Runbooks and how-tos. **Product design SSOT** is under [`../design/`](../design/).
|
||||
|
||||
| Location | Purpose |
|
||||
|----------|---------|
|
||||
| `design/` | Why and what — architecture, use cases, scenarios, decisions |
|
||||
| `docs/` | How-to — install, upgrade, operations, providers, policy |
|
||||
|
||||
---
|
||||
|
||||
## Document status
|
||||
|
||||
| Document | Purpose | Status |
|
||||
|----------|---------|--------|
|
||||
| [DEPLOYER_HOST.md](DEPLOYER_HOST.md) | Create/baselined host requirements | Outline (POR) |
|
||||
| [INSTALL.md](INSTALL.md) | Install stages S0–S6 | Outline (POR) |
|
||||
| [UPGRADE.md](UPGRADE.md) | Upgrade / rollback | Outline — full design in [design/updates-lifecycle.md](../design/updates-lifecycle.md) |
|
||||
| [HERMES_MODELS.md](HERMES_MODELS.md) | Main + auxiliary models | Outline (POR) |
|
||||
| [ARCHITECTURE.md](ARCHITECTURE.md) | Operator-facing architecture summary | Outline → links design |
|
||||
| [DEPLOYER_HOST.md](DEPLOYER_HOST.md) | Create/baseline host requirements | ✅ Procedural — ready for operator use |
|
||||
| [INSTALL.md](INSTALL.md) | Install stages S0–S6 | ✅ Implemented |
|
||||
| [HERMES_MODELS.md](HERMES_MODELS.md) | Main + auxiliary models | ✅ S2-ready |
|
||||
| [OPERATIONS.md](OPERATIONS.md) | Day-2 logs, doctor | ✅ Doctor (S6) implemented |
|
||||
| [POLICY.md](POLICY.md) | OpenShell policy apply | Outline (POR) |
|
||||
| [SETUP_UX.md](SETUP_UX.md) | Owner educational connect flows | Outline (POR) |
|
||||
| [OPERATIONS.md](OPERATIONS.md) | Day-2 logs, doctor | Outline (POR) |
|
||||
| [UPGRADE.md](UPGRADE.md) | Upgrade / rollback | Outline — full design in [design/updates-lifecycle.md](../design/updates-lifecycle.md) |
|
||||
| [ARCHITECTURE.md](ARCHITECTURE.md) | Operator-facing architecture summary | Outline → links design |
|
||||
| [providers/](providers/) | Square, QBO, Vagaro, channels | Outlines |
|
||||
|
||||
Implementation of install scripts is **not** started until **build**.
|
||||
---
|
||||
|
||||
## What's done vs planned
|
||||
|
||||
**Done (scripts implemented, callable via `make`):**
|
||||
- S0b: Docker bootstrap (`make bootstrap`)
|
||||
- S1–S5: Full install (`make install`)
|
||||
- S6: Doctor health checks (`make doctor`)
|
||||
|
||||
**Pending:**
|
||||
- S7: Owner connect + operator connect helpers (`connect/*.sh`)
|
||||
- Upgrade: `upgrade.sh` (snapshot, pull, migrate, re-apply policy, doctor)
|
||||
|
||||
+30
-10
@@ -1,27 +1,47 @@
|
||||
# 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
|
||||
|
||||
Install stages S0–S6 complete ([INSTALL.md](INSTALL.md)).
|
||||
Install stages S0–S6 complete ([INSTALL.md](INSTALL.md)). S7 connect scripts available ([INSTALL.md § S7](INSTALL.md#s7-owner-messaging--operator-connect-scripts)).
|
||||
|
||||
## Flow
|
||||
|
||||
1. **Name the assistant** → profile/sandbox name.
|
||||
2. **Profile intake** — business, timezone, hours, priorities, hard rules.
|
||||
3. **Channels** — WhatsApp, Email, Telegram (connect / skip / later).
|
||||
4. **Scheduling** — owner’s Vagaro and/or Square.
|
||||
5. **Books** — owner’s QuickBooks Online.
|
||||
6. **Expectations** — draft-only client send; no auto-publish; no inventing live data.
|
||||
1. **Name the assistant** → profile/sandbox name.
|
||||
2. **Profile intake** — business, timezone, hours, priorities, hard rules.
|
||||
3. **Channels** — WhatsApp, Email, Telegram (connect / skip / later).
|
||||
4. **Scheduling** — owner's Vagaro and/or Square.
|
||||
5. **Books** — owner's QuickBooks Online.
|
||||
6. **Expectations** — draft-only client send; no auto-publish; no inventing live data.
|
||||
7. **Capability report** — plain language.
|
||||
|
||||
## Rules
|
||||
|
||||
- Owner never receives terminal/Docker/editor instructions.
|
||||
- Secrets via operator `scripts/connect-*.sh` + OpenShell providers / NemoClaw channel flows.
|
||||
- Owner never receives terminal/Docker/editor instructions.
|
||||
- Secrets via operator `scripts/connect-*.sh` + OpenShell providers / NemoClaw channel flows.
|
||||
- Agent explains vendor browser steps only.
|
||||
|
||||
## Operator connect scripts (S7)
|
||||
|
||||
After install, the operator runs connect scripts to wire the owner's SaaS:
|
||||
|
||||
```bash
|
||||
# Preview all connections
|
||||
./scripts/connect.sh all --dry-run
|
||||
|
||||
# Connect specific integrations
|
||||
./scripts/connect.sh square --apply
|
||||
./scripts/connect.sh channels --target whatsapp --apply
|
||||
./scripts/connect.sh quickbooks --apply
|
||||
./scripts/connect.sh vagaro --apply
|
||||
|
||||
# Check status
|
||||
./scripts/connect.sh status
|
||||
```
|
||||
|
||||
See [docs/INSTALL.md § S7](INSTALL.md#s7-owner-messaging--operator-connect-scripts) for full details.
|
||||
|
||||
## Related
|
||||
|
||||
[design/use-cases.md](../design/use-cases.md) family E · [design/mcp-integrations.md](../design/mcp-integrations.md)
|
||||
|
||||
@@ -1,10 +1,47 @@
|
||||
# Owner channels (WhatsApp, Email, Telegram)
|
||||
|
||||
**Status:** Outline.
|
||||
**Status:** Connect script implemented.
|
||||
|
||||
- MVP owner ↔ assistant channels: **WhatsApp, Email, Telegram**.
|
||||
- Configured via **NemoClaw Hermes channel commands** (`nemohermes … channels add`, rebuild when required).
|
||||
- Allowlists for who may talk to the bot.
|
||||
- Owner never configures via host shell recipes in chat.
|
||||
- MVP owner ↔ assistant channels: **WhatsApp, Email, Telegram**.
|
||||
- Configured via **NemoClaw Hermes channel commands** (`nemohermes … channels add`, rebuild when required).
|
||||
- Allowlists for who may talk to the bot.
|
||||
- Owner never configures via host shell recipes in chat.
|
||||
|
||||
## Connect procedure
|
||||
|
||||
```bash
|
||||
# Preview all channels (default)
|
||||
./scripts/connect.sh channels --dry-run
|
||||
|
||||
# Connect a specific channel
|
||||
./scripts/connect.sh channels --target whatsapp --apply
|
||||
./scripts/connect.sh channels --target telegram --apply
|
||||
./scripts/connect.sh channels --target email --apply
|
||||
|
||||
# Connect all channels
|
||||
./scripts/connect.sh channels --apply
|
||||
```
|
||||
|
||||
### What the script does
|
||||
|
||||
1. **Dry-run:** Shows steps for each channel without executing.
|
||||
2. **Apply:**
|
||||
- Prompts for channel-specific credentials.
|
||||
- Calls `nemohermes <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).
|
||||
|
||||
@@ -1,8 +1,59 @@
|
||||
# QuickBooks Online
|
||||
|
||||
**Status:** Outline. Design: [design/mcp-integrations.md](../../design/mcp-integrations.md).
|
||||
**Status:** Connect script implemented. Design: [design/mcp-integrations.md](../../design/mcp-integrations.md).
|
||||
|
||||
- **Agent path:** local QBO MCP on Docker network with Hermes.
|
||||
- **Allow:** P&L/reports, invoice/bill/vendor/customer read, company info.
|
||||
- **Deny:** payment/bill_payment tools; write/update/delete off for MVP.
|
||||
- **Connect:** operator `scripts/connect/connect-quickbooks.sh` (build).
|
||||
- **Agent path:** local QBO MCP on Docker network with Hermes.
|
||||
- **Allow:** P&L/reports, invoice/bill/vendor/customer read, company info.
|
||||
- **Deny:** payment/bill_payment tools; write/update/delete off for MVP.
|
||||
- **Connect:** operator `scripts/connect/connect-quickbooks.sh`.
|
||||
|
||||
## Connect procedure
|
||||
|
||||
```bash
|
||||
# Preview (default)
|
||||
./scripts/connect.sh quickbooks --dry-run
|
||||
|
||||
# Execute (prompts for credentials)
|
||||
./scripts/connect.sh quickbooks --apply
|
||||
```
|
||||
|
||||
### What the script does
|
||||
|
||||
1. **Dry-run:** Shows MCP image, container name, Docker network, allowed/denied tools.
|
||||
2. **Apply:**
|
||||
- Prompts for QBO Client ID, Client Secret, Access Token, Realm ID.
|
||||
- Stores credentials via `openshell provider set qbo-*`.
|
||||
- Registers MCP server type via `nemohermes config set`.
|
||||
- Applies tool allowlist (read-only) and denylist (payment/write tools).
|
||||
- Records status in `.local/capability_state.json`.
|
||||
- Provides guidance for starting the MCP container.
|
||||
|
||||
### Owner steps (browser only)
|
||||
|
||||
1. Create a QBO application at [developer.intuit.com](https://developer.intuit.com).
|
||||
2. Complete OAuth flow to obtain Client ID, Client Secret, and Access Token.
|
||||
3. Note the Realm ID (company ID).
|
||||
4. Provide credentials to the operator.
|
||||
|
||||
### Tool filters
|
||||
|
||||
| Allowed (read) | Denied |
|
||||
|----------------|--------|
|
||||
| `get_report` | `create_payment` |
|
||||
| `search_invoice` | `bill_payment` |
|
||||
| `get_invoice` | `create_invoice` |
|
||||
| `search_bill` | `update_invoice` |
|
||||
| `get_bill` | `delete_invoice` |
|
||||
| `search_vendor` | `create_bill` |
|
||||
| `get_vendor` | `update_bill` |
|
||||
| `search_customer` | `delete_bill` |
|
||||
| `get_customer` | |
|
||||
| `get_company_info` | |
|
||||
|
||||
### MCP container
|
||||
|
||||
The QBO MCP server runs as a Docker container on the same network as Hermes:
|
||||
|
||||
- **Image:** `ghcr.io/intuit/quickbooks-online-mcp-server:latest`
|
||||
- **Container:** `lumina-qbo-mcp`
|
||||
- **Network:** `lumina-network`
|
||||
|
||||
@@ -1,8 +1,48 @@
|
||||
# Square
|
||||
|
||||
**Status:** Outline. Design: [design/mcp-integrations.md](../../design/mcp-integrations.md).
|
||||
**Status:** Connect script implemented. Design: [design/mcp-integrations.md](../../design/mcp-integrations.md).
|
||||
|
||||
- **Agent path:** remote Square MCP.
|
||||
- **Allow:** bookings, customers, catalog, inventory/location reads.
|
||||
- **Deny:** payments, refunds, cards, checkout, payouts.
|
||||
- **Connect:** operator `scripts/connect/connect-square.sh` (build) + owner browser OAuth.
|
||||
- **Agent path:** remote Square MCP (`mcp.squareup.com`).
|
||||
- **Allow:** bookings, customers, catalog, inventory/location reads.
|
||||
- **Deny:** payments, refunds, cards, checkout, payouts.
|
||||
- **Connect:** operator `scripts/connect/connect-square.sh` + owner browser OAuth.
|
||||
|
||||
## Connect procedure
|
||||
|
||||
```bash
|
||||
# Preview (default)
|
||||
./scripts/connect.sh square --dry-run
|
||||
|
||||
# Execute (prompts for access token)
|
||||
./scripts/connect.sh square --apply
|
||||
```
|
||||
|
||||
### What the script does
|
||||
|
||||
1. **Dry-run:** Shows MCP URL, allowed/denied tools, and step-by-step procedure.
|
||||
2. **Apply:**
|
||||
- Prompts for Square OAuth access token (read scope).
|
||||
- Stores token via `openshell provider set square-access-token`.
|
||||
- Registers MCP server URL via `nemohermes config set`.
|
||||
- Applies tool allowlist (read-only) and denylist (payment tools).
|
||||
- Records status in `.local/capability_state.json`.
|
||||
|
||||
### Owner steps (browser only)
|
||||
|
||||
1. Create a Square application at [developer.squareup.com](https://developer.squareup.com).
|
||||
2. Generate an OAuth access token with read-only scope.
|
||||
3. Provide the token to the operator.
|
||||
|
||||
### Tool filters
|
||||
|
||||
| Allowed (read) | Denied |
|
||||
|----------------|--------|
|
||||
| `bookings/list_bookings` | `payments/*` |
|
||||
| `bookings/get_booking` | `refunds/*` |
|
||||
| `customers/list_customers` | `cards/*` |
|
||||
| `customers/get_customer` | `checkout/*` |
|
||||
| `catalog/list_catalog` | `payouts/*` |
|
||||
| `catalog/search_catalog_objects` | |
|
||||
| `inventory/list_inventory` | |
|
||||
| `locations/list_locations` | |
|
||||
| `locations/get_location` | |
|
||||
|
||||
@@ -1,8 +1,41 @@
|
||||
# Vagaro
|
||||
|
||||
**Status:** Outline. Design: [design/mcp-integrations.md](../../design/mcp-integrations.md).
|
||||
**Status:** Connect script implemented. Design: [design/mcp-integrations.md](../../design/mcp-integrations.md).
|
||||
|
||||
- **No public MCP** — REST + webhooks.
|
||||
- Appointments, clients, services, staff.
|
||||
- No unofficial scrape.
|
||||
- **Connect:** operator `scripts/connect/connect-vagaro.sh` (build).
|
||||
- **No public MCP** — REST + webhooks.
|
||||
- Appointments, clients, services, staff.
|
||||
- No unofficial scrape.
|
||||
- **Connect:** operator `scripts/connect/connect-vagaro.sh`.
|
||||
|
||||
## Connect procedure
|
||||
|
||||
```bash
|
||||
# Preview (default)
|
||||
./scripts/connect.sh vagaro --dry-run
|
||||
|
||||
# Execute (prompts for credentials)
|
||||
./scripts/connect.sh vagaro --apply
|
||||
```
|
||||
|
||||
### What the script does
|
||||
|
||||
1. **Dry-run:** Shows API base URL, webhook port, and step-by-step procedure.
|
||||
2. **Apply:**
|
||||
- Prompts for Vagaro API Key, API Secret, and optional Webhook Secret.
|
||||
- Stores credentials via `openshell provider set vagaro-*`.
|
||||
- Verifies API connectivity (best-effort curl to `/v1/me`).
|
||||
- Records status in `.local/capability_state.json`.
|
||||
- Provides webhook URL guidance.
|
||||
|
||||
### Owner steps (browser only)
|
||||
|
||||
1. Create a Vagaro developer account at [developer.vagaro.com](https://developer.vagaro.com).
|
||||
2. Register an application to obtain API credentials.
|
||||
3. Configure webhook URL in the Vagaro dashboard.
|
||||
4. Provide credentials to the operator.
|
||||
|
||||
### API configuration
|
||||
|
||||
- **API Base:** `https://api.vagaro.com`
|
||||
- **Webhook port:** `9876` (configurable via `LUMINA_VAGARO_WEBHOOK_PORT`)
|
||||
- **Webhook URL:** `https://<your-host>:9876/vagaro/webhook`
|
||||
|
||||
+29
-3
@@ -1,6 +1,6 @@
|
||||
# Host scripts
|
||||
|
||||
**Status:** S0b–S6 implemented. S7 pending.
|
||||
**Status:** S0b–S7 implemented.
|
||||
|
||||
All scripts wrap **`nemohermes` / `openshell` / Docker**. No parallel control API.
|
||||
|
||||
@@ -12,11 +12,16 @@ All scripts wrap **`nemohermes` / `openshell` / Docker**. No parallel control AP
|
||||
| `install.sh` | Staged installer (S0b–S5) | ✅ S0b–S5 |
|
||||
| `install/s1-env.sh` | S1: create/validate `.env` | ✅ S1 |
|
||||
| `install/s2-models.sh` | S2: model + vision config + smoke | ✅ S2 |
|
||||
| `install/s4-sandbox.sh` | S4: sandbox verify (attach) or onboard | ✅ S4 |
|
||||
| `install/s4-sandbox.sh` | S4: sandbox verify/onboard | ✅ S4 |
|
||||
| `install/s5-policy-skills.sh` | S5: policy overlays + skills sync | ✅ S5 |
|
||||
| `doctor.sh` | Health checks (Docker, CLIs, sandbox, policy, skills, inference) | ✅ S6 |
|
||||
| `connect.sh` | S7: Operator connect dispatcher | ✅ S7 |
|
||||
| `connect/connect-name.sh` | S7: Name / profile setup | ✅ S7 |
|
||||
| `connect/connect-channels.sh` | S7: Messaging channels (WhatsApp/Email/Telegram) | ✅ S7 |
|
||||
| `connect/connect-square.sh` | S7: Square (remote MCP) | ✅ S7 |
|
||||
| `connect/connect-quickbooks.sh` | S7: QuickBooks Online (local MCP) | ✅ S7 |
|
||||
| `connect/connect-vagaro.sh` | S7: Vagaro (REST + webhooks) | ✅ S7 |
|
||||
| `upgrade.sh` | Snapshot, pull pins, migrate, re-apply policy, doctor | ⏳ Pending |
|
||||
| `connect/*.sh` | Operator connect helpers (Square, QBO, Vagaro, channels) | ⏳ Pending |
|
||||
|
||||
## Shared library
|
||||
|
||||
@@ -25,6 +30,7 @@ All scripts wrap **`nemohermes` / `openshell` / Docker**. No parallel control AP
|
||||
| `lib/common.sh` | Logging, CLI detection, env loading, repo root |
|
||||
| `lib/env.sh` | `.env` validation and creation helpers |
|
||||
| `lib/vision_smoke.sh` | Vision capability smoke test |
|
||||
| `lib/connect_state.sh` | S7: Local capability state management |
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -46,6 +52,14 @@ All scripts wrap **`nemohermes` / `openshell` / Docker**. No parallel control AP
|
||||
./scripts/doctor.sh # human-readable
|
||||
./scripts/doctor.sh --json # machine-readable
|
||||
|
||||
# S7: Connect (default --dry-run; use --apply for mutations)
|
||||
./scripts/connect.sh --help
|
||||
./scripts/connect.sh status
|
||||
./scripts/connect.sh square --dry-run
|
||||
./scripts/connect.sh square --apply
|
||||
./scripts/connect.sh channels --target whatsapp --apply
|
||||
./scripts/connect.sh all --dry-run
|
||||
|
||||
# Or via Make
|
||||
make bootstrap
|
||||
make install
|
||||
@@ -54,8 +68,20 @@ make install-s2
|
||||
make install-s3-s5
|
||||
make install-s5
|
||||
make doctor
|
||||
make connect
|
||||
make connect-status
|
||||
make connect-square
|
||||
make connect-all
|
||||
```
|
||||
|
||||
## S7 Safety model
|
||||
|
||||
- **`--dry-run` is the default** — preview actions without executing
|
||||
- **`--apply`** — execute mutations (prompts for credentials when needed)
|
||||
- **No secrets in git** — credentials stored via OpenShell provider store
|
||||
- **`.local/` directory** — connection state stored in `.local/capability_state.json` (gitignored)
|
||||
- **Owner-safe** — owner never receives terminal/Docker/nano instructions
|
||||
|
||||
## Design reference
|
||||
|
||||
See [docs/INSTALL.md](../docs/INSTALL.md), [docs/UPGRADE.md](../docs/UPGRADE.md), [design/updates-lifecycle.md](../design/updates-lifecycle.md).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+48
-22
@@ -1,30 +1,56 @@
|
||||
# Skills (scaffold)
|
||||
# Skills
|
||||
|
||||
**Status:** Directory layout only. No skill scripts or SKILL.md bodies until **build**.
|
||||
Product behavior: deterministic scripts, fixtures, and SKILL.md contracts.
|
||||
|
||||
See use-case catalog: [design/use-cases.md](../design/use-cases.md).
|
||||
|
||||
## Planned skill families
|
||||
---
|
||||
|
||||
## How skills fit the flow
|
||||
|
||||
```
|
||||
design/use-cases.md skills/<skill>/ skills/_lib/
|
||||
(use-case spec) (SKILL.md + scripts/) (shared providers)
|
||||
│ │ │
|
||||
├─ A1 daily-board ├─ SKILL.md ├─ domain.py
|
||||
├─ B1 books-snapshot ├─ scripts/*.py ├─ board_builder.py
|
||||
├─ ... └─ fixtures/ └─ providers/
|
||||
│ (scheduling, books, mcp)
|
||||
```
|
||||
|
||||
Each skill directory maps to one or more use cases. The SKILL.md defines the contract (inputs, outputs, constraints). Scripts implement deterministic logic. Providers fetch data from fixtures (now) or live SaaS (later via MCP/REST).
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
| Directory | Status |
|
||||
|-----------|--------|
|
||||
| `daily-board/` | Scaffold — skill body at **build** |
|
||||
| `availability/` | Scaffold — skill body at **build** |
|
||||
| `client-card/` | Scaffold — skill body at **build** |
|
||||
| `service-menu/` | Scaffold — skill body at **build** |
|
||||
| `retail-stock/` | Scaffold — skill body at **build** |
|
||||
| `books-snapshot/` | Scaffold — skill body at **build** |
|
||||
| `ar-open-invoices/` | Scaffold — skill body at **build** |
|
||||
| `ap-bills-due/` | Scaffold — skill body at **build** |
|
||||
| `vendor-spend/` | Scaffold — skill body at **build** |
|
||||
| `vendor-inbox/` | Scaffold — skill body at **build** |
|
||||
| `draft-invoice/` | Scaffold — skill body at **build** |
|
||||
| `draft-client-message/` | Scaffold — skill body at **build** |
|
||||
| `social-draft/` | Scaffold — skill body at **build** |
|
||||
| `weekly-digest/` | Scaffold — skill body at **build** |
|
||||
| `remember-forget/` | Scaffold — skill body at **build** |
|
||||
| `setup-education/` | Scaffold — skill body at **build** |
|
||||
| `publish-boundary-test/` | Scaffold — skill body at **build** |
|
||||
| `_lib/lumina_skills/` | Shared providers (scheduling/books/MCP) — empty until **build** |
|
||||
| `daily-board/` | ✅ Implemented — fixtures-only, with `build_board.py` script |
|
||||
| `_lib/lumina_skills/` | ✅ Implemented — domain models, board builder, fixture providers |
|
||||
| `availability/` | Scaffold — SKILL.md only |
|
||||
| `client-card/` | Scaffold — SKILL.md only |
|
||||
| `service-menu/` | Scaffold — SKILL.md only |
|
||||
| `retail-stock/` | Scaffold — SKILL.md only |
|
||||
| `books-snapshot/` | Scaffold — SKILL.md only |
|
||||
| `ar-open-invoices/` | Scaffold — SKILL.md only |
|
||||
| `ap-bills-due/` | Scaffold — SKILL.md only |
|
||||
| `vendor-spend/` | Scaffold — SKILL.md only |
|
||||
| `vendor-inbox/` | Scaffold — SKILL.md only |
|
||||
| `draft-invoice/` | Scaffold — SKILL.md only |
|
||||
| `draft-client-message/` | Scaffold — SKILL.md only |
|
||||
| `social-draft/` | Scaffold — SKILL.md only |
|
||||
| `weekly-digest/` | Scaffold — SKILL.md only |
|
||||
| `remember-forget/` | Scaffold — SKILL.md only |
|
||||
| `setup-education/` | Scaffold — SKILL.md only (Task 5 next) |
|
||||
| `publish-boundary-test/` | Scaffold — SKILL.md only |
|
||||
|
||||
Rules (from design): draft-first outbound; no agent payments; deterministic facts from tools; refuse silent send/publish.
|
||||
---
|
||||
|
||||
## Rules (from design)
|
||||
|
||||
- **Draft-first outbound.** Skills draft messages; the owner sends. No silent send, publish, or pay.
|
||||
- **Deterministic facts from tools.** Code computes appointments, gaps, thresholds, JSON→domain objects. The model ranks and words.
|
||||
- **Fixture data is labeled.** Output always marks `📋 FIXTURE DATA` so the owner never sees silent fake live data.
|
||||
- **Refuse pay / silent send / publish.** Enforced by OpenShell policy + skill hard-fail + model refusal.
|
||||
- **Det vs inference boundary:** [design/det-vs-inf.md](../design/det-vs-inf.md)
|
||||
|
||||
@@ -13,6 +13,9 @@ Shared deterministic library used by Salon_Assistant skills. All code here is
|
||||
| `domain.py` | ✅ | Domain types: `Appointment`, `Gap`, `DayBoard`, `AppointmentStatus` |
|
||||
| `board_builder.py` | ✅ | Deterministic board builder: gaps, confirmation flags, formatting |
|
||||
| `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/books/` | ⏳ | QuickBooks Online adapters (future) |
|
||||
| `providers/mcp/` | ⏳ | MCP client helpers / allowlist metadata (future) |
|
||||
|
||||
@@ -41,6 +41,21 @@ class Appointment:
|
||||
# for fixture convenience.
|
||||
needs_confirmation: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Validate that end_time is after start_time."""
|
||||
if self.end_time < self.start_time:
|
||||
raise ValueError(
|
||||
f"end_time ({self.end_time}) must be >= start_time ({self.start_time}) "
|
||||
f"for appointment {self.appointment_id}"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"Appointment(id={self.appointment_id!r}, "
|
||||
f"{self.start_time:%H:%M}-{self.end_time:%H:%M}, "
|
||||
f"{self.client_name!r}, {self.status.value})"
|
||||
)
|
||||
|
||||
def duration_minutes(self) -> int:
|
||||
"""Appointment duration in whole minutes."""
|
||||
delta = self.end_time - self.start_time
|
||||
@@ -65,6 +80,12 @@ class Gap:
|
||||
# The appointment immediately after this gap (if any).
|
||||
following_appointment_id: Optional[str] = None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"Gap({self.start_time:%H:%M}-{self.end_time:%H:%M}, "
|
||||
f"{self.duration_minutes}min, {self.staff_name!r})"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DayBoard:
|
||||
@@ -83,6 +104,23 @@ class DayBoard:
|
||||
total_booked_minutes: int = 0
|
||||
total_gap_minutes: int = 0
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"DayBoard({self.date}, {self.salon_name!r}, "
|
||||
f"{len(self.appointments)} appts, {len(self.gaps)} gaps)"
|
||||
)
|
||||
|
||||
@property
|
||||
def utilization_rate(self) -> float:
|
||||
"""Fraction of the day that is booked (0.0–1.0).
|
||||
|
||||
Returns 0.0 when there is no total time to compute against.
|
||||
"""
|
||||
total = self.total_booked_minutes + self.total_gap_minutes
|
||||
if total == 0:
|
||||
return 0.0
|
||||
return self.total_booked_minutes / total
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize to a plain dict for JSON output."""
|
||||
return {
|
||||
@@ -110,6 +148,8 @@ class DayBoard:
|
||||
"end": g.end_time.isoformat(),
|
||||
"duration_minutes": g.duration_minutes,
|
||||
"staff": g.staff_name,
|
||||
"preceding_appointment_id": g.preceding_appointment_id,
|
||||
"following_appointment_id": g.following_appointment_id,
|
||||
}
|
||||
for g in self.gaps
|
||||
],
|
||||
@@ -118,4 +158,5 @@ class DayBoard:
|
||||
],
|
||||
"total_booked_minutes": self.total_booked_minutes,
|
||||
"total_gap_minutes": self.total_gap_minutes,
|
||||
"utilization_rate": round(self.utilization_rate, 4),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
|
||||
- Owner-safe: no terminal instructions.
|
||||
- Browser/vendor UI steps only.
|
||||
- Deterministic facts from fixtures; no model inference for capability data.
|
||||
- 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