Compare commits
11 Commits
48566993f6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 00addc540d | |||
| d6b74c42f6 | |||
| 76df8b7ab6 | |||
| 69b67498ff | |||
| 15f6a6c713 | |||
| 0198ab6881 | |||
| e5e179e541 | |||
| 998e32e871 | |||
| 8535f4a767 | |||
| ce087533fa | |||
| 47327d90d8 |
@@ -0,0 +1,30 @@
|
||||
# Salon_Assistant / Lumina — environment configuration
|
||||
# Copy to .env and fill in real values. NEVER commit .env with real secrets.
|
||||
# See docs/INSTALL.md § S1 for details.
|
||||
|
||||
# ── Sandbox identity ───────────────────────────────────────────────────────
|
||||
# NemoClaw sandbox name (used by nemohermes commands). Default: hermes
|
||||
LUMINA_SANDBOX=hermes
|
||||
|
||||
# ── Inference (main model) ────────────────────────────────────────────────
|
||||
# OpenAI-compatible endpoint base URL (trailing slash optional).
|
||||
# Example: local llama.cpp server, remote API, etc.
|
||||
LUMINA_INFERENCE_BASE_URL=http://192.168.1.203:8083/v1
|
||||
|
||||
# Model identifier on the inference endpoint (must match what the server advertises).
|
||||
LUMINA_INFERENCE_MODEL=/home/ty/models/qwen36-27b-mtp-gguf/Qwen3.6-27B-UD-Q4_K_XL.gguf
|
||||
|
||||
# API key for the inference endpoint. Leave empty if the endpoint is unauthenticated.
|
||||
LUMINA_INFERENCE_API_KEY=
|
||||
|
||||
# ── Vision (auxiliary model) ───────────────────────────────────────────────
|
||||
# Vision-capable model for social media / media understanding.
|
||||
# Often the same as LUMINA_INFERENCE_MODEL if the main model is multimodal.
|
||||
LUMINA_VISION_MODEL=/home/ty/models/qwen36-27b-mtp-gguf/Qwen3.6-27B-UD-Q4_K_XL.gguf
|
||||
|
||||
# ── Gateway ────────────────────────────────────────────────────────────────
|
||||
# NemoClaw gateway URL (where OpenShell connects).
|
||||
LUMINA_GATEWAY_URL=https://127.0.0.1:8080
|
||||
|
||||
# Dashboard port (for nemohermes dashboard-url).
|
||||
LUMINA_DASHBOARD_PORT=18789
|
||||
+37
@@ -1,11 +1,48 @@
|
||||
# Secrets and local env (never commit)
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.pem
|
||||
*.key
|
||||
id_rsa*
|
||||
id_ed25519*
|
||||
credentials.json
|
||||
token.json
|
||||
secrets/
|
||||
**/secrets/
|
||||
|
||||
# Runtime / local state
|
||||
state/
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Local operator state (S7 connect state — gitignored)
|
||||
.local/
|
||||
|
||||
# Python
|
||||
.venv/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# Node (if introduced later)
|
||||
node_modules/
|
||||
|
||||
# OS / editor noise
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# Agent / IDE local overrides (do not commit personal agent state)
|
||||
CLAUDE.local.md
|
||||
.claude/settings.local.json
|
||||
.grok/local/
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Execution loop (mandatory — Salon_Assistant)
|
||||
|
||||
Full SSOT: repository root `AGENTS.md` → **Execution loop (mandatory)** and **Git hygiene (mandatory)**.
|
||||
|
||||
This rule exists so agents stay on rails **without** the user naming every slash command.
|
||||
|
||||
## Default loop
|
||||
|
||||
```text
|
||||
Authorize → Plan (if needed) → Build → Review → Check → Local commit (only if asked) → User pushes
|
||||
```
|
||||
|
||||
## Hard rails
|
||||
|
||||
1. **No product implementation** until the user says **build** or **implement**. Docs/design/agent-rules work is fine without that.
|
||||
2. **Do not require the user to restate process.** If they authorize a slice, run plan (when needed) → build → independent review → check yourself.
|
||||
3. **Grok parent = orchestrator only.** All product work (implement / review / check / explore / fix) runs on local **`primary-subagent`** subagents. See `local-worker.md`.
|
||||
4. **Review + check** on multi-file / non-trivial work via **worker** subagents (`model: "primary-subagent"`). Prefer `/review` + `/check-work` patterns that spawn workers. Parent self-LGTM is not enough.
|
||||
5. **Commit only when asked.** **Never push, open/merge PR, or tag** unless the user explicitly orders it. User owns `git push` by default.
|
||||
6. **Scope lock.** Implement only the authorized slice. No drive-by packages, no silent redesign of `design/` SSOT.
|
||||
7. **Subagents:** `model: "primary-subagent"` always (unless user overrides). Implementers edit; reviewers/explorers/verifiers read-only. Parallel writers use worktrees. Inject product hard rules into every worker prompt.
|
||||
8. **Multi-PR / execute-plan:** only with a PR Plan DAG; `--dry-run` first; no Graphite default, no auto-PR, no push/merge unless user overrides. Workers stay on `primary-subagent`. Remote MCP = **gitea_vps** only.
|
||||
9. **Stop** when the slice is done, blocked on a user decision, local worker is down, or secrets / destructive git / forge mistakes would be required — report, do not steamroll onto paid Grok.
|
||||
10. **Platform-first:** `nemohermes` / `openshell` only; no parallel control API; no owner terminal/Docker instructions; no agent pay / silent send.
|
||||
|
||||
## Tiny exception
|
||||
|
||||
Trivial one-file typo/docs nit: plan optional; still no unauthorized build; still no push; still no secrets.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Git hygiene (Salon_Assistant)
|
||||
|
||||
Mandatory. Full rules: repository root `AGENTS.md` → section **Git hygiene (mandatory)**.
|
||||
|
||||
Quick binding rules:
|
||||
|
||||
- **gitea_vps only** for this product’s remote Git/PR MCP (`Ty_Tech/Salon_Assistant`). Never `gitea_mcp_for_ty` or localhost Git MCP.
|
||||
- **No secrets** in commits, messages, PRs, or issues.
|
||||
- **No `git config`** changes.
|
||||
- **No force-push to `main`**, no `--no-verify`, no amend of published commits unless the user explicitly orders it.
|
||||
- **Commit / push / open-or-merge PR only when the user asks.**
|
||||
- Before commit: `status` + staged diff; stage explicit paths only; scan for credentials.
|
||||
- Feature work on named branches (`docs/`, `design/`, `chore/`, `fix/`, `feat/`); base PRs on `main`.
|
||||
- Confirm before destructive Git (hard reset, clean -fd, remote branch delete, history rewrite).
|
||||
- Subagents inherit these rules.
|
||||
|
||||
Detail and checklists: `AGENTS.md`, `CONTRIBUTING.md`, `SECURITY.md`.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Local worker + Grok orchestrator (mandatory)
|
||||
|
||||
Full SSOT: `AGENTS.md` → **Orchestrator vs local worker (mandatory)**.
|
||||
|
||||
## Split
|
||||
|
||||
| Role | Model | Job |
|
||||
|------|-------|-----|
|
||||
| Parent session (composer) | Grok (`grok-4.5` / session default) | Orchestrate only |
|
||||
| Every subagent / workflow child | **`primary-subagent`** (UI: **Primary Subagent**, `http://192.168.1.203:8083/v1`) | **All product work** |
|
||||
|
||||
## Orchestrator MUST
|
||||
|
||||
- Spawn workers with `model: "primary-subagent"` for explore / plan / implement / review / check / fix.
|
||||
- Inject product rails + authorized slice into every worker prompt.
|
||||
- Sequence the execution loop; talk to the user; enforce no push unless asked.
|
||||
- If local endpoint fails: **stop and report** — do not finish the work on paid Grok.
|
||||
|
||||
## Orchestrator MUST NOT
|
||||
|
||||
- Implement product code (`write` / `search_replace` / bulk codegen) in the parent when a worker should.
|
||||
- Omit `model` / use cloud models for workers without an explicit user override for that task.
|
||||
- Use the local worker as the parent orchestrator model.
|
||||
|
||||
## Exceptions (parent only)
|
||||
|
||||
Agent-rules edits (`AGENTS.md`, `.grok/rules/*`), pure Q&A, orientation reads, git inspect, local commit **when user asks**, kill/stop subagents, true one-line nits requested in-chat.
|
||||
|
||||
When unsure → **delegate to `primary-subagent`**.
|
||||
@@ -11,7 +11,12 @@ Remote: Gitea **Ty_Tech/Salon_Assistant** (via **gitea_vps** MCP only for this p
|
||||
2. [`design/use-cases.md`](design/use-cases.md)
|
||||
3. [`design/scenarios.md`](design/scenarios.md)
|
||||
4. [`design/DECISIONS.md`](design/DECISIONS.md)
|
||||
5. [`docs/README.md`](docs/README.md) (operator manuals)
|
||||
5. [`docs/README.md`](docs/README.md) (operator manuals)
|
||||
6. **[Execution loop](#execution-loop-mandatory)** — default harness rails (plan → build → review → check → commit)
|
||||
7. **[Git hygiene](#git-hygiene-mandatory)** — binding for every agent session
|
||||
8. [`CONTRIBUTING.md`](CONTRIBUTING.md) for PR/branch detail
|
||||
|
||||
Also auto-loaded under `.grok/rules/`: `execution-loop.md`, `git-hygiene.md`.
|
||||
|
||||
## Hard rules
|
||||
|
||||
@@ -20,10 +25,244 @@ Remote: Gitea **Ty_Tech/Salon_Assistant** (via **gitea_vps** MCP only for this p
|
||||
- **No** `gitea_mcp_for_ty` / localhost Git MCP for this product — **gitea_vps** only.
|
||||
- Owner never gets terminal/nano/docker instructions.
|
||||
- No agent pay; no silent send/publish.
|
||||
- **`design/`** = product POR SSOT; **`docs/`** = operator runbooks.
|
||||
|
||||
- **`design/`** = product POR SSOT; **`docs/`** = operator runbooks.
|
||||
- **Execution loop + Git hygiene below are mandatory** for every agent (Grok, Claude, Cursor, subagents, workflows, scripts).
|
||||
- **Do not require the user to restate process.** If they authorize work without naming every step, still run the rails. Only the user may *narrow* scope or *skip* a gate (e.g. “docs-only, skip check-work”).
|
||||
- **Orchestrator / worker split** — see [Orchestrator vs local worker](#orchestrator-vs-local-worker-mandatory). Grok (composer) orchestrates only; **all product work runs on the local subagent endpoint** (`primary-subagent`).
|
||||
## Git MCP
|
||||
|
||||
- Server: `gitea_vps`
|
||||
- URL: `https://giteamcp1.vps1.afterthedemo.com/mcp`
|
||||
- User-Agent: `grok-connectors-manager/`
|
||||
- Repo: **owner `Ty_Tech`**, **repo `Salon_Assistant`**, default branch **`main`**
|
||||
|
||||
---
|
||||
|
||||
## Orchestrator vs local worker (mandatory)
|
||||
|
||||
**Purpose:** conserve paid Grok tokens. The parent session is the **orchestrator**; heavy work runs on a **local** OpenAI-compatible endpoint.
|
||||
|
||||
| Role | Who | Model | Allowed to |
|
||||
|------|-----|-------|------------|
|
||||
| **Orchestrator** | Parent Grok Build session (composer) | Session model (e.g. `grok-4.5`) | Talk to user; authorize/scope; run the execution loop; spawn/resume/kill subagents; read results; enforce rails; **light** git status; report blockers |
|
||||
| **Worker** | Every subagent (and workflow `agent()` children) | **`primary-subagent`** (display: **Primary Subagent**, local) | Explore, plan drafts, implement, review, check, fix, write files, run tests in scope |
|
||||
|
||||
### Config (already expected on this host)
|
||||
|
||||
Home `~/.grok/config.toml` (not committed to product git):
|
||||
|
||||
- `[model.primary-subagent]`
|
||||
- `name = "Primary Subagent"`
|
||||
- `base_url = "http://192.168.1.203:8083/v1"`
|
||||
- `model` = GGUF id served by that endpoint (currently `…/Qwen3.6-27B-UD-Q4_K_XL.gguf`)
|
||||
- `[subagents] default_model = "primary-subagent"`
|
||||
- `[subagents.models]` pins: `general-purpose`, `explore`, `plan` → `primary-subagent`
|
||||
- **Removed:** stale `wwg3-worker` / `:8080` entries — do not reintroduce.
|
||||
|
||||
### Orchestrator MUST
|
||||
|
||||
1. **Delegate all product work** — implementation, multi-file edits, test writing, skill/script coding, substantive design drafting when authorized, independent review, and verification — via `spawn_subagent` (or workflow agents) on **`primary-subagent`**.
|
||||
2. **Pass `model: "primary-subagent"`** on every `spawn_subagent` for this product (belt-and-suspenders even when config pins exist). Do **not** pass `grok-4.5` / cloud models to workers unless the user **explicitly** overrides for that one task.
|
||||
3. **Stay coordinator-shaped:** status updates, sequencing, checklists, reading worker summaries, applying execution-loop gates, asking the user when blocked.
|
||||
4. **Inject full rails into every worker prompt:** authorized slice, hard rules, execution loop, Git hygiene, platform-first, no secrets, gitea_vps-only remote MCP.
|
||||
5. **Own Git policy** (commit only if asked; no push/PR/merge unless asked) even when workers make commits in worktrees.
|
||||
6. If the local endpoint is **down or failing**, **stop and report** — do not silently re-run the same workload on paid Grok “to finish.”
|
||||
|
||||
### Orchestrator MUST NOT
|
||||
|
||||
1. **Implement the product itself** with `write` / `search_replace` / large shell codegen on the main session when a worker can do it.
|
||||
2. “Just quickly” rewrite skills, services, scripts, or policy overlays in the parent after **build** is authorized — spawn a worker.
|
||||
3. Spawn workers **without** `model: "primary-subagent"` (or an explicit user override).
|
||||
4. Use `primary-subagent` as the **parent** session model for orchestration (parent stays Grok).
|
||||
5. Burn parent context by pasting entire file trees into chat when a worker can `read_file` / `grep` in a worktree.
|
||||
|
||||
### Allowed parent-session exceptions (small)
|
||||
|
||||
Parent Grok **may** do these without a worker:
|
||||
|
||||
- Answer questions; clarify scope; update **agent rails only** (`AGENTS.md`, `.grok/rules/*`, `CLAUDE.md`) when the user is changing process.
|
||||
- Single-line or pure typography nits the user asked for **in-chat** (still no unauthorized product build).
|
||||
- Read tools for orientation (`read_file`, `grep`, `list_dir`) to write a good worker prompt — not to replace the worker.
|
||||
- Git inspect (`status`/`diff`/`log`) and, **when the user asks**, a local commit of already-prepared work.
|
||||
- Emergency stop / kill runaway subagents.
|
||||
|
||||
If unsure whether something is “product work,” **delegate to `primary-subagent`**.
|
||||
|
||||
### Worker spawn defaults
|
||||
|
||||
```text
|
||||
subagent_type: general-purpose | explore | plan # as needed
|
||||
model: "primary-subagent" # always for this product
|
||||
isolation: "worktree" for parallel/implement writers; none for read-only explore/review when sharing tree is intended
|
||||
capability: read-only for review/explore; read-write or all for implement/fix
|
||||
```
|
||||
|
||||
Prefix `description` with role tags when using skill patterns: `[implementer]`, `[reviewer]`, `[checker]`, etc.
|
||||
|
||||
### Workflows
|
||||
|
||||
Rhai/`workflow` child agents inherit the same rule: product work on **local worker**, not paid parent. Prefer pinning via host subagent defaults; if a workflow supports model opts, set the local worker explicitly.
|
||||
|
||||
---
|
||||
|
||||
## Execution loop (mandatory)
|
||||
|
||||
Default path for **any** non-trivial change. The user does **not** need to type each slash command; the agent **must still execute the gates**. Slash commands (`/plan`, `/review`, `/check-work`, `/execute-plan`, …) are preferred when available; equivalent manual steps (plan in chat, independent reviewer subagent, verifier subagent) are required if skills are not invoked by name.
|
||||
|
||||
```text
|
||||
Authorize → Plan (if needed) → Build → Review → Check → Local commit (if asked) → User pushes
|
||||
```
|
||||
|
||||
### Authorize (hard stop)
|
||||
|
||||
| User said… | Agent may… |
|
||||
|------------|------------|
|
||||
| **build** / **implement** (with or without slice) | Product implementation within stated or agreed slice |
|
||||
| Design / docs / hygiene / scaffolding structure only | Edit design, docs, agent rules, README scaffolds — **not** product runtime code until **build** |
|
||||
| Vague “continue” / “do it” **after** an approved plan or build slice | Continue **that** slice only; do not expand scope |
|
||||
| Nothing authorizing build | **No** product implementation |
|
||||
|
||||
If scope is unclear after **build**, ask **one** clarifying question or propose a minimal slice and wait — do **not** invent a multi-package rewrite.
|
||||
|
||||
### Default gates (do these without being asked)
|
||||
|
||||
1. **Plan** — If approach is ambiguous or multi-file architecture is involved: enter plan mode / write a short plan and get approval **or** follow an existing approved plan/design section. Skip plan only for trivial single-concern edits (typo, one obvious fix).
|
||||
2. **Build** — Implement only the authorized slice. Prefer main session for one sequential change.
|
||||
3. **Review** — Independent review of the diff (prefer `/review --local`, else a **read-only** reviewer subagent). Address **bugs** before claiming done; suggestions/nits: fix or note wontfix with reason.
|
||||
4. **Check** — Verify work (prefer `/check-work`, else a verifier subagent / `make verify` when implementation exists). Do not mark complete on green review alone.
|
||||
5. **Save** — Commit **only** when the user asks (commit / checkpoint / prepare local commit). **Never push, open/merge PR, or tag** unless the user explicitly asks. User owns `git push` by default.
|
||||
|
||||
### Subagents & orchestration (rails, not free-for-all)
|
||||
|
||||
| Rule | Requirement |
|
||||
|------|-------------|
|
||||
| Model | **Every** product subagent: `model: "primary-subagent"`. Parent stays Grok. See [Orchestrator vs local worker](#orchestrator-vs-local-worker-mandatory). |
|
||||
| Who codes | Workers implement/review/check. Parent does **not** implement product code. |
|
||||
| Roles | **Implementers** edit; **reviewers/explorers/verifiers** are read-only (no source edits). |
|
||||
| Isolation | Parallel writers → `isolation: "worktree"`. Do not parallel-edit the main tree. |
|
||||
| Scope | One concern per implementer. No “implement half the product” prompts. |
|
||||
| Product rules | Every implementer/reviewer prompt **must** include or inherit: this file’s hard rules, authorized slice, no secrets, platform-first, owner-safe messaging. |
|
||||
| Resume | Fix cycles: `resume_from` same implementer/reviewer. After ~4 review-fix rounds, escalate disagreement to the user or refresh reviewer — do not thrash forever silently. |
|
||||
| Orchestrator | Main Grok session (or `/execute-plan` orchestrator on Grok) owns sequencing, git policy, and user-facing status. Subagents do not push/merge. |
|
||||
| Multi-PR | Only with an explicit PR Plan DAG (`## PR Plan` / `### PR N:`). Prefer `/execute-plan --dry-run` first. This repo: **`--no-graphite`**, **no auto-PR**, **no push/merge** unless user overrides in writing. Implementers/reviewers still **`primary-subagent`**. |
|
||||
| Workflows | Allowed for repeatable gates; must not bypass authorize/review/check/git hygiene. Child agents = local worker. Effectful git still needs user-gated commit/push rules above. |
|
||||
| best-of-n | Only when user asks or two architectures must be compared with a clear eval; candidates on **`primary-subagent`**. |
|
||||
|
||||
### Stop conditions (do not “push through”)
|
||||
|
||||
Stop and report to the user when:
|
||||
|
||||
- Authorized slice is done (review + check clean, or issues listed).
|
||||
- Build was never authorized.
|
||||
- Plan is blocked on a product decision only the user can make.
|
||||
- Review/check finds **bugs** you cannot fix without expanding scope.
|
||||
- Secrets, destructive git, or forge mismatch (GitHub/`gh` assumptions on this Gitea product) would be required.
|
||||
- Subagent wants to rewrite `design/` SSOT or add a parallel control API.
|
||||
|
||||
### Anti-rails (forbidden defaults)
|
||||
|
||||
- Implementing product code because “we’re in execution mode” without **build** / **implement**.
|
||||
- **Parent Grok implementing product work** instead of spawning `primary-subagent`.
|
||||
- Spawning subagents on paid/cloud models for product work without explicit user override.
|
||||
- Silently falling back to Grok when the local worker is down.
|
||||
- Skipping review **and** check on a multi-file change.
|
||||
- Self-only review (same agent edits and declares “LGTM” with no independent pass).
|
||||
- Push/PR/merge/tag as a surprise side effect of “finishing.”
|
||||
- Expanding into neighboring packages/skills “while here.”
|
||||
- Using GitHub/`gh`/Graphite flows as defaults on this Gitea product.
|
||||
- Weakening these rules in a subdirectory `AGENTS.md` or subagent prompt.
|
||||
|
||||
### Tiny-change exception
|
||||
|
||||
Single-file or pure typo/docs nit: plan optional; still **no push**; still no secrets; still no build without authorize. Prefer a quick diff self-check; full `/review` + `/check-work` strongly recommended once tests/scripts exist.
|
||||
|
||||
### Related
|
||||
|
||||
- Git details: [Git hygiene](#git-hygiene-mandatory)
|
||||
- Human/agent PR conventions: [`CONTRIBUTING.md`](CONTRIBUTING.md)
|
||||
- Short Grok rule mirror: [`.grok/rules/execution-loop.md`](.grok/rules/execution-loop.md)
|
||||
|
||||
---
|
||||
|
||||
## Git hygiene (mandatory)
|
||||
|
||||
Every agent that opens or works in this workspace **must** follow these practices. Deeper dirs inherit this file; do not weaken these rules in subdirectory `AGENTS.md` files.
|
||||
|
||||
### Identity of the remote
|
||||
|
||||
| Item | Value |
|
||||
|------|--------|
|
||||
| Hosted remote | Gitea `Ty_Tech/Salon_Assistant` |
|
||||
| Default branch | `main` |
|
||||
| Agent Git / PR MCP | **`gitea_vps` only** |
|
||||
| Forbidden | `gitea_mcp_for_ty`, localhost Git MCP, inventing a second product remote |
|
||||
|
||||
Prefer local `git` for status/diff/commit/branch on the workspace clone; use **`gitea_vps`** for remote PR/branch/file ops against the product repo when MCP is appropriate. Never mix this product’s Git ops onto another MCP server.
|
||||
|
||||
### Never (unless the user explicitly orders that exact action)
|
||||
|
||||
| Action | Why |
|
||||
|--------|-----|
|
||||
| `git config` (any scope) | Do not change user or repo identity/settings |
|
||||
| Commit or push **secrets** | `.env`, tokens, keys, OAuth dumps, provider credentials, PII exports |
|
||||
| Force-push to `main` (or rewrite its history) | Shared default branch is protected by practice |
|
||||
| `--force` / `--force-with-lease` push without explicit ask | Easy to destroy remote work |
|
||||
| `--no-verify` / skip hooks | Hooks exist to catch mistakes |
|
||||
| Amend a commit already pushed | Rewrites shared history |
|
||||
| `reset --hard`, `clean -fd`, branch delete on remote | Destructive; confirm first |
|
||||
| Push, open/merge PR, or tag release without user request | Shared/remote side effects need intent |
|
||||
| Commit unrelated dirty files “while you’re at it” | Keeps history reviewable |
|
||||
| Commit build artifacts, caches, venvs, logs, local IDE noise | Belongs in `.gitignore` |
|
||||
| Paste secrets into commit messages, PR bodies, or issues | Transcripts and remotes leak |
|
||||
|
||||
### Always
|
||||
|
||||
1. **Orient before mutating Git:** `git status`, `git diff` / `git diff --staged`, and recent `git log` (e.g. `git log -5 --oneline`) so commits match repo style.
|
||||
2. **Work on a branch** for anything beyond a trivial single-commit fix the user asked to land on `main`. Prefer names:
|
||||
- `docs/…`, `design/…`, `chore/…`, `fix/…`, `feat/…`
|
||||
- Base new branches on up-to-date `main` when practical.
|
||||
3. **Stage only intended paths** (`git add <paths>`). Never `git add -A` / `git add .` without reviewing the full set.
|
||||
4. **Scan the staged diff for secrets** before every commit (`.env*`, keys, tokens, cookies, private URLs with credentials).
|
||||
5. **Commit only when the user asks** to commit/save/checkpoint (or an explicit workflow that includes commit).
|
||||
6. **Commit messages:** concise focus line in imperative mood; 1–3 sentences of *why* when non-obvious. Match existing history tone. No secret data. No noisy AI trailer spam unless the user asks for a trailer.
|
||||
7. **Push only when the user asks.** Prefer upstream tracking (`-u`) on first push of a branch.
|
||||
8. **PRs via `gitea_vps`:** base `main`, clear title/body (summary + test plan / how verified), draft/WIP when incomplete. Do not merge unless asked.
|
||||
9. **Confirm before destructive or shared-visible actions:** force-push, hard reset, remote branch delete, PR merge/close, rewriting published commits, mass file deletes.
|
||||
10. **Leave the tree honest:** do not hide failures by amending away broken intermediate state the user still needs; do not discard uncommitted user work.
|
||||
11. **Respect “no build”:** do not commit product implementation until **build** / **implement** is authorized; docs/design/scaffold hygiene changes are fine when requested.
|
||||
12. **Subagents inherit these rules.** When spawning agents, point them at this file or restate: no secrets, no force to `main`, no push/PR/merge without user ask, `gitea_vps` only for product remote Git MCP.
|
||||
|
||||
### Commit checklist (before `git commit`)
|
||||
|
||||
```text
|
||||
[ ] User asked to commit (or workflow explicitly includes commit)
|
||||
[ ] Branch is correct (not accidental detached HEAD / wrong fork)
|
||||
[ ] git status + staged diff reviewed; only intended files staged
|
||||
[ ] No secrets, credentials, owner PII, or local absolute machine dumps
|
||||
[ ] No caches/venv/logs/state that should be gitignored
|
||||
[ ] Message explains purpose; matches repo style
|
||||
[ ] Hooks will run (do not pass --no-verify)
|
||||
```
|
||||
|
||||
### Push / PR checklist
|
||||
|
||||
```text
|
||||
[ ] User asked to push and/or open PR
|
||||
[ ] Branch name is descriptive
|
||||
[ ] Remote is origin → Ty_Tech/Salon_Assistant (Gitea)
|
||||
[ ] PR uses gitea_vps; base = main
|
||||
[ ] Description states what/why and how checked
|
||||
[ ] No secrets in PR text or screenshots paths
|
||||
```
|
||||
|
||||
### Recovering from mistakes
|
||||
|
||||
- Unstaged junk → restore files carefully; never `clean -fd` without listing what will die and getting confirmation.
|
||||
- Bad unpushed commit → amend only if HEAD is yours, not pushed, and user wants amend; otherwise new commit or interactive fix with consent.
|
||||
- Secret committed → **stop**; do not keep pushing. Tell the user; rotate credentials; remove from history only with explicit coordinated procedure (not casual `push --force` to `main`).
|
||||
|
||||
### Related docs
|
||||
|
||||
- Branch/PR conventions: [`CONTRIBUTING.md`](CONTRIBUTING.md)
|
||||
- Security / no secrets in git: [`SECURITY.md`](SECURITY.md)
|
||||
- Ignore patterns: [`.gitignore`](.gitignore)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Claude / coding-agent instructions — Salon_Assistant
|
||||
|
||||
This directory is the **Salon_Assistant / Lumina** product seed (Gitea `Ty_Tech/Salon_Assistant`).
|
||||
|
||||
1. Read and follow **[AGENTS.md](AGENTS.md)** in full — product hard rules, **execution loop**, and **Git hygiene**.
|
||||
2. Design SSOT: `design/DESIGN_PLAN.md`, `design/use-cases.md`, `design/scenarios.md`, `design/DECISIONS.md`.
|
||||
3. Operator docs live under `docs/`; do not treat them as a second design SSOT.
|
||||
4. **No product implementation** until the user says **build** / **implement**.
|
||||
5. **Default rails without being asked:** plan (if needed) → build authorized slice → independent review → check → commit only if asked → **no push** unless asked.
|
||||
6. **Grok parent orchestrates only.** All product work goes to local **Primary Subagent** (`spawn_subagent` with `model: "primary-subagent"` → `http://192.168.1.203:8083/v1`). Do not implement product code in the parent.
|
||||
7. Git remote ops for this product: **`gitea_vps` only** — never `gitea_mcp_for_ty` or localhost Git MCP.
|
||||
8. Platform-first: `nemohermes` / `openshell` only; no parallel control API.
|
||||
9. Owner-facing answers must never include terminal, nano, or Docker instructions.
|
||||
10. Subagents inherit these rules; reviewers/verifiers stay read-only; parallel writers use worktrees.
|
||||
If any instruction conflicts, **this tree’s `AGENTS.md` wins** over parent workspace notes for product work inside `Salon_Assistant/`.
|
||||
+52
-4
@@ -5,6 +5,7 @@
|
||||
1. Read [design/DESIGN_PLAN.md](design/DESIGN_PLAN.md).
|
||||
2. Read [design/use-cases.md](design/use-cases.md), [design/scenarios.md](design/scenarios.md), [design/DECISIONS.md](design/DECISIONS.md).
|
||||
3. Implementation requires an explicit **build** decision for greenfield features; structural and documentation work follows this CONTRIBUTING guide.
|
||||
4. Coding agents must also follow **[AGENTS.md](AGENTS.md)** — especially **Execution loop (mandatory)** and **Git hygiene (mandatory)**. Agents must run those rails without the user naming every step.
|
||||
|
||||
## Platform-first
|
||||
|
||||
@@ -13,15 +14,51 @@
|
||||
|
||||
## Git ops for this product
|
||||
|
||||
- Remote: Gitea **Ty_Tech/Salon_Assistant**.
|
||||
- Agent Git MCP for this product: **gitea_vps** only (`https://giteamcp1.vps1.afterthedemo.com/mcp`, User-Agent `grok-connectors-manager/`).
|
||||
| Item | Value |
|
||||
|------|--------|
|
||||
| Remote | Gitea **Ty_Tech/Salon_Assistant** |
|
||||
| Default branch | `main` |
|
||||
| Agent Git MCP | **`gitea_vps` only** (`https://giteamcp1.vps1.afterthedemo.com/mcp`, User-Agent `grok-connectors-manager/`) |
|
||||
| Forbidden MCP | `gitea_mcp_for_ty`, localhost Git MCP for this product |
|
||||
|
||||
Local `git` on a clone is fine for day-to-day status/diff/commit. Use **gitea_vps** for remote PR/branch operations against the product repo when working as an agent with MCP.
|
||||
|
||||
## Branching
|
||||
|
||||
- Branch from current `main`.
|
||||
- Prefer prefixes: `docs/`, `design/`, `chore/`, `fix/`, `feat/`.
|
||||
- Keep branches focused; one concern per branch when practical.
|
||||
- Do not force-push `main`. Do not rewrite published history without explicit coordination.
|
||||
|
||||
## Commits
|
||||
|
||||
- **No secrets** in trees or messages (see [SECURITY.md](SECURITY.md) and `.gitignore`).
|
||||
- Stage only files that belong to the change; review `git status` and the staged diff.
|
||||
- Prefer small, reviewable commits with imperative subject lines and a short *why* when needed.
|
||||
- Do not skip hooks (`--no-verify`) unless an operator explicitly requires it for a broken-hook emergency.
|
||||
- Agents: commit/push only when the user asks (see AGENTS.md checklists).
|
||||
|
||||
## Pull requests
|
||||
|
||||
- Branch from `main`.
|
||||
- Base: `main`.
|
||||
- Title reflects the change; body includes summary and how it was verified.
|
||||
- Keep use-case SSOT in `design/use-cases.md` (do not duplicate full matrices in README).
|
||||
- Run `make verify` when implementation exists (fixture CI).
|
||||
- No secrets in commits.
|
||||
- Agents open/update PRs via **gitea_vps**; merge only when the user asks.
|
||||
- No secrets, tokens, or production PII in PR text.
|
||||
|
||||
### Suggested PR body shape
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
- …
|
||||
|
||||
## Verification
|
||||
- [ ] … (e.g. docs-only review / make verify / doctor)
|
||||
|
||||
## Notes
|
||||
- Related design decisions / use cases if any
|
||||
```
|
||||
|
||||
## Code style
|
||||
|
||||
@@ -33,3 +70,14 @@
|
||||
- Tag releases (`vX.Y.Z`).
|
||||
- Pin images and MCP package versions in the release manifest.
|
||||
- `scripts/upgrade.sh` is the operator upgrade entrypoint (see docs/UPGRADE.md).
|
||||
- Changelog: [CHANGELOG.md](CHANGELOG.md) (Keep a Changelog).
|
||||
|
||||
## Hygiene anti-patterns
|
||||
|
||||
| Avoid | Prefer |
|
||||
|-------|--------|
|
||||
| Commit `.env`, keys, OAuth dumps | OpenShell providers / host env; examples only as `.env.example` |
|
||||
| `git add .` without review | Explicit paths after `git status` / diff |
|
||||
| Drive-by refactors in unrelated PRs | Separate branch/PR |
|
||||
| Force-push to shared `main` | Revert commit or forward fix |
|
||||
| Agent “cleanup” that deletes unknown user files | Investigate; ask before destructive clean |
|
||||
|
||||
@@ -1,22 +1,121 @@
|
||||
# Salon_Assistant / Lumina — operator entrypoints (STUB until build)
|
||||
# Salon_Assistant / Lumina — operator entrypoints
|
||||
# Approved structure: document targets only; do not invoke unimplemented scripts.
|
||||
|
||||
.PHONY: help bootstrap install upgrade doctor verify sync-design
|
||||
.PHONY: help bootstrap install install-s0-s2 install-s3-s5 upgrade doctor verify sync-design \
|
||||
install-s0b install-s1 install-s2 install-s3 install-s4 install-s5 \
|
||||
connect connect-status connect-name connect-channels connect-square \
|
||||
connect-quickbooks connect-vagaro connect-all
|
||||
|
||||
help:
|
||||
@echo "Salon_Assistant (Lumina) — structure phase (0.1.0-design)"
|
||||
@echo " Implementation not started. Explicit build/implement required."
|
||||
@echo "Salon_Assistant (Lumina) — $(shell cat VERSION 2>/dev/null || echo 'unknown')"
|
||||
@echo ""
|
||||
@echo "Intended targets (after build):"
|
||||
@echo " make bootstrap - host prereqs (Docker if missing)"
|
||||
@echo " make install - full install S0b–S6"
|
||||
@echo " make upgrade - product upgrade"
|
||||
@echo " make doctor - health checks"
|
||||
@echo " make verify - lint + tests + smoke (fixtures)"
|
||||
@echo " make sync-design - list design pack paths"
|
||||
@echo "Implemented targets:"
|
||||
@echo " make bootstrap - host prereqs (Docker if missing) [S0b]"
|
||||
@echo " make install - full install S0b–S5"
|
||||
@echo " make install-s0-s2 - install stages S0b through S2"
|
||||
@echo " make install-s3-s5 - install stages S3 through S5 (stack, sandbox, policy, skills)"
|
||||
@echo " make doctor - health checks (S6)"
|
||||
@echo " make verify - unit tests (fixtures, no model)"
|
||||
@echo ""
|
||||
@echo "Staged install:"
|
||||
@echo " make install-s0b - S0b only: Docker bootstrap"
|
||||
@echo " make install-s1 - S1 only: repository environment (.env)"
|
||||
@echo " make install-s2 - S2 only: model + vision config + smoke"
|
||||
@echo " make install-s3 - S3 only: stack alignment (documentation)"
|
||||
@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"
|
||||
|
||||
bootstrap install upgrade doctor verify:
|
||||
@echo "not implemented — wait for explicit build/implement order" >&2; exit 1
|
||||
# ── Implemented targets ────────────────────────────────────────────────────
|
||||
|
||||
bootstrap:
|
||||
@bash scripts/bootstrap.sh
|
||||
|
||||
install:
|
||||
@bash scripts/install.sh
|
||||
|
||||
install-s0-s2:
|
||||
@bash scripts/install.sh --stage s0b
|
||||
@bash scripts/install.sh --stage s1
|
||||
@bash scripts/install.sh --stage s2
|
||||
|
||||
install-s3-s5:
|
||||
@bash scripts/install.sh --stage s3-s5
|
||||
|
||||
install-s0b:
|
||||
@bash scripts/install.sh --stage s0b
|
||||
|
||||
install-s1:
|
||||
@bash scripts/install.sh --stage s1
|
||||
|
||||
install-s2:
|
||||
@bash scripts/install.sh --stage s2
|
||||
|
||||
install-s3:
|
||||
@bash scripts/install.sh --stage s3
|
||||
|
||||
install-s4:
|
||||
@bash scripts/install.sh --stage s4
|
||||
|
||||
install-s5:
|
||||
@bash scripts/install.sh --stage s5
|
||||
|
||||
# ── S6: Doctor ─────────────────────────────────────────────────────────────
|
||||
|
||||
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:
|
||||
@echo "not implemented — upgrade pending" >&2; exit 1
|
||||
|
||||
# ── Verify ─────────────────────────────────────────────────────────────────
|
||||
|
||||
verify:
|
||||
@echo "Running unit tests..."
|
||||
@python3 -m pytest tests/unit/ -v
|
||||
@echo ""
|
||||
@echo "verify: OK"
|
||||
|
||||
sync-design:
|
||||
@echo "Design SSOT:"
|
||||
|
||||
@@ -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,24 +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
|
||||
## 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.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
- Agent never executes payments, refunds, or bill-pay.
|
||||
- Client outbound messages and social posts are draft-only unless a future gated send is explicitly designed.
|
||||
- Secrets never commit to git (use OpenShell providers / host env).
|
||||
- Agents: follow **Git hygiene** in [AGENTS.md](AGENTS.md) (secret scan before every commit; never put credentials in messages/PRs).
|
||||
- Owner never receives host shell, Docker, or editor instructions from the assistant.
|
||||
|
||||
## Reporting
|
||||
|
||||
+49
-7
@@ -1,13 +1,55 @@
|
||||
# Hermes agent package (scaffold)
|
||||
# Hermes agent package
|
||||
|
||||
**Status:** Structure only until **build**.
|
||||
**Status:** Config fragments and manifest ready for S4–S5.
|
||||
|
||||
## Intended contents
|
||||
## Structure
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `config/` | Onboard fragments (model, MCP, channels placeholders) |
|
||||
| `skills-manifest/` | Which Lumina skills ship with the profile |
|
||||
| Identity templates | SOUL / USER / assistant naming (Claire demo persona when implemented) |
|
||||
| `config/inference.yaml` | Inference config fragment (main + aux vision models) |
|
||||
| `config/mcp-servers.yaml` | MCP server config fragment (Square, QBO — enabled at S7) |
|
||||
| `config/channels.yaml` | Messaging channel config fragment (WhatsApp, Telegram, Email — enabled at S7) |
|
||||
| `skills-manifest/manifest.yaml` | Skills manifest listing all Lumina skills for sync |
|
||||
| `identity/assistant.yaml` | Identity template (name, role, capabilities, constraints) |
|
||||
|
||||
Onboard via `nemohermes onboard` using this package — never hand-edit in-sandbox config as SSOT.
|
||||
## Onboard modes
|
||||
|
||||
### Attach (default for UAT)
|
||||
|
||||
When the sandbox already exists (e.g., `hermes` on this host), S4 skips
|
||||
onboard and verifies the sandbox is healthy. Config fragments are used as
|
||||
reference only — the live config is managed by `nemohermes` sealed commands.
|
||||
|
||||
```bash
|
||||
# Attach mode: verify sandbox exists and is healthy
|
||||
nemohermes <name> status
|
||||
```
|
||||
|
||||
### Onboard (clean host)
|
||||
|
||||
On a fresh host, use `nemohermes onboard` with this agent package:
|
||||
|
||||
```bash
|
||||
# Onboard with agent package (dry-run first)
|
||||
nemohermes onboard --from-dir agents/hermes --dry-run
|
||||
|
||||
# Onboard for real
|
||||
nemohermes onboard --from-dir agents/hermes
|
||||
```
|
||||
|
||||
The onboard process:
|
||||
1. Creates the sandbox container
|
||||
2. Applies inference config from `.env`
|
||||
3. Registers the identity template
|
||||
4. Skills are synced separately in S5
|
||||
|
||||
## Platform-first
|
||||
|
||||
All config mutations use `nemohermes` sealed commands. Never hand-edit
|
||||
in-sandbox config as SSOT. The fragments in this directory are the
|
||||
product's source of truth for what gets configured.
|
||||
|
||||
## Design reference
|
||||
|
||||
- [design/DESIGN_PLAN.md §3](../../design/DESIGN_PLAN.md) — Hermes as NemoClaw-managed infrastructure
|
||||
- [design/DESIGN_PLAN.md §2](../../design/DESIGN_PLAN.md) — Host automation (no custom control API)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# agents/hermes/config/channels.yaml
|
||||
# Messaging channel config fragment for nemohermes onboard.
|
||||
#
|
||||
# Channels are registered via nemohermes <name> channels add, not by
|
||||
# hand-editing in-sandbox config. This file documents the planned channels.
|
||||
#
|
||||
# Platform-first: channel mutations use nemohermes <name> channels add/stop/start.
|
||||
|
||||
# Planned channels (enabled at S7 when connected):
|
||||
# - whatsapp
|
||||
# - telegram
|
||||
# - email
|
||||
|
||||
# Rules:
|
||||
# - Owner ↔ agent: full messaging
|
||||
# - Client outbound: draft-first only (no silent send/publish)
|
||||
@@ -0,0 +1,22 @@
|
||||
# agents/hermes/config/inference.yaml
|
||||
# Inference config fragment for nemohermes onboard.
|
||||
#
|
||||
# This fragment is merged into the sandbox's managed config during onboard.
|
||||
# Values are resolved from .env at install time.
|
||||
#
|
||||
# Platform-first: the actual config write is done by nemohermes inference set,
|
||||
# not by hand-editing in-sandbox files.
|
||||
|
||||
# Main model endpoint (OpenAI-compatible)
|
||||
# Resolved from LUMINA_INFERENCE_BASE_URL and LUMINA_INFERENCE_MODEL
|
||||
inference:
|
||||
provider: compatible-endpoint
|
||||
# model: set by nemohermes inference set from .env
|
||||
# base_url: set by nemohermes inference set from .env
|
||||
|
||||
# Auxiliary vision model
|
||||
# Resolved from LUMINA_VISION_MODEL
|
||||
auxiliary:
|
||||
vision:
|
||||
# model: set from LUMINA_VISION_MODEL (often same as main if multimodal)
|
||||
pass
|
||||
@@ -0,0 +1,40 @@
|
||||
# agents/hermes/config/mcp-servers.yaml
|
||||
# MCP server config fragment for nemohermes onboard.
|
||||
#
|
||||
# Lists MCP servers that may be registered at S7 (SaaS connections).
|
||||
# At S3–S5, this is a placeholder — no live MCP servers are configured.
|
||||
#
|
||||
# Platform-first: MCP registration is done via nemohermes <name> mcp add,
|
||||
# not by hand-editing in-sandbox config.
|
||||
|
||||
mcp_servers:
|
||||
# Square — remote MCP (enabled at S7 when connected)
|
||||
# - name: square
|
||||
# url: ${SQUARE_MCP_URL}
|
||||
# tools:
|
||||
# include:
|
||||
# - bookings.*
|
||||
# - customers.*
|
||||
# - catalog.*
|
||||
# - inventory.*
|
||||
# exclude:
|
||||
# - payments.*
|
||||
# - refunds.*
|
||||
# - cards.*
|
||||
# - checkout.*
|
||||
# - payouts.*
|
||||
|
||||
# QuickBooks Online — local MCP (enabled at S7 when connected)
|
||||
# - name: qbo
|
||||
# url: http://lumina-qbo-mcp:3000 # compose service
|
||||
# tools:
|
||||
# include:
|
||||
# - reports.*
|
||||
# - search.*
|
||||
# - get.*
|
||||
# exclude:
|
||||
# - create_payment.*
|
||||
# - bill_payment.*
|
||||
# - write.*
|
||||
# - update.*
|
||||
# - delete.*
|
||||
@@ -0,0 +1,30 @@
|
||||
# agents/hermes/identity/assistant.yaml
|
||||
# Lumina identity template — minimal, product-safe.
|
||||
#
|
||||
# This template defines the assistant's identity for the Hermes sandbox.
|
||||
# The owner's chosen name replaces the default at onboard time.
|
||||
#
|
||||
# Rules:
|
||||
# - Default name: "Lumina" (generic; owner renames at setup)
|
||||
# - No hardcoded persona details (demo "Claire" is a fixture, not identity)
|
||||
# - Owner name/profile persists across upgrades (volume-backed)
|
||||
|
||||
identity:
|
||||
# Default display name (overridden by owner at setup)
|
||||
name: Lumina
|
||||
# Role description
|
||||
role: "Salon and spa owner assistant"
|
||||
# Capabilities summary (used in intro messages)
|
||||
capabilities:
|
||||
- daily operations board
|
||||
- appointment availability
|
||||
- client management
|
||||
- books and finance overview
|
||||
- social media drafts
|
||||
- vendor communications
|
||||
# Safety constraints
|
||||
constraints:
|
||||
- draft-first outbound messaging
|
||||
- no agent payments
|
||||
- no silent send or publish
|
||||
- deterministic facts from tools; inference for wording only
|
||||
@@ -0,0 +1,82 @@
|
||||
# agents/hermes/skills-manifest/manifest.yaml
|
||||
# Lumina skills manifest — which skills ship with the Hermes profile.
|
||||
#
|
||||
# This manifest is used by the install script (S5) to determine which
|
||||
# skill directories to sync into the sandbox via nemohermes skill install.
|
||||
#
|
||||
# Skills are organized by domain. Each entry maps to a directory under
|
||||
# skills/ in the repository root.
|
||||
|
||||
skills:
|
||||
# ── Daily operations ──────────────────────────────────────────────────
|
||||
- name: daily-board
|
||||
domain: operations
|
||||
description: "Today's salon board: appointments, tasks, priorities"
|
||||
|
||||
- name: availability
|
||||
domain: operations
|
||||
description: "Check and display appointment availability"
|
||||
|
||||
- name: service-menu
|
||||
domain: operations
|
||||
description: "Service catalog and pricing"
|
||||
|
||||
- name: retail-stock
|
||||
domain: operations
|
||||
description: "Retail product inventory levels"
|
||||
|
||||
# ── Client management ─────────────────────────────────────────────────
|
||||
- name: client-card
|
||||
domain: clients
|
||||
description: "Client profile and history"
|
||||
|
||||
- name: draft-client-message
|
||||
domain: clients
|
||||
description: "Draft outbound messages to clients (draft-first)"
|
||||
|
||||
# ── Books / finance ───────────────────────────────────────────────────
|
||||
- name: books-snapshot
|
||||
domain: books
|
||||
description: "QuickBooks snapshot: P&L, balance, cash"
|
||||
|
||||
- name: ar-open-invoices
|
||||
domain: books
|
||||
description: "Accounts receivable: open invoices"
|
||||
|
||||
- name: ap-bills-due
|
||||
domain: books
|
||||
description: "Accounts payable: bills due"
|
||||
|
||||
- name: vendor-spend
|
||||
domain: books
|
||||
description: "Vendor spending summary"
|
||||
|
||||
- name: vendor-inbox
|
||||
domain: books
|
||||
description: "Vendor communications and documents"
|
||||
|
||||
- name: draft-invoice
|
||||
domain: books
|
||||
description: "Draft invoices for clients (draft-first)"
|
||||
|
||||
# ── Social / marketing ────────────────────────────────────────────────
|
||||
- name: social-draft
|
||||
domain: social
|
||||
description: "Draft social media posts (draft-first; vision aux)"
|
||||
|
||||
- name: weekly-digest
|
||||
domain: social
|
||||
description: "Weekly business digest for the owner"
|
||||
|
||||
# ── System ────────────────────────────────────────────────────────────
|
||||
- name: remember-forget
|
||||
domain: system
|
||||
description: "Confirm-to-remember persistence; forget entries"
|
||||
|
||||
- name: setup-education
|
||||
domain: system
|
||||
description: "Owner-safe connect education and capability report"
|
||||
|
||||
- name: publish-boundary-test
|
||||
domain: system
|
||||
description: "Boundary test: verify publish/send denials"
|
||||
+37
-4
@@ -1,11 +1,44 @@
|
||||
# Fixtures (scaffold)
|
||||
# Fixtures
|
||||
|
||||
Demo persona when implemented: **Claire Bennett**, **Lumina Hair Studio & Spa**.
|
||||
Demo persona: **Claire Bennett**, **Lumina Hair Studio & Spa**.
|
||||
|
||||
All fixture data is clearly labeled in skill output — never silent fake live data.
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `books/` | Sample QBO-shaped JSON |
|
||||
| `scheduling/` | Sample appointment data for daily-board |
|
||||
| `books/` | Sample QBO-shaped JSON (future) |
|
||||
| `media/` | Sample social media assets for vision tests |
|
||||
| `../recorded/` | Optional recorded responses (local only; do not commit secrets) |
|
||||
|
||||
No fixture JSON committed until **build** unless explicitly requested.
|
||||
## Scheduling fixtures
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `scheduling/claire_bennett_2026-07-28.json` | Sample salon day: 7 appointments (2 staff, 1 cancelled) |
|
||||
|
||||
### Fixture schema
|
||||
|
||||
```json
|
||||
{
|
||||
"salon_name": "Lumina Hair Studio & Spa",
|
||||
"date": "2026-07-28",
|
||||
"business_hours": {"open": "09:00", "close": "18:00"},
|
||||
"staff": [{"name": "Claire Bennett", "role": "owner-stylist"}],
|
||||
"appointments": [
|
||||
{
|
||||
"id": "APT-001",
|
||||
"start": "2026-07-28T09:00:00",
|
||||
"end": "2026-07-28T10:30:00",
|
||||
"client_name": "Elena Rossi",
|
||||
"service_name": "Balayage + Cut",
|
||||
"staff_name": "Claire Bennett",
|
||||
"status": "confirmed",
|
||||
"needs_confirmation": false,
|
||||
"notes": "Formula: 9.1 + 0-45 gloss"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Status values:** `confirmed`, `pending`, `cancelled`, `completed`, `no_show`
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"salon_name": "Lumina Hair Studio & Spa",
|
||||
"date": "2026-07-28",
|
||||
"business_hours": {
|
||||
"open": "09:00",
|
||||
"close": "18:00"
|
||||
},
|
||||
"staff": [
|
||||
{
|
||||
"name": "Claire Bennett",
|
||||
"role": "owner-stylist"
|
||||
},
|
||||
{
|
||||
"name": "Maya Torres",
|
||||
"role": "colorist"
|
||||
}
|
||||
],
|
||||
"appointments": [
|
||||
{
|
||||
"id": "APT-001",
|
||||
"start": "2026-07-28T09:00:00",
|
||||
"end": "2026-07-28T10:30:00",
|
||||
"client_name": "Elena Rossi",
|
||||
"service_name": "Balayage + Cut",
|
||||
"staff_name": "Claire Bennett",
|
||||
"status": "confirmed",
|
||||
"needs_confirmation": false,
|
||||
"notes": "Formula: 9.1 + 0-45 gloss. Allergic to ammonia."
|
||||
},
|
||||
{
|
||||
"id": "APT-002",
|
||||
"start": "2026-07-28T10:30:00",
|
||||
"end": "2026-07-28T11:30:00",
|
||||
"client_name": "Sarah Kim",
|
||||
"service_name": "Blowout + Updo",
|
||||
"staff_name": "Claire Bennett",
|
||||
"status": "pending",
|
||||
"needs_confirmation": true,
|
||||
"notes": "Wedding guest — updo reference photo sent."
|
||||
},
|
||||
{
|
||||
"id": "APT-003",
|
||||
"start": "2026-07-28T11:00:00",
|
||||
"end": "2026-07-28T12:30:00",
|
||||
"client_name": "Jasmine Patel",
|
||||
"service_name": "Root Touch-Up",
|
||||
"staff_name": "Maya Torres",
|
||||
"status": "confirmed",
|
||||
"needs_confirmation": false,
|
||||
"notes": "2B dark brown. Regular client."
|
||||
},
|
||||
{
|
||||
"id": "APT-004",
|
||||
"start": "2026-07-28T13:00:00",
|
||||
"end": "2026-07-28T14:00:00",
|
||||
"client_name": "Chris Nguyen",
|
||||
"service_name": "Men's Cut",
|
||||
"staff_name": "Claire Bennett",
|
||||
"status": "pending",
|
||||
"needs_confirmation": true,
|
||||
"notes": "Running late from work — may be 15 min behind."
|
||||
},
|
||||
{
|
||||
"id": "APT-005",
|
||||
"start": "2026-07-28T14:00:00",
|
||||
"end": "2026-07-28T15:30:00",
|
||||
"client_name": "Priya Sharma",
|
||||
"service_name": "Deep Conditioning Treatment",
|
||||
"staff_name": "Maya Torres",
|
||||
"status": "confirmed",
|
||||
"needs_confirmation": false,
|
||||
"notes": "Post-color repair. Keratin-safe product only."
|
||||
},
|
||||
{
|
||||
"id": "APT-006",
|
||||
"start": "2026-07-28T15:30:00",
|
||||
"end": "2026-07-28T17:00:00",
|
||||
"client_name": "Aisha Williams",
|
||||
"service_name": "Full Color + Style",
|
||||
"staff_name": "Claire Bennett",
|
||||
"status": "confirmed",
|
||||
"needs_confirmation": false,
|
||||
"notes": "First visit — consultation included."
|
||||
},
|
||||
{
|
||||
"id": "APT-007",
|
||||
"start": "2026-07-28T12:30:00",
|
||||
"end": "2026-07-28T13:00:00",
|
||||
"client_name": "Tom Bradley",
|
||||
"service_name": "Quick Trim",
|
||||
"staff_name": "Maya Torres",
|
||||
"status": "cancelled",
|
||||
"needs_confirmation": false,
|
||||
"notes": "Client cancelled — rescheduled to next week."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,55 @@
|
||||
# Compose (scaffold)
|
||||
# Compose — product-managed services
|
||||
|
||||
**Status:** No `docker-compose.yml` until **build**.
|
||||
**Status:** Optional stub. Not required for S3–S5 UAT attach path.
|
||||
|
||||
Will host gateway/sandbox alignment, local QBO MCP, webhooks, volumes — per [design/DESIGN_PLAN.md](../../design/DESIGN_PLAN.md).
|
||||
## Host vs container boundaries
|
||||
|
||||
| Layer | Owner | Managed by |
|
||||
|-------|-------|------------|
|
||||
| **Host** | Operator | `nemohermes` CLI, `openshell` CLI, product scripts |
|
||||
| **Gateway** | OpenShell | `nemohermes` (sealed transactions) |
|
||||
| **Hermes sandbox** | OpenShell | `nemohermes <name> start/stop/rebuild/destroy` |
|
||||
| **Local MCP / webhooks** | Product | `docker compose` (this file) — optional |
|
||||
|
||||
**Key principle:** OpenShell owns the Hermes sandbox container. The product does NOT define the sandbox in `docker-compose.yml`. The sandbox is created and managed exclusively through `nemohermes` commands.
|
||||
|
||||
## What this compose file provides
|
||||
|
||||
An optional Docker Compose file for **product-managed services** that run alongside the Hermes sandbox:
|
||||
|
||||
- **QBO MCP** — local MCP server for QuickBooks Online (stdio or network-attached)
|
||||
- **Vagaro webhooks** — REST endpoint for Vagaro webhook ingestion
|
||||
|
||||
These services share the `lumina` Docker network. The Hermes sandbox container is NOT part of this compose file.
|
||||
|
||||
## When to use
|
||||
|
||||
- **UAT attach (S3–S5):** Not needed. The sandbox runs via `nemohermes`; no local MCP or webhooks yet.
|
||||
- **S7+ (SaaS connections):** Uncomment and configure the relevant service when connecting Square, QBO, or Vagaro.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# When services are uncommented and configured:
|
||||
cd deploy/compose
|
||||
docker compose up -d
|
||||
|
||||
# Check status
|
||||
docker compose ps
|
||||
|
||||
# Logs
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
## Volumes
|
||||
|
||||
| Volume | Purpose |
|
||||
|--------|---------|
|
||||
| `qbo-mcp-data` | QBO MCP state (tokens, cache) |
|
||||
| `vagaro-webhooks-data` | Webhook processing state |
|
||||
|
||||
## Design reference
|
||||
|
||||
- [design/DESIGN_PLAN.md §3](../../design/DESIGN_PLAN.md) — Hermes as NemoClaw-managed infrastructure
|
||||
- [design/DESIGN_PLAN.md §6](../../design/DESIGN_PLAN.md) — MCP and SaaS integration
|
||||
- [docs/ARCHITECTURE.md](../../docs/ARCHITECTURE.md) — overall architecture
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Salon_Assistant / Lumina — optional product services
|
||||
#
|
||||
# This compose file is OPTIONAL. It is NOT required for the S3–S5 UAT attach path.
|
||||
# OpenShell manages the Hermes sandbox container directly; nemohermes owns
|
||||
# sandbox lifecycle (start/stop/rebuild/destroy). This compose file exists only
|
||||
# for future local MCP processes and webhook services that run alongside the
|
||||
# sandbox on the same Docker network.
|
||||
#
|
||||
# Usage (when needed):
|
||||
# cd deploy/compose && docker compose up -d
|
||||
#
|
||||
# See deploy/compose/README.md for details.
|
||||
|
||||
# ── Network ────────────────────────────────────────────────────────────────
|
||||
# All product-managed services share this network. The Hermes sandbox container
|
||||
# is NOT defined here — it is managed by nemohermes/OpenShell.
|
||||
networks:
|
||||
lumina:
|
||||
driver: bridge
|
||||
|
||||
# ── Volumes ────────────────────────────────────────────────────────────────
|
||||
volumes:
|
||||
qbo-mcp-data:
|
||||
vagaro-webhooks-data:
|
||||
|
||||
# ── Services (stubs — enabled when SaaS connections are configured) ────────
|
||||
# Uncomment and configure when the corresponding SaaS integration is connected.
|
||||
|
||||
# qbo-mcp:
|
||||
# image: ghcr.io/ty-tech/qbo-mcp:latest # placeholder — real image at S7
|
||||
# container_name: lumina-qbo-mcp
|
||||
# networks:
|
||||
# - lumina
|
||||
# volumes:
|
||||
# - qbo-mcp-data:/data
|
||||
# environment:
|
||||
# - QBO_CLIENT_ID=${QBO_CLIENT_ID}
|
||||
# - QBO_CLIENT_SECRET=${QBO_CLIENT_SECRET}
|
||||
# - QBO_REFRESH_TOKEN=${QBO_REFRESH_TOKEN}
|
||||
# - QBO_REALM_ID=${QBO_REALM_ID}
|
||||
# restart: unless-stopped
|
||||
|
||||
# vagaro-webhooks:
|
||||
# image: ghcr.io/ty-tech/vagaro-webhooks:latest # placeholder — real image at S7
|
||||
# container_name: lumina-vagaro-webhooks
|
||||
# networks:
|
||||
# - lumina
|
||||
# volumes:
|
||||
# - vagaro-webhooks-data:/data
|
||||
# environment:
|
||||
# - VAGARO_API_KEY=${VAGARO_API_KEY}
|
||||
# - VAGARO_WEBHOOK_SECRET=${VAGARO_WEBHOOK_SECRET}
|
||||
# ports:
|
||||
# - "127.0.0.1:8090:8080"
|
||||
# restart: unless-stopped
|
||||
@@ -0,0 +1,28 @@
|
||||
# /implement queue (first 5 tasks)
|
||||
|
||||
Paste **one line** at a time into Grok Build (composer). Do not paste the whole file.
|
||||
|
||||
## Task 1
|
||||
```
|
||||
/implement S0-S2 only per design/DESIGN_PLAN.md sec 4: host/docs, docker-if-missing, env, model+vision via nemohermes/openshell, vision smoke. No skills SaaS S3-S7. AGENTS.md. No push.
|
||||
```
|
||||
|
||||
## Task 2
|
||||
```
|
||||
/implement S3-S5 only per design/DESIGN_PLAN.md sec 4: compose/nemohermes stack, sandbox from package, policy from policy/openshell, skills sync. No doctor S6, no connect S7, no live SaaS. AGENTS.md. No push.
|
||||
```
|
||||
|
||||
## Task 3
|
||||
```
|
||||
/implement S6 doctor only per design/DESIGN_PLAN.md sec 4 and docs/OPERATIONS.md: health checks Docker/nemohermes/policy/skills/inference. No OAuth/channels. AGENTS.md. No push.
|
||||
```
|
||||
|
||||
## Task 4
|
||||
```
|
||||
/implement use case A1 daily-board per design/use-cases.md: skills/_lib + fixtures + skills/daily-board fixtures-only. No live Vagaro/Square. AGENTS.md. No push.
|
||||
```
|
||||
|
||||
## Task 5
|
||||
```
|
||||
/implement use case E1 setup-education per design/use-cases.md and docs/SETUP_UX.md: owner-safe connect education + capability report shape, fixtures/stubs only no real secrets. AGENTS.md. No push.
|
||||
```
|
||||
+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)
|
||||
|
||||
+37
-11
@@ -1,29 +1,55 @@
|
||||
# Deployer host baselining
|
||||
|
||||
**Status:** Outline from approved design POR. Procedural scripts at **build**.
|
||||
**Status:** Procedural — ready for operator use.
|
||||
|
||||
## Purpose
|
||||
|
||||
Create a clean host that can run the Docker-based Lumina stack. This product is **repo-driven**, not tied to one developer’s existing Hermes install.
|
||||
Create a clean host that can run the Docker-based Lumina stack. This product is **repo-driven**, not tied to one developer's existing Hermes install.
|
||||
|
||||
## Requirements (POR)
|
||||
## Requirements
|
||||
|
||||
| Item | Spec |
|
||||
|------|------|
|
||||
| OS | Linux x86_64 or arm64 (cloud VM, bare metal; WSL2 optional path only) |
|
||||
| Resources | Multi-core CPU; ≥8–16 GB RAM recommended; disk for Docker images |
|
||||
| Privileges | User able to install Docker / run Compose |
|
||||
| Privileges | User able to install Docker / run Compose (sudo access) |
|
||||
| Network | Outbound HTTPS to model endpoint, SaaS APIs, MCP remotes, image registry |
|
||||
| GPU | Optional; only if local VLM is used (not required if vision is remote) |
|
||||
|
||||
## Steps (operator)
|
||||
## Prerequisites (before running scripts)
|
||||
|
||||
1. Provision host (cloud console / hypervisor).
|
||||
2. Install base OS updates.
|
||||
3. Create deploy user; clone this repository.
|
||||
4. Run `./scripts/bootstrap.sh` (installs Docker if missing — when implemented).
|
||||
5. Continue with [INSTALL.md](INSTALL.md).
|
||||
1. **Provision host** — cloud console, hypervisor, or bare metal.
|
||||
2. **Install base OS updates** — `sudo apt update && sudo apt upgrade -y` (Debian/Ubuntu) or equivalent.
|
||||
3. **Create deploy user** — a non-root user with sudo access.
|
||||
4. **Install NemoClaw CLIs** — `nemohermes` and `openshell` must be on PATH. See [NemoClaw docs](https://docs.nvidia.com/nemoclaw/latest/user-guide/hermes/reference/architecture.md) for installation.
|
||||
5. **Clone this repository** — `git clone <repo-url> && cd Salon_Assistant`.
|
||||
|
||||
## Bootstrap (S0b)
|
||||
|
||||
Run the bootstrap script to install Docker if missing:
|
||||
|
||||
```bash
|
||||
./scripts/bootstrap.sh
|
||||
```
|
||||
|
||||
Or via Make:
|
||||
|
||||
```bash
|
||||
make bootstrap
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
- Detects if Docker is already installed and running (idempotent).
|
||||
- If missing, installs Docker via the official convenience script (Debian/Ubuntu) or dnf (RHEL/Fedora).
|
||||
- Adds the current user to the `docker` group.
|
||||
- Verifies the Docker daemon is reachable.
|
||||
|
||||
**If Docker is already present:** the script detects it and exits immediately with a success message.
|
||||
|
||||
## Continue with install
|
||||
|
||||
After bootstrap, proceed to [INSTALL.md](INSTALL.md) for stages S1–S2.
|
||||
|
||||
## Design reference
|
||||
|
||||
[design/DESIGN_PLAN.md](../design/DESIGN_PLAN.md) § Docker packaging and install stages.
|
||||
[design/DESIGN_PLAN.md](../design/DESIGN_PLAN.md) §4 Docker packaging and install stages.
|
||||
|
||||
+72
-8
@@ -1,13 +1,77 @@
|
||||
# Hermes model configuration
|
||||
|
||||
**Status:** Outline from approved design.
|
||||
**Status:** S2-ready — sufficient for operators configuring models.
|
||||
|
||||
| Slot | Requirement |
|
||||
|------|-------------|
|
||||
| **Main** | OpenAI-compatible chat + tools; endpoint **may be outside Docker** |
|
||||
| **Auxiliary `vision`** | **Required** for media social; install smoke test |
|
||||
| **Other aux** | Default to same base/main endpoint unless overridden |
|
||||
## Model slots
|
||||
|
||||
Configured via NemoClaw inference + generated Hermes config (`nemohermes inference set` / sealed config), not owner terminal editing.
|
||||
| Slot | Requirement | Configured via |
|
||||
|------|-------------|----------------|
|
||||
| **Main** | OpenAI-compatible chat + tools; endpoint **may be outside Docker** | `openshell inference set` + `.env` |
|
||||
| **Auxiliary `vision`** | **Required** for media social; install smoke test | `.env` + vision smoke |
|
||||
| **Other aux** | Default to same base/main endpoint unless overridden | `.env` |
|
||||
|
||||
See [design/DESIGN_PLAN.md](../design/DESIGN_PLAN.md) and [Hermes Configuring Models](https://hermes-agent.nousresearch.com/docs/user-guide/configuring-models).
|
||||
## Environment variables
|
||||
|
||||
All model configuration lives in `.env`. See `.env.example` for the template.
|
||||
|
||||
| Variable | Purpose | Example |
|
||||
|----------|---------|---------|
|
||||
| `LUMINA_INFERENCE_BASE_URL` | OpenAI-compatible endpoint base URL | `http://192.168.1.203:8083/v1` |
|
||||
| `LUMINA_INFERENCE_MODEL` | Main model identifier (must match what the server advertises) | `/home/ty/models/…/Qwen3.6-27B-UD-Q4_K_XL.gguf` |
|
||||
| `LUMINA_INFERENCE_API_KEY` | API key for the inference endpoint (empty if unauthenticated) | *(leave empty for local endpoints)* |
|
||||
| `LUMINA_VISION_MODEL` | Vision-capable model for social media / media understanding | Same as main if multimodal |
|
||||
| `LUMINA_SANDBOX` | Sandbox name (used by `nemohermes` commands) | `hermes` |
|
||||
|
||||
## Configuring the main model
|
||||
|
||||
The main model is configured through the OpenShell gateway:
|
||||
|
||||
```bash
|
||||
openshell inference set \
|
||||
--provider compatible-endpoint \
|
||||
--model "$LUMINA_INFERENCE_MODEL" \
|
||||
--no-verify
|
||||
```
|
||||
|
||||
This is done automatically by `scripts/install/s2-models.sh` when the gateway is connected.
|
||||
|
||||
**Provider name:** `compatible-endpoint` is the NemoClaw provider for OpenAI-compatible endpoints (llama.cpp, Ollama, vLLM, etc.).
|
||||
|
||||
**Note:** `openshell inference set` takes `--provider` and `--model` but **not** `--url`. The gateway resolves the endpoint URL from its own metadata (the compatible-endpoint provider reads the URL from the gateway config). The install script verifies the endpoint is reachable before calling `openshell inference set`.
|
||||
|
||||
## Vision model
|
||||
|
||||
The vision model must support multimodal inputs (images). The install smoke test verifies this:
|
||||
|
||||
1. Probes the models endpoint (`$LUMINA_INFERENCE_BASE_URL/models`) for a `multimodal` capability tag.
|
||||
2. Falls back to a chat completion probe if tags are not available.
|
||||
|
||||
If the main model is already multimodal, set `LUMINA_VISION_MODEL` to the same value as `LUMINA_INFERENCE_MODEL`.
|
||||
|
||||
## Checking current configuration
|
||||
|
||||
```bash
|
||||
# See what the gateway has configured
|
||||
openshell inference get
|
||||
|
||||
# See what models the endpoint advertises
|
||||
curl -s "$LUMINA_INFERENCE_BASE_URL/models" | jq '.models[].name'
|
||||
|
||||
# Check sandbox status
|
||||
nemohermes <sandbox-name> status
|
||||
```
|
||||
|
||||
## Runtime changes
|
||||
|
||||
Per NemoClaw's runtime change matrix:
|
||||
- **Inference route changes** are typically hot (no rebuild needed).
|
||||
- **Auxiliary model changes** may require a gateway restart.
|
||||
- Always use `nemohermes` / `openshell` for mutations — never hand-edit in-sandbox config.
|
||||
|
||||
See [design/DESIGN_PLAN.md](../design/DESIGN_PLAN.md) §3.4 and [NemoClaw runtime changes docs](https://docs.nvidia.com/nemoclaw/latest/user-guide/hermes/manage-sandboxes/configure-sandboxes/understand-runtime-changes.md).
|
||||
|
||||
## References
|
||||
|
||||
- [Hermes Configuring Models](https://hermes-agent.nousresearch.com/docs/user-guide/configuring-models)
|
||||
- [Hermes Configuration](https://hermes-agent.nousresearch.com/docs/user-guide/configuration)
|
||||
- [design/research/platform-notes.md](../design/research/platform-notes.md)
|
||||
|
||||
+246
-15
@@ -1,31 +1,262 @@
|
||||
# Install
|
||||
|
||||
**Status:** Outline from approved design. Scripts stubbed until **build**.
|
||||
**Status:** Stages S0–S7 implemented.
|
||||
|
||||
## Stages
|
||||
|
||||
| Stage | Where | Outcome |
|
||||
|-------|--------|---------|
|
||||
| S0 | Human | Host per [DEPLOYER_HOST.md](DEPLOYER_HOST.md) |
|
||||
| S0b | Host script | Docker installed if missing |
|
||||
| S1 | Host script | Repo env, `.env` from `.env.example` |
|
||||
| S2 | Host script | Main + aux vision config; vision smoke |
|
||||
| S3–S5 | Host → Compose / `nemohermes` | Stack, sandbox, policy, skills |
|
||||
| S6 | Host script | Doctor green |
|
||||
| S7 | Owner + operator connect helpers | Name assistant; connect **their** SaaS/channels |
|
||||
| Stage | Where | Outcome | Status |
|
||||
|-------|-------|---------|--------|
|
||||
| S0 | Human | Host per [DEPLOYER_HOST.md](DEPLOYER_HOST.md) | ✅ Procedural |
|
||||
| S0b | Host script | Docker installed if missing | ✅ Implemented |
|
||||
| S1 | Host script | Repo env, `.env` from `.env.example` | ✅ Implemented |
|
||||
| S2 | Host script | Main + aux vision config; vision smoke | ✅ Implemented |
|
||||
| S3 | Host script | Stack alignment (compose docs; OpenShell owns sandbox) | ✅ Implemented |
|
||||
| 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 | ✅ Implemented |
|
||||
|
||||
## Platform commands (normative)
|
||||
|
||||
All mutations: `nemohermes` / `openshell` (see [design/research/platform-notes.md](../design/research/platform-notes.md)).
|
||||
|
||||
## Intended entrypoints
|
||||
---
|
||||
|
||||
## S0: Host baselining
|
||||
|
||||
Follow [DEPLOYER_HOST.md](DEPLOYER_HOST.md) to prepare the host:
|
||||
|
||||
1. Provision a Linux host (cloud VM, bare metal).
|
||||
2. Install OS updates.
|
||||
3. Create a deploy user with sudo access.
|
||||
4. Install `nemohermes` and `openshell` CLIs.
|
||||
5. Clone this repository.
|
||||
|
||||
## S0b: Docker bootstrap
|
||||
|
||||
```bash
|
||||
./scripts/bootstrap.sh
|
||||
./scripts/install.sh
|
||||
./scripts/doctor.sh
|
||||
# or
|
||||
make bootstrap
|
||||
```
|
||||
|
||||
## After install
|
||||
**Idempotent:** if Docker is already installed and running, the script exits immediately.
|
||||
|
||||
[SETUP_UX.md](SETUP_UX.md) · [design/scenarios.md](../design/scenarios.md) (S1–S5).
|
||||
**What it checks:**
|
||||
- `docker info` succeeds → skip install.
|
||||
- OS family detection (Debian/Ubuntu or RHEL/Fedora).
|
||||
- Installs Docker, adds user to `docker` group, verifies daemon.
|
||||
|
||||
## S1: Repository environment
|
||||
|
||||
```bash
|
||||
./scripts/install/s1-env.sh
|
||||
# or
|
||||
make install-s1
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
1. If `.env` does not exist, copies `.env.example` to `.env`.
|
||||
2. Validates that all required keys are present and non-empty:
|
||||
- `LUMINA_SANDBOX` — sandbox name (default: `hermes`)
|
||||
- `LUMINA_INFERENCE_BASE_URL` — inference endpoint URL
|
||||
- `LUMINA_INFERENCE_MODEL` — model identifier
|
||||
- `LUMINA_VISION_MODEL` — vision model identifier
|
||||
3. Fails with clear messages if keys are missing or empty.
|
||||
|
||||
**Edit `.env`** with your actual values before running S2. See `.env.example` for all keys and descriptions.
|
||||
|
||||
**Never commit `.env` with real secrets.** It is in `.gitignore`.
|
||||
|
||||
## S2: Model + vision configuration
|
||||
|
||||
```bash
|
||||
./scripts/install/s2-models.sh
|
||||
# or
|
||||
make install-s2
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
1. Loads `.env` and validates required keys.
|
||||
2. Checks that `nemohermes` and `openshell` CLIs are available.
|
||||
3. Verifies the inference endpoint is reachable (`/v1/models`).
|
||||
4. If the OpenShell gateway is connected, configures the inference route via `openshell inference set`.
|
||||
5. Runs the **vision smoke test**:
|
||||
- Probes `/v1/models` for `multimodal` capability tag.
|
||||
- Falls back to a minimal chat completion if capability tags are not exposed.
|
||||
6. **Fails loudly** (non-zero exit) if vision capability cannot be confirmed.
|
||||
|
||||
**If the gateway is not yet connected:** the script validates the endpoint and skips the `openshell` write. S3+ will handle full gateway configuration.
|
||||
|
||||
## S3: Stack alignment
|
||||
|
||||
```bash
|
||||
./scripts/install.sh --stage s3
|
||||
# or
|
||||
make install-s3
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
- Documents that OpenShell manages the Hermes sandbox container.
|
||||
- The product compose file (`deploy/compose/docker-compose.yml`) is optional — used only for local MCP and webhook services at S7+.
|
||||
- No action needed for UAT attach path.
|
||||
|
||||
**Key principle:** OpenShell owns the sandbox. The product does NOT define the sandbox in `docker-compose.yml`.
|
||||
|
||||
## S4: Sandbox verification or onboard
|
||||
|
||||
```bash
|
||||
./scripts/install/s4-sandbox.sh # attach mode (default)
|
||||
./scripts/install/s4-sandbox.sh --mode attach
|
||||
./scripts/install/s4-sandbox.sh --mode onboard
|
||||
./scripts/install/s4-sandbox.sh --mode onboard --dry-run
|
||||
# or
|
||||
make install-s4
|
||||
```
|
||||
|
||||
**Attach mode (default):**
|
||||
- Verifies the sandbox exists and is healthy.
|
||||
- Checks that the agent package (`agents/hermes/`) is present.
|
||||
- No destructive operations.
|
||||
|
||||
**Onboard mode (clean host):**
|
||||
- Creates a new sandbox from the agent package using `nemohermes onboard`.
|
||||
- If a sandbox with the same name already exists, falls back to attach mode for safety.
|
||||
- Use `--dry-run` to preview without executing.
|
||||
|
||||
## S5: Policy overlays + skills sync
|
||||
|
||||
```bash
|
||||
./scripts/install/s5-policy-skills.sh # policy + skills
|
||||
./scripts/install/s5-policy-skills.sh --policy-only
|
||||
./scripts/install/s5-policy-skills.sh --skills-only
|
||||
# or
|
||||
make install-s5
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
1. **Policy:** Applies the inference policy overlay from `policy/openshell/overlays/inference.yaml` via `nemohermes policy-add`. Existing balanced-tier presets (npm, pypi, huggingface, brew) are preserved.
|
||||
2. **Skills:** Iterates skill directories under `skills/` (skipping `_lib`) and installs each via `nemohermes skill install`. Skills without `SKILL.md` are skipped with a warning.
|
||||
|
||||
**Additive only:** policy-add never removes existing presets. Skills install is idempotent.
|
||||
|
||||
## Run all stages (S0b–S5)
|
||||
|
||||
```bash
|
||||
./scripts/install.sh
|
||||
# or
|
||||
make install
|
||||
```
|
||||
|
||||
## Run S3–S5 only (attach path)
|
||||
|
||||
```bash
|
||||
./scripts/install.sh --stage s3-s5
|
||||
# or
|
||||
make install-s3-s5
|
||||
```
|
||||
|
||||
## After install (S0–S6)
|
||||
|
||||
- Verify `.env` values are correct for your environment.
|
||||
- Check policy: `nemohermes <name> policy-list`
|
||||
- Run health checks: `make doctor`
|
||||
- See [SETUP_UX.md](SETUP_UX.md) for owner-facing setup after full install.
|
||||
- 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:
|
||||
- `nemohermes` v0.0.93, `openshell` v0.0.85
|
||||
- Sandbox: `hermes` (default)
|
||||
- Gateway: `nemoclaw` at `https://127.0.0.1:8080`
|
||||
- Inference: OpenAI-compatible endpoint with multimodal model
|
||||
- Docker: present and running
|
||||
|
||||
Scripts are idempotent on this configuration.
|
||||
|
||||
+43
-3
@@ -1,14 +1,54 @@
|
||||
# Operations (day-2)
|
||||
|
||||
**Status:** Outline.
|
||||
**Status:** Doctor (S6) implemented.
|
||||
|
||||
## Operator commands (when implemented)
|
||||
## Health checks (doctor)
|
||||
|
||||
```bash
|
||||
# Run all health checks (human-readable)
|
||||
make doctor
|
||||
# or
|
||||
./scripts/doctor.sh
|
||||
|
||||
# Machine-readable JSON summary
|
||||
./scripts/doctor.sh --json
|
||||
```
|
||||
|
||||
**What doctor checks:**
|
||||
|
||||
| Check | What it validates | Severity |
|
||||
|-------|-------------------|----------|
|
||||
| Docker | Daemon running and accessible | Critical |
|
||||
| nemohermes CLI | Installed and versioned | Critical |
|
||||
| openshell CLI | Installed and versioned | Critical |
|
||||
| Sandbox | `nemohermes <name> doctor` healthy | Critical |
|
||||
| Policy | `lumina-inference` preset applied | Warning |
|
||||
| Skills | Skill directories with SKILL.md present | Warning |
|
||||
| Inference endpoint | `/v1/models` reachable from .env URL | Critical |
|
||||
| Inference gateway | `openshell inference get` configured | Warning |
|
||||
|
||||
**Exit codes:** `0` = all critical checks passed; `1` = one or more critical failures.
|
||||
|
||||
## Platform diagnostics
|
||||
|
||||
```bash
|
||||
# Sandbox status
|
||||
nemohermes <sandbox-name> status
|
||||
|
||||
# Sandbox doctor (platform-level)
|
||||
nemohermes <sandbox-name> doctor
|
||||
|
||||
# Sandbox logs
|
||||
nemohermes <sandbox-name> logs --follow
|
||||
docker compose -f deploy/compose/docker-compose.yml logs
|
||||
|
||||
# Gateway status
|
||||
openshell status
|
||||
|
||||
# Inference config
|
||||
openshell inference get
|
||||
|
||||
# Policy presets
|
||||
nemohermes <sandbox-name> policy-list
|
||||
```
|
||||
|
||||
## Log levels
|
||||
|
||||
+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`
|
||||
|
||||
@@ -1,14 +1,52 @@
|
||||
# OpenShell policy (scaffold)
|
||||
# OpenShell policy
|
||||
|
||||
**Status:** Structure only — no live policy applied until **build**.
|
||||
**Status:** Base policy and overlays ready for S5.
|
||||
|
||||
## Intended contents
|
||||
## Structure
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `policy.yaml` (or equivalent) | Deny-by-default base for salon egress |
|
||||
| `overlays/` | Per-integration allowlists (Square MCP host, QBO local, channels, etc.) |
|
||||
| `base.yaml` | Deny-by-default intent (reference; not applied directly) |
|
||||
| `overlays/inference.yaml` | Inference endpoint (applied at S5) |
|
||||
| `overlays/square-mcp.yaml` | Square MCP (applied at S7 when connected) |
|
||||
| `overlays/quickbooks.yaml` | QuickBooks Online (applied at S7 when connected) |
|
||||
| `overlays/vagaro.yaml` | Vagaro REST API (applied at S7 when connected) |
|
||||
| `overlays/channels.yaml` | Messaging channels (applied at S7 when connected) |
|
||||
|
||||
Apply via platform CLIs only: `openshell policy set`, `nemohermes <name> policy-add` / `policy-remove`.
|
||||
## Policy lifecycle
|
||||
|
||||
See [design/mcp-integrations.md](../../design/mcp-integrations.md) and [docs/POLICY.md](../../docs/POLICY.md).
|
||||
1. **Install (S5):** Apply inference overlay. Existing balanced-tier presets (npm, pypi, huggingface, brew) are preserved.
|
||||
2. **Connect (S7):** Apply SaaS/channel overlays as integrations are enabled.
|
||||
3. **Upgrade:** Re-apply all overlays (idempotent via `--yes`).
|
||||
4. **Doctor (S6):** Verify active policy matches expected state.
|
||||
|
||||
## Apply policy
|
||||
|
||||
```bash
|
||||
# Apply inference overlay (S5)
|
||||
nemohermes <name> policy-add --from-file policy/openshell/overlays/inference.yaml --yes
|
||||
|
||||
# Apply SaaS overlay (S7, when connected)
|
||||
nemohermes <name> policy-add --from-file policy/openshell/overlays/square-mcp.yaml --yes
|
||||
|
||||
# Apply built-in channel preset (S7, when connected)
|
||||
nemohermes <name> policy-add telegram --yes
|
||||
|
||||
# List active presets
|
||||
nemohermes <name> policy-list
|
||||
|
||||
# Export current policy
|
||||
nemohermes <name> policy-get
|
||||
```
|
||||
|
||||
## Key principles
|
||||
|
||||
- **Additive only:** policy-add never removes existing presets.
|
||||
- **No wipe:** Never remove balanced-tier presets (npm, pypi, huggingface, brew).
|
||||
- **Always deny:** Social publish, payment/refund/payout/bill-pay are never allowed.
|
||||
- **Platform-first:** All policy mutations via `nemohermes` / `openshell` CLIs.
|
||||
|
||||
## Design reference
|
||||
|
||||
- [design/DESIGN_PLAN.md §3](../../design/DESIGN_PLAN.md) — Hermes as NemoClaw-managed infrastructure
|
||||
- [docs/POLICY.md](../../docs/POLICY.md) — Full policy documentation
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# policy/openshell/base.yaml
|
||||
# Lumina base policy — reference document.
|
||||
#
|
||||
# This file documents the deny-by-default intent for the Lumina sandbox.
|
||||
# It is NOT applied directly. The live policy is managed by nemohermes
|
||||
# policy-add/policy-remove commands.
|
||||
#
|
||||
# Apply via: nemohermes <name> policy-add --from-file <path>
|
||||
#
|
||||
# ── Deny-by-default intent ────────────────────────────────────────────────
|
||||
#
|
||||
# The sandbox starts with NO network egress except what is explicitly allowed
|
||||
# by applied policy presets. The base tier (balanced) provides:
|
||||
#
|
||||
# npm, pypi, huggingface, brew — package management (always allowed)
|
||||
#
|
||||
# Lumina adds overlays for:
|
||||
# - inference endpoints (main model + vision)
|
||||
# - SaaS integrations (Square, QBO, Vagaro) — when connected
|
||||
# - messaging channels (WhatsApp, Telegram, Email) — when connected
|
||||
#
|
||||
# ── Always deny ────────────────────────────────────────────────────────────
|
||||
# - Social publish APIs (no silent publish)
|
||||
# - Payment/refund/payout/bill-pay endpoints
|
||||
# - Any host not explicitly listed in an overlay
|
||||
#
|
||||
# ── Policy lifecycle ───────────────────────────────────────────────────────
|
||||
# 1. Install (S5): apply base overlays (inference)
|
||||
# 2. Connect (S7): apply SaaS/channel overlays as integrations are enabled
|
||||
# 3. Upgrade: re-apply all overlays (idempotent)
|
||||
# 4. Doctor (S6): verify active policy matches expected state
|
||||
#
|
||||
# See docs/POLICY.md for full policy documentation.
|
||||
@@ -0,0 +1,18 @@
|
||||
# policy/openshell/overlays/channels.yaml
|
||||
# Messaging channels policy overlay (applied at S7 when channels are connected).
|
||||
#
|
||||
# Allows the sandbox to reach messaging channel APIs. Each channel has
|
||||
# its own built-in preset in nemohermes (telegram, whatsapp, slack, etc.).
|
||||
# This overlay documents which channels Lumina uses.
|
||||
#
|
||||
# Apply via: nemohermes <name> policy-add <preset-name> --yes
|
||||
# (Built-in presets; no --from-file needed for standard channels)
|
||||
#
|
||||
# Planned channels:
|
||||
# - whatsapp (nemohermes built-in preset)
|
||||
# - telegram (nemohermes built-in preset)
|
||||
# - email (handled by openshell provider store)
|
||||
#
|
||||
# Rules:
|
||||
# - Owner ↔ agent: full messaging
|
||||
# - Client outbound: draft-first only (no silent send/publish)
|
||||
@@ -0,0 +1,49 @@
|
||||
# policy/openshell/overlays/inference.yaml
|
||||
# Inference endpoint policy overlay for Lumina.
|
||||
#
|
||||
# Allows the sandbox to reach the configured inference endpoint through
|
||||
# the OpenShell gateway. The gateway resolves inference.local to the
|
||||
# actual endpoint URL from .env.
|
||||
#
|
||||
# Apply via: nemohermes <name> policy-add --from-file <this-file> --yes
|
||||
#
|
||||
# This overlay is additive — it does not remove existing presets.
|
||||
|
||||
preset:
|
||||
name: lumina-inference
|
||||
network_policies:
|
||||
lumina-inference:
|
||||
name: lumina-inference
|
||||
endpoints:
|
||||
- host: inference.local
|
||||
port: 443
|
||||
protocol: rest
|
||||
enforcement: enforce
|
||||
rules:
|
||||
- allow:
|
||||
method: POST
|
||||
path: /v1/chat/completions
|
||||
- allow:
|
||||
method: POST
|
||||
path: /v1/messages
|
||||
- allow:
|
||||
method: POST
|
||||
path: /v1/responses
|
||||
- allow:
|
||||
method: POST
|
||||
path: /v1/completions
|
||||
- allow:
|
||||
method: POST
|
||||
path: /v1/embeddings
|
||||
- allow:
|
||||
method: GET
|
||||
path: /v1/models
|
||||
- allow:
|
||||
method: GET
|
||||
path: /v1/models/**
|
||||
binaries:
|
||||
- path: /usr/local/bin/hermes
|
||||
# Glob supported by nemohermes policy engine (verified against built-in
|
||||
# presets: huggingface, nous_research, npm_yarn, pypi all use python3*)
|
||||
- path: /usr/bin/python3*
|
||||
- path: /opt/hermes/.venv/bin/python
|
||||
@@ -0,0 +1,38 @@
|
||||
# policy/openshell/overlays/quickbooks.yaml
|
||||
# QuickBooks Online policy overlay (applied at S7 when QBO is connected).
|
||||
#
|
||||
# Allows the sandbox to reach QuickBooks Online APIs for read operations
|
||||
# (reports, search, get). Write/update/delete operations are excluded
|
||||
# at the MCP tool level.
|
||||
#
|
||||
# Apply via: nemohermes <name> policy-add --from-file <this-file> --yes
|
||||
|
||||
preset:
|
||||
name: quickbooks-online
|
||||
network_policies:
|
||||
quickbooks-online:
|
||||
name: quickbooks-online
|
||||
endpoints:
|
||||
- host: quickbooks.api.intuit.com
|
||||
port: 443
|
||||
protocol: rest
|
||||
enforcement: enforce
|
||||
rules:
|
||||
- allow:
|
||||
method: GET
|
||||
path: /**
|
||||
- allow:
|
||||
method: POST
|
||||
path: /**
|
||||
- host: oauth.platform.intuit.com
|
||||
port: 443
|
||||
protocol: rest
|
||||
enforcement: enforce
|
||||
rules:
|
||||
- allow:
|
||||
method: POST
|
||||
path: /**
|
||||
binaries:
|
||||
- path: /usr/local/bin/hermes
|
||||
- path: /usr/bin/python3*
|
||||
- path: /opt/hermes/.venv/bin/python
|
||||
@@ -0,0 +1,30 @@
|
||||
# policy/openshell/overlays/square-mcp.yaml
|
||||
# Square MCP policy overlay (applied at S7 when Square is connected).
|
||||
#
|
||||
# Allows the sandbox to reach Square's MCP server for bookings, customers,
|
||||
# catalog, and inventory reads. Payment/refund/payout tools are excluded
|
||||
# at the MCP tool level, not the network level.
|
||||
#
|
||||
# Apply via: nemohermes <name> policy-add --from-file <this-file> --yes
|
||||
|
||||
preset:
|
||||
name: square-mcp
|
||||
network_policies:
|
||||
square-mcp:
|
||||
name: square-mcp
|
||||
endpoints:
|
||||
- host: connect.squareup.com
|
||||
port: 443
|
||||
protocol: rest
|
||||
enforcement: enforce
|
||||
rules:
|
||||
- allow:
|
||||
method: GET
|
||||
path: /**
|
||||
- allow:
|
||||
method: POST
|
||||
path: /**
|
||||
binaries:
|
||||
- path: /usr/local/bin/hermes
|
||||
- path: /usr/bin/python3*
|
||||
- path: /opt/hermes/.venv/bin/python
|
||||
@@ -0,0 +1,33 @@
|
||||
# policy/openshell/overlays/vagaro.yaml
|
||||
# Vagaro policy overlay (applied at S7 when Vagaro is connected).
|
||||
#
|
||||
# Allows the sandbox to reach Vagaro's REST API for appointments, clients,
|
||||
# services, and staff data. Webhook ingestion is handled by the local
|
||||
# vagaro-webhooks service (compose-managed), not direct sandbox egress.
|
||||
#
|
||||
# Apply via: nemohermes <name> policy-add --from-file <this-file> --yes
|
||||
|
||||
preset:
|
||||
name: vagaro
|
||||
network_policies:
|
||||
vagaro:
|
||||
name: vagaro
|
||||
endpoints:
|
||||
- host: api.vagaro.com
|
||||
port: 443
|
||||
protocol: rest
|
||||
enforcement: enforce
|
||||
rules:
|
||||
- allow:
|
||||
method: GET
|
||||
path: /**
|
||||
- allow:
|
||||
method: POST
|
||||
path: /**
|
||||
- allow:
|
||||
method: PUT
|
||||
path: /**
|
||||
binaries:
|
||||
- path: /usr/local/bin/hermes
|
||||
- path: /usr/bin/python3*
|
||||
- path: /opt/hermes/.venv/bin/python
|
||||
+81
-13
@@ -1,19 +1,87 @@
|
||||
# Host scripts (scaffold)
|
||||
# Host scripts
|
||||
|
||||
**Status:** Documented entrypoints only — no executable bodies until **build**.
|
||||
**Status:** S0b–S7 implemented.
|
||||
|
||||
All scripts must wrap **`nemohermes` / `openshell` / Docker**. No parallel control API.
|
||||
All scripts wrap **`nemohermes` / `openshell` / Docker**. No parallel control API.
|
||||
|
||||
## Intended entrypoints
|
||||
## Entrypoints
|
||||
|
||||
| Script | Role |
|
||||
|--------|------|
|
||||
| `bootstrap.sh` | Host prereqs; install Docker if missing |
|
||||
| `install.sh` | Stages S0b–S6 |
|
||||
| `upgrade.sh` | Snapshot, pull pins, migrate, re-apply policy, doctor |
|
||||
| `doctor.sh` | Health checks |
|
||||
| `connect/*.sh` | Operator connect helpers (Square, QBO, Vagaro, channels) |
|
||||
| `install/` | Stage helpers |
|
||||
| `lib/` | Shared shell helpers |
|
||||
| Script | Role | Status |
|
||||
|--------|------|--------|
|
||||
| `bootstrap.sh` | Host prereqs; install Docker if missing | ✅ S0b |
|
||||
| `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/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 |
|
||||
|
||||
## Shared library
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `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
|
||||
|
||||
```bash
|
||||
# Bootstrap (Docker if missing)
|
||||
./scripts/bootstrap.sh
|
||||
|
||||
# Full install (S0b–S5)
|
||||
./scripts/install.sh
|
||||
|
||||
# Individual stages
|
||||
./scripts/install.sh --stage s1 # env only
|
||||
./scripts/install.sh --stage s2 # models only
|
||||
./scripts/install.sh --stage s4 # sandbox verify/onboard
|
||||
./scripts/install.sh --stage s5 # policy + skills
|
||||
./scripts/install.sh --stage s3-s5 # S3 through S5
|
||||
|
||||
# Health checks (S6)
|
||||
./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
|
||||
make install-s1
|
||||
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).
|
||||
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/bootstrap.sh — S0b: host bootstrap (Docker install-if-missing)
|
||||
#
|
||||
# Idempotent: detects Docker and skips install if already present.
|
||||
# Linux only (Debian/Ubuntu/RHEL/Fedora families).
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/bootstrap.sh
|
||||
# make bootstrap
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# Source shared helpers
|
||||
# shellcheck source=lib/common.sh
|
||||
source "$SCRIPT_DIR/lib/common.sh"
|
||||
|
||||
log_section "S0b: Host bootstrap"
|
||||
warn_if_root
|
||||
|
||||
# ── Check Docker ───────────────────────────────────────────────────────────
|
||||
if docker_available; then
|
||||
log_info "Docker already installed ($(docker --version))"
|
||||
log_info "Bootstrap complete — nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log_warn "Docker not found or not running."
|
||||
|
||||
# ── Detect OS family ──────────────────────────────────────────────────────
|
||||
detect_os_family() {
|
||||
if [[ -f /etc/os-release ]]; then
|
||||
# Sourced in a subshell via $() — OS variables do not leak to caller scope.
|
||||
. /etc/os-release
|
||||
echo "${ID_LIKE:-$ID}"
|
||||
else
|
||||
log_error "Cannot detect OS family (no /etc/os-release)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
OS_FAMILY="$(detect_os_family)"
|
||||
log_info "Detected OS family: $OS_FAMILY"
|
||||
|
||||
install_docker_debian() {
|
||||
log_info "Installing Docker (Debian/Ubuntu) via official convenience script…"
|
||||
if ! curl -fsSL https://get.docker.com | sh; then
|
||||
log_error "Docker install script failed."
|
||||
log_error "Install Docker manually, then re-run this script."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Add current user to docker group (requires sudo)
|
||||
local user="${SUDO_USER:-$(whoami)}"
|
||||
if [[ -n "$user" ]] && getent group docker &>/dev/null; then
|
||||
log_info "Adding user '$user' to docker group…"
|
||||
sudo usermod -aG docker "$user" 2>/dev/null || true
|
||||
log_warn "Log out and back in (or run 'newgrp docker') for group changes to take effect."
|
||||
fi
|
||||
}
|
||||
|
||||
install_docker_rhel() {
|
||||
log_info "Installing Docker (RHEL/Fedora) via dnf…"
|
||||
sudo dnf install -y dnf-utils 2>/dev/null || sudo dnf install -ydnf-plugins-core 2>/dev/null || true
|
||||
sudo dnf config-manager --add-repo https://download.docker.com/linux/$(echo "$OS_FAMILY" | head -c3)/docker-ce.repo 2>/dev/null || {
|
||||
log_warn "Could not add Docker repo. Trying generic install…"
|
||||
sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin || {
|
||||
log_error "Docker install failed. Install manually and re-run."
|
||||
return 1
|
||||
}
|
||||
}
|
||||
sudo systemctl enable --now docker
|
||||
}
|
||||
|
||||
# ── Install ────────────────────────────────────────────────────────────────
|
||||
if echo "$OS_FAMILY" | grep -qiE 'debian|ubuntu'; then
|
||||
install_docker_debian
|
||||
elif echo "$OS_FAMILY" | grep -qiE 'rhel|fedora|centos|rocky|almalinux'; then
|
||||
install_docker_rhel
|
||||
else
|
||||
log_error "Unsupported OS family: $OS_FAMILY"
|
||||
log_error "Supported: Debian/Ubuntu, RHEL/Fedora/CentOS/Rocky/AlmaLinux"
|
||||
log_error "Install Docker manually, then re-run this script."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Verify ─────────────────────────────────────────────────────────────────
|
||||
if docker_available; then
|
||||
log_info "Docker installed successfully: $(docker --version)"
|
||||
log_info "Bootstrap complete."
|
||||
else
|
||||
log_error "Docker install appeared to succeed but daemon is not reachable."
|
||||
log_error "Check Docker service: sudo systemctl status docker"
|
||||
exit 1
|
||||
fi
|
||||
@@ -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."
|
||||
Executable
+355
@@ -0,0 +1,355 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/doctor.sh — S6: Lumina product health checks
|
||||
#
|
||||
# Composes platform-layer health checks:
|
||||
# Docker · nemohermes · openshell · sandbox · policy · skills · inference
|
||||
#
|
||||
# Platform-first: wraps nemohermes / openshell / Docker. No parallel control API.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/doctor.sh # full check
|
||||
# ./scripts/doctor.sh --json # machine-readable summary
|
||||
# ./scripts/doctor.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"
|
||||
|
||||
# ── Defaults ───────────────────────────────────────────────────────────────
|
||||
JSON_OUTPUT=0
|
||||
|
||||
# ── Parse args ─────────────────────────────────────────────────────────────
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--help|-h)
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [OPTIONS]
|
||||
|
||||
S6: Lumina product health checks.
|
||||
|
||||
Checks:
|
||||
Docker daemon, nemohermes CLI, openshell CLI, sandbox status,
|
||||
policy overlays, skills directory, inference endpoint.
|
||||
|
||||
Options:
|
||||
--json Machine-readable JSON summary
|
||||
--help Show this help
|
||||
|
||||
Exit codes:
|
||||
0 All critical checks passed (warnings are non-fatal)
|
||||
1 One or more critical checks failed
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
--json)
|
||||
JSON_OUTPUT=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown argument: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── State tracking ─────────────────────────────────────────────────────────
|
||||
CRITICAL_FAIL=0
|
||||
WARN_COUNT=0
|
||||
declare -a CHECK_RESULTS=()
|
||||
|
||||
# Strip ANSI escape codes from a string
|
||||
strip_ansi() {
|
||||
sed 's/\x1b\[[0-9;]*m//g' <<< "$1"
|
||||
}
|
||||
|
||||
# Record a check result: "group|label|status|detail"
|
||||
record_check() {
|
||||
local group="$1" label="$2" status="$3" detail="$4"
|
||||
# Strip any ANSI codes that may have leaked from CLI output
|
||||
detail="$(strip_ansi "$detail")"
|
||||
CHECK_RESULTS+=("${group}|${label}|${status}|${detail}")
|
||||
if [[ "$status" == "FAIL" ]]; then
|
||||
CRITICAL_FAIL=1
|
||||
elif [[ "$status" == "WARN" ]]; then
|
||||
WARN_COUNT=$((WARN_COUNT + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Load .env (best-effort; warn if missing) ───────────────────────────────
|
||||
if [[ $JSON_OUTPUT -eq 1 ]]; then
|
||||
load_env >/dev/null 2>&1 || true
|
||||
else
|
||||
load_env 2>/dev/null || true
|
||||
fi
|
||||
|
||||
SANDBOX_NAME="$(get_sandbox_name)"
|
||||
SKILLS_DIR="$REPO_ROOT/skills"
|
||||
POLICY_DIR="$REPO_ROOT/policy/openshell/overlays"
|
||||
|
||||
# ── Check: Docker ──────────────────────────────────────────────────────────
|
||||
check_docker() {
|
||||
if ! cmd_exists docker; then
|
||||
record_check "Docker" "CLI" "FAIL" "docker command not found"
|
||||
return
|
||||
fi
|
||||
if ! docker info &>/dev/null; then
|
||||
record_check "Docker" "Daemon" "FAIL" "docker daemon not running or not accessible"
|
||||
return
|
||||
fi
|
||||
local version
|
||||
version="$(docker --version 2>/dev/null | sed 's/^Docker version //' | cut -d',' -f1 | tr -d ' ')"
|
||||
record_check "Docker" "Daemon" "OK" "running ($version)"
|
||||
}
|
||||
|
||||
# ── Check: nemohermes CLI ──────────────────────────────────────────────────
|
||||
check_nemohermes() {
|
||||
if ! cmd_exists nemohermes; then
|
||||
record_check "CLI" "nemohermes" "FAIL" "nemohermes not found — install NemoClaw platform"
|
||||
return
|
||||
fi
|
||||
local version
|
||||
version="$(nemohermes --version 2>/dev/null | head -1 | grep -oP 'v[\d.]+' || echo 'unknown')"
|
||||
record_check "CLI" "nemohermes" "OK" "$version"
|
||||
}
|
||||
|
||||
# ── Check: openshell CLI ───────────────────────────────────────────────────
|
||||
check_openshell() {
|
||||
if ! cmd_exists openshell; then
|
||||
record_check "CLI" "openshell" "FAIL" "openshell not found — install OpenShell"
|
||||
return
|
||||
fi
|
||||
local version
|
||||
version="$(openshell --version 2>/dev/null | head -1 | grep -oP '[\d.]+' || echo 'unknown')"
|
||||
record_check "CLI" "openshell" "OK" "$version"
|
||||
}
|
||||
|
||||
# ── Check: Sandbox status ──────────────────────────────────────────────────
|
||||
check_sandbox() {
|
||||
# Skip if nemohermes is missing (already flagged)
|
||||
if ! cmd_exists nemohermes; then
|
||||
record_check "Sandbox" "Status" "FAIL" "skipped — nemohermes not available"
|
||||
return
|
||||
fi
|
||||
|
||||
# Run nemohermes doctor for the sandbox — this is the authoritative platform check
|
||||
local doctor_output
|
||||
doctor_output="$(nemohermes "$SANDBOX_NAME" doctor 2>&1)" || true
|
||||
|
||||
# Check for summary line
|
||||
if echo "$doctor_output" | grep -qi "Summary: healthy"; then
|
||||
record_check "Sandbox" "Doctor" "OK" "$SANDBOX_NAME healthy"
|
||||
elif echo "$doctor_output" | grep -qi "Summary:.*warning"; then
|
||||
record_check "Sandbox" "Doctor" "WARN" "$SANDBOX_NAME has warnings"
|
||||
elif echo "$doctor_output" | grep -qi "Summary:.*unhealthy\|Summary:.*critical"; then
|
||||
record_check "Sandbox" "Doctor" "FAIL" "$SANDBOX_NAME unhealthy"
|
||||
else
|
||||
# Fallback: check status command
|
||||
if nemohermes "$SANDBOX_NAME" status &>/dev/null; then
|
||||
record_check "Sandbox" "Status" "OK" "$SANDBOX_NAME reachable"
|
||||
else
|
||||
record_check "Sandbox" "Status" "FAIL" "$SANDBOX_NAME not found or not reachable"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Check: Policy overlays ─────────────────────────────────────────────────
|
||||
check_policy() {
|
||||
# Skip if nemohermes is missing
|
||||
if ! cmd_exists nemohermes; then
|
||||
record_check "Policy" "Overlays" "FAIL" "skipped — nemohermes not available"
|
||||
return
|
||||
fi
|
||||
|
||||
# Check that policy overlay files exist in the repo
|
||||
local inference_policy="$POLICY_DIR/inference.yaml"
|
||||
if [[ ! -f "$inference_policy" ]]; then
|
||||
record_check "Policy" "Overlay files" "WARN" "inference.yaml not found in $POLICY_DIR"
|
||||
fi
|
||||
|
||||
# Check that lumina-inference preset is applied in the sandbox
|
||||
local policy_list
|
||||
policy_list="$(nemohermes "$SANDBOX_NAME" policy-list 2>&1)" || {
|
||||
record_check "Policy" "policy-list" "FAIL" "could not list policy presets"
|
||||
return
|
||||
}
|
||||
|
||||
if echo "$policy_list" | grep -q "lumina-inference"; then
|
||||
record_check "Policy" "lumina-inference" "OK" "preset applied"
|
||||
else
|
||||
record_check "Policy" "lumina-inference" "WARN" "preset not found in sandbox — run S5 or: nemohermes $SANDBOX_NAME policy-add --from-file $inference_policy --yes"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Check: Skills ──────────────────────────────────────────────────────────
|
||||
check_skills() {
|
||||
if [[ ! -d "$SKILLS_DIR" ]]; then
|
||||
record_check "Skills" "Directory" "FAIL" "skills directory not found at $SKILLS_DIR"
|
||||
return
|
||||
fi
|
||||
|
||||
# Count skill directories (exclude _lib and hidden)
|
||||
local skill_count=0
|
||||
local skill_with_md=0
|
||||
for skill_dir in "$SKILLS_DIR"/*/; do
|
||||
[[ -d "$skill_dir" ]] || continue
|
||||
local name
|
||||
name="$(basename "$skill_dir")"
|
||||
[[ "$name" == "_lib" ]] && continue
|
||||
[[ "$name" == "README.md" ]] && continue
|
||||
skill_count=$((skill_count + 1))
|
||||
if [[ -f "$skill_dir/SKILL.md" ]]; then
|
||||
skill_with_md=$((skill_with_md + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $skill_count -eq 0 ]]; then
|
||||
record_check "Skills" "Pack" "WARN" "no skill directories found in $SKILLS_DIR"
|
||||
elif [[ $skill_with_md -lt $skill_count ]]; then
|
||||
record_check "Skills" "Pack" "WARN" "$skill_with_md/$skill_count skills have SKILL.md"
|
||||
else
|
||||
record_check "Skills" "Pack" "OK" "$skill_count skills with SKILL.md"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Check: Inference endpoint ──────────────────────────────────────────────
|
||||
check_inference() {
|
||||
# Check .env has the required keys
|
||||
local env_file="${REPO_ROOT}/.env"
|
||||
if [[ ! -f "$env_file" ]]; then
|
||||
record_check "Inference" "Config" "FAIL" ".env not found — run S1 first"
|
||||
return
|
||||
fi
|
||||
|
||||
# Source .env to get variables (already done by load_env, but verify)
|
||||
local base_url="${LUMINA_INFERENCE_BASE_URL:-}"
|
||||
local model="${LUMINA_INFERENCE_MODEL:-}"
|
||||
|
||||
if [[ -z "$base_url" ]]; then
|
||||
record_check "Inference" "Endpoint URL" "FAIL" "LUMINA_INFERENCE_BASE_URL not set in .env"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -z "$model" ]]; then
|
||||
record_check "Inference" "Model" "FAIL" "LUMINA_INFERENCE_MODEL not set in .env"
|
||||
return
|
||||
fi
|
||||
|
||||
# Check endpoint reachability
|
||||
local url="$base_url"
|
||||
# Ensure URL ends with /v1 for the models endpoint
|
||||
if [[ "$url" != */v1 && "$url" != */v1/* ]]; then
|
||||
url="${url%/}/v1"
|
||||
fi
|
||||
|
||||
if curl -sf --max-time 15 "${url}/models" &>/dev/null; then
|
||||
record_check "Inference" "Endpoint" "OK" "reachable ($base_url)"
|
||||
else
|
||||
record_check "Inference" "Endpoint" "FAIL" "unreachable at $base_url"
|
||||
fi
|
||||
|
||||
# Check openshell inference config (optional — gateway may not be connected)
|
||||
if cmd_exists openshell; then
|
||||
local inf_output
|
||||
inf_output="$(openshell inference get 2>&1)" || true
|
||||
if echo "$inf_output" | grep -q "Provider:"; then
|
||||
local provider
|
||||
# Strip ANSI escape codes and whitespace
|
||||
provider="$(echo "$inf_output" | grep "Provider:" | head -1 | sed 's/.*Provider: *//' | sed 's/\x1b\[[0-9;]*m//g' | tr -d '[:space:]')"
|
||||
record_check "Inference" "Gateway route" "OK" "configured ($provider)"
|
||||
else
|
||||
record_check "Inference" "Gateway route" "WARN" "not configured via openshell"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Run all checks ─────────────────────────────────────────────────────────
|
||||
if [[ $JSON_OUTPUT -eq 0 ]]; then
|
||||
log_section "Lumina Doctor (S6)"
|
||||
fi
|
||||
|
||||
check_docker
|
||||
check_nemohermes
|
||||
check_openshell
|
||||
check_sandbox
|
||||
check_policy
|
||||
check_skills
|
||||
check_inference
|
||||
|
||||
# ── Output results ─────────────────────────────────────────────────────────
|
||||
if [[ $JSON_OUTPUT -eq 1 ]]; then
|
||||
# Machine-readable JSON summary
|
||||
checks_json="["
|
||||
first=1
|
||||
for entry in "${CHECK_RESULTS[@]}"; do
|
||||
IFS='|' read -r group label status detail <<< "$entry"
|
||||
if [[ $first -eq 1 ]]; then
|
||||
first=0
|
||||
else
|
||||
checks_json+=","
|
||||
fi
|
||||
# Escape backslashes first, then double quotes (JSON-safe)
|
||||
detail="${detail//\\/\\\\}"
|
||||
detail="${detail//\"/\\\"}"
|
||||
checks_json+="{\"group\":\"$group\",\"label\":\"$label\",\"status\":\"$status\",\"detail\":\"$detail\"}"
|
||||
done
|
||||
checks_json+="]"
|
||||
|
||||
overall="healthy"
|
||||
[[ $CRITICAL_FAIL -eq 1 ]] && overall="unhealthy"
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"product": "Lumina",
|
||||
"stage": "S6",
|
||||
"sandbox": "$SANDBOX_NAME",
|
||||
"overall": "$overall",
|
||||
"critical_failures": $CRITICAL_FAIL,
|
||||
"warnings": $WARN_COUNT,
|
||||
"checks": $checks_json
|
||||
}
|
||||
EOF
|
||||
else
|
||||
# Human-readable summary
|
||||
log_section "Results"
|
||||
|
||||
ok_count=0
|
||||
warn_count=0
|
||||
fail_count=0
|
||||
|
||||
for entry in "${CHECK_RESULTS[@]}"; do
|
||||
IFS='|' read -r group label status detail <<< "$entry"
|
||||
case "$status" in
|
||||
OK) printf " \033[0;32m[OK]\033[0m %-12s %s — %s\n" "$group" "$label" "$detail" ;;
|
||||
WARN) printf " \033[1;33m[WARN]\033[0m %-12s %s — %s\n" "$group" "$label" "$detail" ;;
|
||||
FAIL) printf " \033[0;31m[FAIL]\033[0m %-12s %s — %s\n" "$group" "$label" "$detail" ;;
|
||||
esac
|
||||
case "$status" in
|
||||
OK) ok_count=$((ok_count + 1)) ;;
|
||||
WARN) warn_count=$((warn_count + 1)) ;;
|
||||
FAIL) fail_count=$((fail_count + 1)) ;;
|
||||
esac
|
||||
done
|
||||
|
||||
log_section "Summary"
|
||||
printf " Checks: %d OK, %d WARN, %d FAIL\n" "$ok_count" "$warn_count" "$fail_count"
|
||||
|
||||
if [[ $CRITICAL_FAIL -eq 1 ]]; then
|
||||
printf " Overall: \033[0;31mUNHEALTHY\033[0m\n"
|
||||
elif [[ $WARN_COUNT -gt 0 ]]; then
|
||||
printf " Overall: \033[1;33mHEALTHY (with warnings)\033[0m\n"
|
||||
else
|
||||
printf " Overall: \033[0;32mHEALTHY\033[0m\n"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Exit code ──────────────────────────────────────────────────────────────
|
||||
if [[ $CRITICAL_FAIL -eq 1 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/install.sh — Lumina staged installer
|
||||
#
|
||||
# Runs install stages S0b–S5.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/install.sh # run all implemented stages (S0b–S5)
|
||||
# ./scripts/install.sh --stage s1 # run only S1
|
||||
# ./scripts/install.sh --stage s3-s5 # run S3 through S5
|
||||
# ./scripts/install.sh --help
|
||||
#
|
||||
# All stages are idempotent. Re-running is safe.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# Source shared helpers
|
||||
# shellcheck source=lib/common.sh
|
||||
source "$SCRIPT_DIR/lib/common.sh"
|
||||
|
||||
# ── Usage ──────────────────────────────────────────────────────────────────
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [OPTIONS]
|
||||
|
||||
Run Lumina install stages (S0b–S5 implemented).
|
||||
|
||||
Options:
|
||||
--stage <s0b|s1|s2|s3|s4|s5|s3-s5> Run only the specified stage or range
|
||||
--help Show this help
|
||||
|
||||
Stages:
|
||||
S0b Docker install-if-missing (bootstrap)
|
||||
S1 Repository environment (.env)
|
||||
S2 Model + vision configuration + smoke test
|
||||
S3 Stack alignment (compose documentation; no-op on attach)
|
||||
S4 Sandbox verification or onboard (attach mode default)
|
||||
S5 Policy overlays + skills sync
|
||||
|
||||
All stages are idempotent.
|
||||
|
||||
Examples:
|
||||
$(basename "$0") # run S0b → S1 → S2 → S3 → S4 → S5
|
||||
$(basename "$0") --stage s1 # run only S1 (env)
|
||||
$(basename "$0") --stage s5 # run only S5 (policy + skills)
|
||||
$(basename "$0") --stage s3-s5 # run S3 → S4 → S5
|
||||
EOF
|
||||
}
|
||||
|
||||
# ── Parse args ─────────────────────────────────────────────────────────────
|
||||
SINGLE_STAGE=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--help|-h) usage; exit 0 ;;
|
||||
--stage)
|
||||
shift
|
||||
SINGLE_STAGE="${1:-}"
|
||||
if [[ -z "$SINGLE_STAGE" ]]; then
|
||||
log_error "--stage requires a value"
|
||||
exit 1
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown argument: $1"
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Stage runners ──────────────────────────────────────────────────────────
|
||||
run_s0b() {
|
||||
log_section "S0b: Docker bootstrap"
|
||||
bash "$SCRIPT_DIR/bootstrap.sh"
|
||||
}
|
||||
|
||||
run_s1() {
|
||||
bash "$SCRIPT_DIR/install/s1-env.sh"
|
||||
}
|
||||
|
||||
run_s2() {
|
||||
bash "$SCRIPT_DIR/install/s2-models.sh"
|
||||
}
|
||||
|
||||
run_s3() {
|
||||
log_section "S3: Stack alignment"
|
||||
log_info "OpenShell manages the Hermes sandbox container."
|
||||
log_info "Product compose (deploy/compose/) is optional for local MCP/webhooks."
|
||||
log_info "No action needed for UAT attach path."
|
||||
}
|
||||
|
||||
run_s4() {
|
||||
bash "$SCRIPT_DIR/install/s4-sandbox.sh"
|
||||
}
|
||||
|
||||
run_s5() {
|
||||
bash "$SCRIPT_DIR/install/s5-policy-skills.sh"
|
||||
}
|
||||
|
||||
# ── Main ───────────────────────────────────────────────────────────────────
|
||||
log_section "Lumina installer (stages S0b–S5)"
|
||||
warn_if_root
|
||||
|
||||
if [[ -n "$SINGLE_STAGE" ]]; then
|
||||
case "$SINGLE_STAGE" in
|
||||
s0b) run_s0b ;;
|
||||
s1) run_s1 ;;
|
||||
s2) run_s2 ;;
|
||||
s3) run_s3 ;;
|
||||
s4) run_s4 ;;
|
||||
s5) run_s5 ;;
|
||||
s3-s5) run_s3; run_s4; run_s5 ;;
|
||||
*)
|
||||
log_error "Unknown stage: $SINGLE_STAGE"
|
||||
log_error "Valid stages: s0b, s1, s2, s3, s4, s5, s3-s5"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# Run all implemented stages in order
|
||||
run_s0b
|
||||
run_s1
|
||||
run_s2
|
||||
run_s3
|
||||
run_s4
|
||||
run_s5
|
||||
fi
|
||||
|
||||
log_section "Install complete (S0b–S5)"
|
||||
log_info "Next steps:"
|
||||
log_info " - Review .env for correctness"
|
||||
log_info " - Verify policy: nemohermes $(get_sandbox_name) policy-list"
|
||||
log_info " - Continue with S6 (doctor) when implemented"
|
||||
log_info " - See docs/INSTALL.md for full procedure"
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/install/s1-env.sh — S1: repo environment
|
||||
#
|
||||
# Creates .env from .env.example if needed, then validates required keys.
|
||||
# Never commits real secrets.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/install/s1-env.sh
|
||||
# (called by install.sh --stage s1)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Source shared helpers (common.sh sets REPO_ROOT via git rev-parse)
|
||||
# shellcheck source=../lib/common.sh
|
||||
source "$SCRIPT_DIR/../lib/common.sh"
|
||||
# shellcheck source=../lib/env.sh
|
||||
source "$SCRIPT_DIR/../lib/env.sh"
|
||||
|
||||
log_section "S1: Repository environment"
|
||||
|
||||
# ── Create .env from example if needed ─────────────────────────────────────
|
||||
create_env_from_example
|
||||
|
||||
# ── Validate ───────────────────────────────────────────────────────────────
|
||||
validate_env
|
||||
|
||||
log_info "S1 complete: .env is valid."
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/install/s2-models.sh — S2: model + aux vision config
|
||||
#
|
||||
# Configures inference via openshell/nemohermes and runs vision smoke test.
|
||||
# Platform-first: all mutations through nemohermes/openshell CLIs.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/install/s2-models.sh
|
||||
# (called by install.sh --stage s2)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Source shared helpers (common.sh sets REPO_ROOT via git rev-parse)
|
||||
# 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/vision_smoke.sh
|
||||
source "$SCRIPT_DIR/../lib/vision_smoke.sh"
|
||||
|
||||
log_section "S2: Model + vision configuration"
|
||||
|
||||
# ── Load .env ──────────────────────────────────────────────────────────────
|
||||
load_env
|
||||
|
||||
# ── Validate required keys ─────────────────────────────────────────────────
|
||||
validate_env || exit 1
|
||||
|
||||
# ── Check CLI prerequisites ────────────────────────────────────────────────
|
||||
require_cmd openshell "Install OpenShell CLI (part of NemoClaw platform)"
|
||||
require_cmd nemohermes "Install nemohermes CLI (part of NemoClaw platform)"
|
||||
|
||||
# ── Verify inference endpoint is reachable ─────────────────────────────────
|
||||
# LUMINA_INFERENCE_BASE_URL includes /v1 (e.g. http://host:port/v1)
|
||||
log_info "Verifying inference endpoint: $LUMINA_INFERENCE_BASE_URL"
|
||||
if ! curl -sf --max-time 15 "${LUMINA_INFERENCE_BASE_URL}/models" &>/dev/null; then
|
||||
log_error "Inference endpoint unreachable at $LUMINA_INFERENCE_BASE_URL"
|
||||
log_error "Check that the model server is running and the URL is correct."
|
||||
log_error "Fix LUMINA_INFERENCE_BASE_URL in .env and re-run."
|
||||
exit 1
|
||||
fi
|
||||
log_info "Inference endpoint reachable."
|
||||
|
||||
# ── Configure inference via openshell ──────────────────────────────────────
|
||||
# Only set if the gateway is connected and we can reach it.
|
||||
# If the gateway is not yet set up, we validate the endpoint and skip
|
||||
# the openshell write (S3+ will handle full gateway config).
|
||||
log_info "Checking OpenShell gateway status…"
|
||||
if openshell status &>/dev/null 2>&1; then
|
||||
log_info "Gateway connected — configuring inference route…"
|
||||
|
||||
# Use openshell inference set to configure the main model.
|
||||
# openshell inference set takes --provider and --model but NOT --url.
|
||||
# The gateway resolves the endpoint URL from its own metadata (the
|
||||
# compatible-endpoint provider reads the URL from the gateway config).
|
||||
# --no-verify skips the endpoint verification that openshell does internally
|
||||
# since we already verified above.
|
||||
if openshell inference set \
|
||||
--provider compatible-endpoint \
|
||||
--model "$LUMINA_INFERENCE_MODEL" \
|
||||
--no-verify 2>&1; then
|
||||
log_info "Inference route configured via openshell."
|
||||
else
|
||||
log_warn "openshell inference set returned non-zero."
|
||||
log_warn "The gateway may already have this route configured, or the gateway"
|
||||
log_warn "requires a different provider name. Check with: openshell inference get"
|
||||
fi
|
||||
else
|
||||
log_warn "OpenShell gateway not connected — skipping inference route configuration."
|
||||
log_warn "Inference will be configured when the gateway is available (S3+)."
|
||||
log_warn "Ensure LUMINA_INFERENCE_BASE_URL and LUMINA_INFERENCE_MODEL are correct in .env."
|
||||
fi
|
||||
|
||||
# ── Vision smoke test ──────────────────────────────────────────────────────
|
||||
vision_smoke || {
|
||||
log_error "Vision smoke test FAILED."
|
||||
log_error "The inference endpoint does not appear to support multimodal/vision."
|
||||
log_error "Check:"
|
||||
log_error " 1. LUMINA_VISION_MODEL points to a vision-capable model"
|
||||
log_error " 2. The model server supports multimodal inputs"
|
||||
log_error " 3. LUMINA_INFERENCE_BASE_URL is correct"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log_info "S2 complete: model configured, vision smoke passed."
|
||||
Executable
+169
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/install/s4-sandbox.sh — S4: sandbox verification / onboard
|
||||
#
|
||||
# Two modes:
|
||||
# attach (default) — verify existing sandbox is healthy; no destructive ops
|
||||
# onboard — create new sandbox from agent package (clean host only)
|
||||
#
|
||||
# Platform-first: all mutations via nemohermes. Never hand-edit in-sandbox config.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/install/s4-sandbox.sh # attach mode (default)
|
||||
# ./scripts/install/s4-sandbox.sh --mode attach
|
||||
# ./scripts/install/s4-sandbox.sh --mode onboard
|
||||
# ./scripts/install/s4-sandbox.sh --mode onboard --dry-run
|
||||
# ./scripts/install/s4-sandbox.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"
|
||||
|
||||
# ── Defaults ───────────────────────────────────────────────────────────────
|
||||
MODE="${LUMINA_INSTALL_MODE:-attach}"
|
||||
DRY_RUN=0
|
||||
|
||||
# ── Parse args ─────────────────────────────────────────────────────────────
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--help|-h)
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [OPTIONS]
|
||||
|
||||
S4: Sandbox verification or onboard.
|
||||
|
||||
Options:
|
||||
--mode <attach|onboard> Install mode (default: attach)
|
||||
--dry-run Preview onboard without executing
|
||||
--help Show this help
|
||||
|
||||
Modes:
|
||||
attach Verify existing sandbox is healthy (UAT default)
|
||||
onboard Create new sandbox from agent package (clean host)
|
||||
|
||||
Examples:
|
||||
$(basename "$0") # attach mode
|
||||
$(basename "$0") --mode onboard # onboard mode
|
||||
$(basename "$0") --mode onboard --dry-run # onboard dry-run
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
--mode)
|
||||
shift
|
||||
MODE="${1:-}"
|
||||
if [[ -z "$MODE" ]]; then
|
||||
log_error "--mode requires a value (attach or onboard)"
|
||||
exit 1
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown argument: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Validate mode ──────────────────────────────────────────────────────────
|
||||
if [[ "$MODE" != "attach" && "$MODE" != "onboard" ]]; then
|
||||
log_error "Invalid mode: $MODE (must be 'attach' or 'onboard')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_section "S4: Sandbox ($MODE mode)"
|
||||
|
||||
# ── Load .env ──────────────────────────────────────────────────────────────
|
||||
load_env
|
||||
|
||||
# ── Validate required keys ─────────────────────────────────────────────────
|
||||
validate_env || exit 1
|
||||
|
||||
# ── Check CLI prerequisites ────────────────────────────────────────────────
|
||||
require_cmd nemohermes "Install nemohermes CLI (part of NemoClaw platform)"
|
||||
|
||||
SANDBOX_NAME="$(get_sandbox_name)"
|
||||
AGENT_PKG_DIR="$REPO_ROOT/agents/hermes"
|
||||
|
||||
# ── Attach mode ────────────────────────────────────────────────────────────
|
||||
do_attach() {
|
||||
log_info "Attach mode: verifying sandbox '$SANDBOX_NAME'"
|
||||
|
||||
# Check sandbox exists and report status
|
||||
if ! nemohermes "$SANDBOX_NAME" status &>/dev/null; then
|
||||
log_error "Sandbox '$SANDBOX_NAME' not found or not reachable."
|
||||
log_error "If this is a clean host, re-run with --mode onboard"
|
||||
log_error "Or create the sandbox manually: nemohermes onboard"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Sandbox '$SANDBOX_NAME' is healthy."
|
||||
|
||||
# Verify agent package exists (reference only in attach mode)
|
||||
if [[ -d "$AGENT_PKG_DIR" ]]; then
|
||||
log_info "Agent package found at $AGENT_PKG_DIR"
|
||||
if [[ -f "$AGENT_PKG_DIR/skills-manifest/manifest.yaml" ]]; then
|
||||
log_info "Skills manifest present — S5 will sync skills"
|
||||
else
|
||||
log_warn "Skills manifest not found — skills sync (S5) may be incomplete"
|
||||
fi
|
||||
else
|
||||
log_warn "Agent package directory not found at $AGENT_PKG_DIR"
|
||||
fi
|
||||
|
||||
log_info "S4 attach complete."
|
||||
}
|
||||
|
||||
# ── Onboard mode ───────────────────────────────────────────────────────────
|
||||
do_onboard() {
|
||||
log_info "Onboard mode: preparing sandbox '$SANDBOX_NAME' from agent package"
|
||||
|
||||
# Verify agent package exists
|
||||
if [[ ! -d "$AGENT_PKG_DIR" ]]; then
|
||||
log_error "Agent package not found at $AGENT_PKG_DIR"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check if sandbox already exists — do not destroy it
|
||||
if nemohermes "$SANDBOX_NAME" status &>/dev/null; then
|
||||
log_warn "Sandbox '$SANDBOX_NAME' already exists."
|
||||
log_warn "Onboard mode does not destroy existing sandboxes."
|
||||
log_warn "Switching to attach behavior for safety."
|
||||
do_attach
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ $DRY_RUN -eq 1 ]]; then
|
||||
log_info "DRY-RUN: Would execute:"
|
||||
log_info " nemohermes onboard --from-dir $AGENT_PKG_DIR"
|
||||
log_info " (with inference from .env: $LUMINA_INFERENCE_BASE_URL)"
|
||||
log_info "S4 onboard dry-run complete."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Onboard with agent package
|
||||
log_info "Running nemohermes onboard with agent package…"
|
||||
if nemohermes onboard --from-dir "$AGENT_PKG_DIR"; then
|
||||
log_info "Sandbox '$SANDBOX_NAME' onboarded successfully."
|
||||
else
|
||||
log_error "Onboard failed. Check nemohermes logs for details."
|
||||
log_error "You may need to run nemohermes onboard manually first."
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "S4 onboard complete."
|
||||
}
|
||||
|
||||
# ── Execute ────────────────────────────────────────────────────────────────
|
||||
case "$MODE" in
|
||||
attach) do_attach ;;
|
||||
onboard) do_onboard ;;
|
||||
esac
|
||||
Executable
+197
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/install/s5-policy-skills.sh — S5: policy apply + skills sync
|
||||
#
|
||||
# Applies Lumina policy overlays and syncs skills into the sandbox.
|
||||
# Additive only: never removes existing presets.
|
||||
#
|
||||
# Platform-first: all mutations via nemohermes.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/install/s5-policy-skills.sh
|
||||
# ./scripts/install/s5-policy-skills.sh --policy-only
|
||||
# ./scripts/install/s5-policy-skills.sh --skills-only
|
||||
# ./scripts/install/s5-policy-skills.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"
|
||||
|
||||
# ── Defaults ───────────────────────────────────────────────────────────────
|
||||
DO_POLICY=1
|
||||
DO_SKILLS=1
|
||||
POLICY_ONLY_SET=0
|
||||
SKILLS_ONLY_SET=0
|
||||
|
||||
# ── Parse args ─────────────────────────────────────────────────────────────
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--help|-h)
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [OPTIONS]
|
||||
|
||||
S5: Apply policy overlays and sync skills into the sandbox.
|
||||
|
||||
Options:
|
||||
--policy-only Apply policy overlays only (skip skills sync)
|
||||
--skills-only Sync skills only (skip policy apply)
|
||||
--help Show this help
|
||||
|
||||
Examples:
|
||||
$(basename "$0") # policy + skills
|
||||
$(basename "$0") --policy-only # policy only
|
||||
$(basename "$0") --skills-only # skills only
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
--policy-only)
|
||||
DO_POLICY=1
|
||||
DO_SKILLS=0
|
||||
POLICY_ONLY_SET=1
|
||||
shift
|
||||
;;
|
||||
--skills-only)
|
||||
DO_POLICY=0
|
||||
DO_SKILLS=1
|
||||
SKILLS_ONLY_SET=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown argument: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Validate mutually exclusive flags ──────────────────────────────────────
|
||||
if [[ $POLICY_ONLY_SET -eq 1 && $SKILLS_ONLY_SET -eq 1 ]]; then
|
||||
log_error "--policy-only and --skills-only are mutually exclusive"
|
||||
exit 1
|
||||
fi
|
||||
if [[ $DO_POLICY -eq 0 && $DO_SKILLS -eq 0 ]]; then
|
||||
log_error "Internal error: both DO_POLICY and DO_SKILLS are disabled"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_section "S5: Policy + Skills sync"
|
||||
|
||||
# ── Load .env ──────────────────────────────────────────────────────────────
|
||||
load_env
|
||||
|
||||
# ── Validate required keys ─────────────────────────────────────────────────
|
||||
validate_env || exit 1
|
||||
|
||||
# ── Check CLI prerequisites ────────────────────────────────────────────────
|
||||
require_cmd nemohermes "Install nemohermes CLI (part of NemoClaw platform)"
|
||||
|
||||
SANDBOX_NAME="$(get_sandbox_name)"
|
||||
POLICY_DIR="$REPO_ROOT/policy/openshell/overlays"
|
||||
SKILLS_DIR="$REPO_ROOT/skills"
|
||||
|
||||
# ── Verify sandbox exists ──────────────────────────────────────────────────
|
||||
if ! nemohermes "$SANDBOX_NAME" status &>/dev/null 2>&1; then
|
||||
log_error "Sandbox '$SANDBOX_NAME' not found. Run S4 first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Policy apply ───────────────────────────────────────────────────────────
|
||||
apply_policy() {
|
||||
log_section "S5a: Apply policy overlays"
|
||||
|
||||
if [[ ! -d "$POLICY_DIR" ]]; then
|
||||
log_warn "Policy overlays directory not found: $POLICY_DIR"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Apply inference overlay (always apply at S5)
|
||||
local inference_policy="$POLICY_DIR/inference.yaml"
|
||||
if [[ -f "$inference_policy" ]]; then
|
||||
log_info "Applying inference policy overlay…"
|
||||
if nemohermes "$SANDBOX_NAME" policy-add --from-file "$inference_policy" --yes 2>&1; then
|
||||
log_info "Inference policy overlay applied."
|
||||
else
|
||||
log_warn "Inference policy overlay may already be applied (idempotent)."
|
||||
fi
|
||||
else
|
||||
log_warn "Inference policy overlay not found: $inference_policy"
|
||||
fi
|
||||
|
||||
# List current policy for verification
|
||||
log_info "Current policy presets:"
|
||||
nemohermes "$SANDBOX_NAME" policy-list 2>&1 || log_warn "Could not list policy presets"
|
||||
|
||||
log_info "S5a policy apply complete."
|
||||
}
|
||||
|
||||
# ── Skills sync ────────────────────────────────────────────────────────────
|
||||
sync_skills() {
|
||||
log_section "S5b: Sync skills"
|
||||
|
||||
if [[ ! -d "$SKILLS_DIR" ]]; then
|
||||
log_warn "Skills directory not found: $SKILLS_DIR"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local skill_count=0
|
||||
local skill_ok=0
|
||||
local skill_skip=0
|
||||
local skill_fail=0
|
||||
|
||||
# Iterate skill directories (skip _lib and hidden dirs)
|
||||
for skill_dir in "$SKILLS_DIR"/*/; do
|
||||
# Skip if not a directory
|
||||
[[ -d "$skill_dir" ]] || continue
|
||||
|
||||
local skill_name
|
||||
skill_name="$(basename "$skill_dir")"
|
||||
|
||||
# Skip _lib (shared library, not a skill)
|
||||
if [[ "$skill_name" == "_lib" ]]; then
|
||||
log_info "Skipping _lib (shared library)"
|
||||
continue
|
||||
fi
|
||||
|
||||
skill_count=$((skill_count + 1))
|
||||
|
||||
# Check for SKILL.md (required by nemohermes skill install)
|
||||
local skill_md="$skill_dir/SKILL.md"
|
||||
if [[ ! -f "$skill_md" ]]; then
|
||||
log_warn "Skipping '$skill_name': no SKILL.md found"
|
||||
skill_skip=$((skill_skip + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
log_info "Installing skill: $skill_name"
|
||||
if nemohermes "$SANDBOX_NAME" skill install "$skill_dir" 2>&1; then
|
||||
log_info " ✓ $skill_name installed"
|
||||
skill_ok=$((skill_ok + 1))
|
||||
else
|
||||
log_warn " ✗ $skill_name failed (may already be installed)"
|
||||
skill_fail=$((skill_fail + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
log_info "Skills sync summary: $skill_count found, $skill_ok installed, $skill_skip skipped, $skill_fail failed"
|
||||
|
||||
if [[ $skill_count -eq 0 ]]; then
|
||||
log_warn "No skill directories found in $SKILLS_DIR"
|
||||
fi
|
||||
|
||||
log_info "S5b skills sync complete."
|
||||
}
|
||||
|
||||
# ── Execute ────────────────────────────────────────────────────────────────
|
||||
if [[ $DO_POLICY -eq 1 ]]; then
|
||||
apply_policy
|
||||
fi
|
||||
|
||||
if [[ $DO_SKILLS -eq 1 ]]; then
|
||||
sync_skills
|
||||
fi
|
||||
|
||||
log_info "S5 complete."
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/lib/common.sh — shared helpers for all host scripts
|
||||
# Sourced by bootstrap.sh, install.sh, and stage scripts.
|
||||
# Do not execute directly.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Colours (auto-disable when not a tty) ──────────────────────────────────
|
||||
if [[ -t 2 ]]; then
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
||||
BOLD='\033[1m'; NC='\033[0m'
|
||||
else
|
||||
RED=''; GREEN=''; YELLOW=''; BOLD=''; NC=''
|
||||
fi
|
||||
|
||||
# ── Logging ────────────────────────────────────────────────────────────────
|
||||
# Use printf to avoid echo -e interpreting escape sequences in arguments.
|
||||
log_info() { printf "${GREEN}[INFO]${NC} %s\n" "$*"; }
|
||||
log_warn() { printf "${YELLOW}[WARN]${NC} %s\n" "$*" >&2; }
|
||||
log_error() { printf "${RED}[ERROR]${NC} %s\n" "$*" >&2; }
|
||||
log_section() { printf "\n${BOLD}═══ %s ═══${NC}\n" "$*"; }
|
||||
|
||||
# ── Repo root (works from any subdirectory) ────────────────────────────────
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)")"
|
||||
|
||||
# ── Env loading ────────────────────────────────────────────────────────────
|
||||
# NOTE: Sources .env directly as shell code. This is a known pattern with a
|
||||
# theoretical injection surface if .env contains shell commands. In practice,
|
||||
# .env is created from .env.example (controlled by this repo) and edited by
|
||||
# the operator. Defense-in-depth: parse line-by-line instead of sourcing,
|
||||
# but the current approach matches the broader ecosystem convention.
|
||||
load_env() {
|
||||
local env_file="${1:-${REPO_ROOT}/.env}"
|
||||
if [[ -f "$env_file" ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
set -a; source "$env_file"; set +a
|
||||
log_info "Loaded env from $env_file"
|
||||
else
|
||||
log_warn ".env not found at $env_file — variables must be set externally"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── CLI detection ──────────────────────────────────────────────────────────
|
||||
cmd_exists() { command -v "$1" &>/dev/null; }
|
||||
|
||||
require_cmd() {
|
||||
if ! cmd_exists "$1"; then
|
||||
log_error "Required command not found: $1"
|
||||
if [[ "${2:-}" ]]; then
|
||||
log_error "$2"
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Sandbox name ───────────────────────────────────────────────────────────
|
||||
get_sandbox_name() {
|
||||
echo "${LUMINA_SANDBOX:-hermes}"
|
||||
}
|
||||
|
||||
# ── Docker check ───────────────────────────────────────────────────────────
|
||||
docker_available() {
|
||||
cmd_exists docker && docker info &>/dev/null
|
||||
}
|
||||
|
||||
# ── nemohermes check ───────────────────────────────────────────────────────
|
||||
nemohermes_available() {
|
||||
cmd_exists nemohermes
|
||||
}
|
||||
|
||||
# ── openshell check ────────────────────────────────────────────────────────
|
||||
openshell_available() {
|
||||
cmd_exists openshell
|
||||
}
|
||||
|
||||
# ── Guard: script must be run as operator (not root-only, but warn) ────────
|
||||
warn_if_root() {
|
||||
if [[ "$(id -u)" -eq 0 ]]; then
|
||||
log_warn "Running as root. Some host scripts work better as a regular user with sudo access."
|
||||
fi
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/lib/env.sh — .env validation helpers
|
||||
# Sourced by install stages that need environment checks.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Required keys for S1. Each entry: "KEY description"
|
||||
S1_REQUIRED_KEYS=(
|
||||
"LUMINA_SANDBOX Sandbox name (default: hermes)"
|
||||
"LUMINA_INFERENCE_BASE_URL OpenAI-compatible inference endpoint base URL"
|
||||
"LUMINA_INFERENCE_MODEL Model identifier on the inference endpoint"
|
||||
"LUMINA_VISION_MODEL Vision-capable model identifier (may equal LUMINA_INFERENCE_MODEL)"
|
||||
)
|
||||
|
||||
# Optional keys with defaults
|
||||
declare -A S1_OPTIONAL_KEYS=(
|
||||
["LUMINA_INFERENCE_API_KEY"]="API key for the inference endpoint (empty if endpoint is unauthenticated)"
|
||||
["LUMINA_GATEWAY_URL"]="NemoClaw gateway URL (default: https://127.0.0.1:8080)"
|
||||
["LUMINA_DASHBOARD_PORT"]="Dashboard port (default: 18789)"
|
||||
)
|
||||
|
||||
# ── Validate .env has all required keys ────────────────────────────────────
|
||||
validate_env() {
|
||||
local env_file="${1:-${REPO_ROOT}/.env}"
|
||||
local missing=0
|
||||
|
||||
if [[ ! -f "$env_file" ]]; then
|
||||
log_error ".env file not found at $env_file"
|
||||
log_error "Copy .env.example to .env and fill in values first."
|
||||
return 1
|
||||
fi
|
||||
|
||||
for entry in "${S1_REQUIRED_KEYS[@]}"; do
|
||||
# Split on first space
|
||||
local key="${entry%% *}"
|
||||
local desc="${entry#* }"
|
||||
if ! grep -q "^${key}=" "$env_file" 2>/dev/null; then
|
||||
log_error "Missing required key: $key ($desc)"
|
||||
missing=1
|
||||
elif grep -qE "^${key}=[[:space:]]*$" "$env_file" 2>/dev/null; then
|
||||
log_error "Empty or whitespace-only required key: $key ($desc)"
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $missing -ne 0 ]]; then
|
||||
log_error "Fix missing keys in $env_file and re-run."
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info ".env validation passed ($env_file)"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Create .env from .env.example if it doesn't exist ──────────────────────
|
||||
create_env_from_example() {
|
||||
local env_file="${1:-${REPO_ROOT}/.env}"
|
||||
local example_file="${2:-${REPO_ROOT}/.env.example}"
|
||||
|
||||
if [[ -f "$env_file" ]]; then
|
||||
log_info ".env already exists at $env_file — skipping creation"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ ! -f "$example_file" ]]; then
|
||||
log_error ".env.example not found at $example_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
cp "$example_file" "$env_file"
|
||||
log_info "Created $env_file from .env.example"
|
||||
log_warn "Edit $env_file with your actual values before continuing."
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/lib/vision_smoke.sh — vision capability smoke test
|
||||
# Sourced by S2 install stage.
|
||||
#
|
||||
# Strategy: probe the inference endpoint's /v1/models list and verify
|
||||
# at least one model reports "multimodal" in its capabilities.
|
||||
# Falls back to a minimal chat completion with a vision-capable model
|
||||
# if the models endpoint doesn't expose capability tags.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Vision smoke: check endpoint reports multimodal capability ─────────────
|
||||
vision_smoke() {
|
||||
local base_url="${LUMINA_INFERENCE_BASE_URL}"
|
||||
local model="${LUMINA_VISION_MODEL:-${LUMINA_INFERENCE_MODEL}}"
|
||||
local api_key="${LUMINA_INFERENCE_API_KEY:-}"
|
||||
|
||||
log_section "S2: Vision smoke test"
|
||||
log_info "Probing inference endpoint: $base_url"
|
||||
|
||||
# Build curl headers (-f: fail on HTTP error codes)
|
||||
local -a curl_args=(-sf --max-time 30)
|
||||
if [[ -n "$api_key" ]]; then
|
||||
curl_args+=(-H "Authorization: Bearer $api_key")
|
||||
fi
|
||||
|
||||
# ── Step 1: Check /v1/models for multimodal capability ──────────────────
|
||||
# LUMINA_INFERENCE_BASE_URL includes /v1 (e.g. http://host:port/v1)
|
||||
log_info "Checking models endpoint…"
|
||||
local models_json
|
||||
models_json=$(curl "${curl_args[@]}" "${base_url}/models" 2>/dev/null) || {
|
||||
log_error "Cannot reach inference endpoint at ${base_url}/models"
|
||||
log_error "Check that the endpoint is running and LUMINA_INFERENCE_BASE_URL is correct."
|
||||
return 1
|
||||
}
|
||||
|
||||
# Check for multimodal in capabilities array (case-sensitive per API spec)
|
||||
if printf '%s\n' "$models_json" | grep -q '"multimodal"'; then
|
||||
log_info "PASS: Inference endpoint reports multimodal capability"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ── Step 2: Fallback — try a minimal chat completion with vision model ──
|
||||
log_warn "Models endpoint did not advertise 'multimodal' — falling back to chat completion probe"
|
||||
log_info "Testing chat completion with model: $model"
|
||||
|
||||
local response
|
||||
response=$(curl \
|
||||
-H "Content-Type: application/json" \
|
||||
"${curl_args[@]}" \
|
||||
"${base_url}/chat/completions" \
|
||||
-d "{
|
||||
\"model\": \"${model}\",
|
||||
\"messages\": [{\"role\": \"user\", \"content\": \"Say OK\"}],
|
||||
\"max_tokens\": 4
|
||||
}" 2>/dev/null) || {
|
||||
log_error "Chat completion probe failed to ${base_url}/chat/completions"
|
||||
log_error "The inference endpoint may be down or the model '${model}' is not available."
|
||||
return 1
|
||||
}
|
||||
|
||||
# Check for a valid response structure
|
||||
if printf '%s\n' "$response" | grep -q '"choices"'; then
|
||||
log_info "PASS: Chat completion succeeded with model ${model}"
|
||||
log_info "Vision capability assumed (endpoint supports multimodal per design)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_error "Chat completion probe returned unexpected response"
|
||||
log_error "Response (truncated): $(printf '%s' "$response" | head -c 500)"
|
||||
return 1
|
||||
}
|
||||
+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)
|
||||
|
||||
@@ -1,9 +1,34 @@
|
||||
# Shared skill library (scaffold)
|
||||
# Shared skill library
|
||||
|
||||
**Status:** Empty until **build**.
|
||||
**Status:** Partially implemented.
|
||||
|
||||
Planned packages under `providers/`:
|
||||
Shared deterministic library used by Salon_Assistant skills. All code here is
|
||||
**deterministic** — no model inference, no network calls. See
|
||||
[design/det-vs-inf.md](../../../design/det-vs-inf.md).
|
||||
|
||||
- `scheduling/` — Vagaro / Square adapters
|
||||
- `books/` — QuickBooks Online adapters
|
||||
- `mcp/` — MCP client helpers / allowlist metadata
|
||||
## Packages
|
||||
|
||||
| Package | Status | Purpose |
|
||||
|---------|--------|---------|
|
||||
| `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) |
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
from lumina_skills.domain import Appointment, AppointmentStatus
|
||||
from lumina_skills.board_builder import build_board, format_board_text
|
||||
from lumina_skills.providers.scheduling.fixture_provider import load_fixtures
|
||||
```
|
||||
|
||||
## Design references
|
||||
|
||||
- Deterministic boundary: [design/det-vs-inf.md](../../../design/det-vs-inf.md)
|
||||
- Use cases: [design/use-cases.md](../../../design/use-cases.md)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""lumina_skills — shared deterministic library for Salon_Assistant skills."""
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Deterministic board builder.
|
||||
|
||||
Takes a list of Appointment objects and produces a DayBoard with:
|
||||
- Sorted appointments
|
||||
- Computed gaps between consecutive appointments per staff member
|
||||
- Confirmation flags
|
||||
- Offline/fixture labeling
|
||||
|
||||
All logic is deterministic — no model inference.
|
||||
See design/det-vs-inf.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import warnings
|
||||
from datetime import datetime, time
|
||||
from typing import Any
|
||||
|
||||
from lumina_skills.domain import Appointment, AppointmentStatus, DayBoard, Gap
|
||||
|
||||
|
||||
def build_board(
|
||||
appointments: list[Appointment],
|
||||
date: str,
|
||||
salon_name: str,
|
||||
source: str = "fixtures",
|
||||
business_hours: dict[str, str] | None = None,
|
||||
) -> DayBoard:
|
||||
"""Build a complete DayBoard from a list of appointments.
|
||||
|
||||
Args:
|
||||
appointments: Raw appointment list (from fixtures or live source).
|
||||
date: The board date in YYYY-MM-DD format.
|
||||
salon_name: Display name of the salon.
|
||||
source: Data source label — "fixtures", "offline", "vagaro", "square".
|
||||
business_hours: Optional {"open": "HH:MM", "close": "HH:MM"} to
|
||||
compute gaps at day boundaries.
|
||||
|
||||
Returns:
|
||||
A fully populated DayBoard.
|
||||
"""
|
||||
is_offline = source in ("fixtures", "offline")
|
||||
|
||||
# Filter out cancelled appointments for the board view.
|
||||
active = [a for a in appointments if a.status != AppointmentStatus.CANCELLED]
|
||||
|
||||
# Sort by start time.
|
||||
active.sort(key=lambda a: a.start_time)
|
||||
|
||||
# Compute gaps per staff member.
|
||||
gaps = _compute_gaps(active, date, business_hours)
|
||||
|
||||
# Confirmation flags: pending appointments that need confirmation.
|
||||
needs_confirmation = [a for a in active if a.needs_confirmation]
|
||||
|
||||
# Totals.
|
||||
total_booked = sum(a.duration_minutes() for a in active)
|
||||
total_gap = sum(g.duration_minutes for g in gaps)
|
||||
|
||||
return DayBoard(
|
||||
date=date,
|
||||
salon_name=salon_name,
|
||||
source=source,
|
||||
is_offline=is_offline,
|
||||
appointments=active,
|
||||
gaps=gaps,
|
||||
needs_confirmation=needs_confirmation,
|
||||
total_booked_minutes=total_booked,
|
||||
total_gap_minutes=total_gap,
|
||||
)
|
||||
|
||||
|
||||
def _compute_gaps(
|
||||
appointments: list[Appointment],
|
||||
date: str,
|
||||
business_hours: dict[str, str] | None = None,
|
||||
) -> list[Gap]:
|
||||
"""Compute unbooked gaps between consecutive appointments per staff.
|
||||
|
||||
Gaps are computed per staff member. If business_hours is provided,
|
||||
gaps from open→first appointment and last appointment→close are
|
||||
included (only if >= 30 minutes).
|
||||
|
||||
Args:
|
||||
appointments: Sorted list of active appointments.
|
||||
date: Board date string (YYYY-MM-DD).
|
||||
business_hours: Optional {"open": "HH:MM", "close": "HH:MM"}.
|
||||
|
||||
Returns:
|
||||
List of Gap objects.
|
||||
"""
|
||||
gaps: list[Gap] = []
|
||||
|
||||
# Group appointments by staff.
|
||||
staff_apts: dict[str, list[Appointment]] = {}
|
||||
for apt in appointments:
|
||||
staff_apts.setdefault(apt.staff_name, []).append(apt)
|
||||
|
||||
for staff_name, apts in staff_apts.items():
|
||||
# apts is already sorted by start_time from the caller.
|
||||
open_time = None
|
||||
close_time = None
|
||||
if business_hours:
|
||||
open_str = business_hours.get("open", "")
|
||||
close_str = business_hours.get("close", "")
|
||||
if open_str:
|
||||
open_time = _parse_time_str(open_str)
|
||||
if open_time is None:
|
||||
warnings.warn(
|
||||
f"Invalid business_hours.open format: {open_str!r} "
|
||||
f"(expected HH:MM). Skipping open boundary gap.",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
if close_str:
|
||||
close_time = _parse_time_str(close_str)
|
||||
if close_time is None:
|
||||
warnings.warn(
|
||||
f"Invalid business_hours.close format: {close_str!r} "
|
||||
f"(expected HH:MM). Skipping close boundary gap.",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# Gap from open to first appointment.
|
||||
if open_time and apts:
|
||||
first_start = apts[0].start_time_only()
|
||||
gap_mins = _time_diff_minutes(open_time, first_start)
|
||||
if gap_mins >= 30:
|
||||
gaps.append(Gap(
|
||||
start_time=open_time,
|
||||
end_time=first_start,
|
||||
duration_minutes=gap_mins,
|
||||
staff_name=staff_name,
|
||||
following_appointment_id=apts[0].appointment_id,
|
||||
))
|
||||
|
||||
# Gaps between consecutive appointments.
|
||||
for i in range(len(apts) - 1):
|
||||
current_end = apts[i].end_time_only()
|
||||
next_start = apts[i + 1].start_time_only()
|
||||
gap_mins = _time_diff_minutes(current_end, next_start)
|
||||
if gap_mins < 0:
|
||||
warnings.warn(
|
||||
f"Overlapping appointments for {staff_name}: "
|
||||
f"{apts[i].appointment_id} ends at {current_end} but "
|
||||
f"{apts[i + 1].appointment_id} starts at {next_start} "
|
||||
f"({abs(gap_mins)} min overlap). Gap skipped.",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
continue
|
||||
if gap_mins >= 30:
|
||||
gaps.append(Gap(
|
||||
start_time=current_end,
|
||||
end_time=next_start,
|
||||
duration_minutes=gap_mins,
|
||||
staff_name=staff_name,
|
||||
preceding_appointment_id=apts[i].appointment_id,
|
||||
following_appointment_id=apts[i + 1].appointment_id,
|
||||
))
|
||||
|
||||
# Gap from last appointment to close.
|
||||
if close_time and apts:
|
||||
last_end = apts[-1].end_time_only()
|
||||
gap_mins = _time_diff_minutes(last_end, close_time)
|
||||
if gap_mins >= 30:
|
||||
gaps.append(Gap(
|
||||
start_time=last_end,
|
||||
end_time=close_time,
|
||||
duration_minutes=gap_mins,
|
||||
staff_name=staff_name,
|
||||
preceding_appointment_id=apts[-1].appointment_id,
|
||||
))
|
||||
|
||||
return gaps
|
||||
|
||||
|
||||
def _parse_time_str(raw: str) -> time | None:
|
||||
"""Parse an HH:MM string into a time object.
|
||||
|
||||
Returns None if the format is invalid (not HH:MM with valid ranges).
|
||||
"""
|
||||
m = re.fullmatch(r"(\d{2}):(\d{2})", raw)
|
||||
if m is None:
|
||||
return None
|
||||
h, mi = int(m.group(1)), int(m.group(2))
|
||||
if h > 23 or mi > 59:
|
||||
return None
|
||||
return time(h, mi)
|
||||
|
||||
|
||||
def _time_diff_minutes(start: time, end: time) -> int:
|
||||
"""Minutes between two time objects (same day assumed).
|
||||
|
||||
Returns a negative value when *end* is before *start* (overlap).
|
||||
Callers should check for negative results and warn.
|
||||
"""
|
||||
diff = datetime.combine(datetime.today(), end) - datetime.combine(datetime.today(), start)
|
||||
return int(diff.total_seconds() // 60)
|
||||
|
||||
|
||||
def format_board_text(board: DayBoard) -> str:
|
||||
"""Format a DayBoard 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.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
|
||||
# Header with offline label.
|
||||
source_label = "📋 FIXTURE DATA" if board.is_offline else "📅 LIVE DATA"
|
||||
lines.append(f"═══ {board.salon_name} — {board.date} ═══")
|
||||
lines.append(f"[{source_label}]")
|
||||
lines.append("")
|
||||
|
||||
# Appointments.
|
||||
lines.append("── Appointments ──")
|
||||
if not board.appointments:
|
||||
lines.append(" No appointments.")
|
||||
else:
|
||||
for apt in board.appointments:
|
||||
start_str = apt.start_time.strftime("%H:%M")
|
||||
end_str = apt.end_time.strftime("%H:%M")
|
||||
status_icon = _status_icon(apt.status)
|
||||
confirm_flag = " ⚠️ CONFIRM" if apt.needs_confirmation else ""
|
||||
lines.append(
|
||||
f" {start_str}–{end_str} {status_icon} {apt.client_name}"
|
||||
f" — {apt.service_name} ({apt.staff_name}){confirm_flag}"
|
||||
)
|
||||
if apt.notes:
|
||||
lines.append(f" 📝 {apt.notes}")
|
||||
lines.append("")
|
||||
|
||||
# Gaps.
|
||||
lines.append("── Gaps (≥30 min) ──")
|
||||
if not board.gaps:
|
||||
lines.append(" No significant gaps.")
|
||||
else:
|
||||
for gap in board.gaps:
|
||||
start_str = gap.start_time.strftime("%H:%M")
|
||||
end_str = gap.end_time.strftime("%H:%M")
|
||||
lines.append(
|
||||
f" {start_str}–{end_str} ({gap.duration_minutes} min) "
|
||||
f"— {gap.staff_name}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Confirmation needed.
|
||||
if board.needs_confirmation:
|
||||
lines.append("── Needs Confirmation ──")
|
||||
for apt in board.needs_confirmation:
|
||||
start_str = apt.start_time.strftime("%H:%M")
|
||||
lines.append(
|
||||
f" ⚠️ {apt.client_name} — {apt.service_name} at {start_str}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Summary.
|
||||
lines.append("── Summary ──")
|
||||
lines.append(f" Booked: {board.total_booked_minutes} min | Gaps: {board.total_gap_minutes} min")
|
||||
lines.append(f" Appointments: {len(board.appointments)} | "
|
||||
f"Need confirmation: {len(board.needs_confirmation)}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _status_icon(status: AppointmentStatus) -> str:
|
||||
"""Emoji icon for appointment status."""
|
||||
icons = {
|
||||
AppointmentStatus.CONFIRMED: "✅",
|
||||
AppointmentStatus.PENDING: "⏳",
|
||||
AppointmentStatus.COMPLETED: "✔️",
|
||||
AppointmentStatus.NO_SHOW: "❌",
|
||||
AppointmentStatus.CANCELLED: "🚫",
|
||||
}
|
||||
return icons.get(status, "❓")
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Deterministic domain types for scheduling / board building.
|
||||
|
||||
These are pure data classes — 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 datetime import datetime, time
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class AppointmentStatus(str, Enum):
|
||||
"""Standardized appointment status."""
|
||||
CONFIRMED = "confirmed"
|
||||
PENDING = "pending"
|
||||
CANCELLED = "cancelled"
|
||||
COMPLETED = "completed"
|
||||
NO_SHOW = "no_show"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Appointment:
|
||||
"""A single salon appointment — the core domain object.
|
||||
|
||||
Fields match what the daily-board (A1) needs to display:
|
||||
time, client, service, staff, status, confirmation flag.
|
||||
"""
|
||||
appointment_id: str
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
client_name: str
|
||||
service_name: str
|
||||
staff_name: str
|
||||
status: AppointmentStatus
|
||||
notes: str = ""
|
||||
# Whether the client still needs a confirmation call/message.
|
||||
# Derived at build time from status + last_contact, but stored here
|
||||
# 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
|
||||
return int(delta.total_seconds() // 60)
|
||||
|
||||
def start_time_only(self) -> time:
|
||||
return self.start_time.time()
|
||||
|
||||
def end_time_only(self) -> time:
|
||||
return self.end_time.time()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Gap:
|
||||
"""An unbooked time slot between two appointments (or day boundary)."""
|
||||
start_time: time
|
||||
end_time: time
|
||||
duration_minutes: int
|
||||
staff_name: str
|
||||
# The appointment immediately before this gap (if any).
|
||||
preceding_appointment_id: Optional[str] = None
|
||||
# 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:
|
||||
"""The complete daily board for one staff member or the whole salon.
|
||||
|
||||
This is the structured output that the daily-board skill presents.
|
||||
All data is deterministic — no model inference.
|
||||
"""
|
||||
date: str # YYYY-MM-DD
|
||||
salon_name: str
|
||||
source: str # "fixtures" | "offline" | "vagaro" | "square" (future)
|
||||
is_offline: bool # True when source is fixtures or offline
|
||||
appointments: list[Appointment] = field(default_factory=list)
|
||||
gaps: list[Gap] = field(default_factory=list)
|
||||
needs_confirmation: list[Appointment] = field(default_factory=list)
|
||||
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 {
|
||||
"date": self.date,
|
||||
"salon_name": self.salon_name,
|
||||
"source": self.source,
|
||||
"is_offline": self.is_offline,
|
||||
"appointments": [
|
||||
{
|
||||
"id": a.appointment_id,
|
||||
"start": a.start_time.isoformat(),
|
||||
"end": a.end_time.isoformat(),
|
||||
"client": a.client_name,
|
||||
"service": a.service_name,
|
||||
"staff": a.staff_name,
|
||||
"status": a.status.value,
|
||||
"needs_confirmation": a.needs_confirmation,
|
||||
"notes": a.notes,
|
||||
}
|
||||
for a in self.appointments
|
||||
],
|
||||
"gaps": [
|
||||
{
|
||||
"start": g.start_time.isoformat(),
|
||||
"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
|
||||
],
|
||||
"needs_confirmation": [
|
||||
a.appointment_id for a in self.needs_confirmation
|
||||
],
|
||||
"total_booked_minutes": self.total_booked_minutes,
|
||||
"total_gap_minutes": self.total_gap_minutes,
|
||||
"utilization_rate": round(self.utilization_rate, 4),
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Provider adapters for external data sources."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Books provider: QuickBooks Online (future)."""
|
||||
@@ -0,0 +1 @@
|
||||
"""MCP client helpers and allowlist metadata (future)."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Scheduling provider: fixtures, Vagaro, Square (future)."""
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Fixture provider for scheduling data.
|
||||
|
||||
Loads appointment fixtures from JSON files under data/fixtures/scheduling/.
|
||||
This is the *only* data source for the daily-board until live SaaS adapters
|
||||
(Vagaro, Square) are implemented.
|
||||
|
||||
All output is labeled `source: fixtures` / `is_offline: True` so the owner
|
||||
never sees silent fake live data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from lumina_skills.domain import Appointment, AppointmentStatus
|
||||
|
||||
|
||||
# Mapping from fixture status strings to domain enum.
|
||||
_STATUS_MAP: dict[str, AppointmentStatus] = {
|
||||
"confirmed": AppointmentStatus.CONFIRMED,
|
||||
"pending": AppointmentStatus.PENDING,
|
||||
"cancelled": AppointmentStatus.CANCELLED,
|
||||
"completed": AppointmentStatus.COMPLETED,
|
||||
"no_show": AppointmentStatus.NO_SHOW,
|
||||
}
|
||||
|
||||
|
||||
def _parse_status(raw: str) -> AppointmentStatus:
|
||||
"""Convert a fixture status string to AppointmentStatus.
|
||||
|
||||
Raises:
|
||||
ValueError: If the status string is not recognized.
|
||||
"""
|
||||
key = raw.lower()
|
||||
if key not in _STATUS_MAP:
|
||||
raise ValueError(
|
||||
f"Unknown appointment status {raw!r}. "
|
||||
f"Expected one of: {', '.join(sorted(_STATUS_MAP))}"
|
||||
)
|
||||
return _STATUS_MAP[key]
|
||||
|
||||
|
||||
def _parse_datetime(raw: str) -> datetime:
|
||||
"""Parse ISO-format datetime strings from fixtures."""
|
||||
return datetime.fromisoformat(raw)
|
||||
|
||||
|
||||
def load_fixtures(fixture_path: str | pathlib.Path) -> list[Appointment]:
|
||||
"""Load appointments from a fixture JSON file.
|
||||
|
||||
Expected top-level shape:
|
||||
```json
|
||||
{
|
||||
"salon_name": "Lumina Hair Studio & Spa",
|
||||
"date": "2026-07-28",
|
||||
"business_hours": {"open": "09:00", "close": "18:00"},
|
||||
"staff": [{"name": "Claire Bennett", "role": "owner-stylist"}],
|
||||
"appointments": [
|
||||
{
|
||||
"id": "APT-001",
|
||||
"start": "2026-07-28T09:00:00",
|
||||
"end": "2026-07-28T10:00:00",
|
||||
"client_name": "Elena Rossi",
|
||||
"service_name": "Balayage + Cut",
|
||||
"staff_name": "Claire Bennett",
|
||||
"status": "confirmed",
|
||||
"needs_confirmation": false,
|
||||
"notes": "Formula: 9.1 + 0-45 gloss"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Args:
|
||||
fixture_path: Path to a JSON fixture file.
|
||||
|
||||
Returns:
|
||||
List of Appointment domain objects.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the fixture file does not exist.
|
||||
ValueError: If the fixture JSON is malformed.
|
||||
"""
|
||||
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"))
|
||||
|
||||
appointments: list[Appointment] = []
|
||||
for apt_raw in raw.get("appointments", []):
|
||||
appointments.append(Appointment(
|
||||
appointment_id=apt_raw["id"],
|
||||
start_time=_parse_datetime(apt_raw["start"]),
|
||||
end_time=_parse_datetime(apt_raw["end"]),
|
||||
client_name=apt_raw["client_name"],
|
||||
service_name=apt_raw["service_name"],
|
||||
staff_name=apt_raw["staff_name"],
|
||||
status=_parse_status(apt_raw.get("status", "pending")),
|
||||
notes=apt_raw.get("notes", ""),
|
||||
needs_confirmation=apt_raw.get("needs_confirmation", False),
|
||||
))
|
||||
|
||||
return appointments
|
||||
|
||||
|
||||
def load_fixture_metadata(fixture_path: str | pathlib.Path) -> dict[str, Any]:
|
||||
"""Load non-appointment metadata from a fixture file.
|
||||
|
||||
Returns salon_name, date, business_hours, staff list, 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"),
|
||||
"date": raw.get("date", ""),
|
||||
"business_hours": raw.get("business_hours", {}),
|
||||
"staff": raw.get("staff", []),
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: ap-bills-due
|
||||
description: "Accounts payable: bills due"
|
||||
domain: books
|
||||
---
|
||||
|
||||
# ap-bills-due
|
||||
|
||||
Accounts payable: bills due.
|
||||
|
||||
## Description
|
||||
|
||||
Lists upcoming and overdue bills from QuickBooks Online.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Read-only bill data.
|
||||
- No bill payment execution.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: ar-open-invoices
|
||||
description: "Accounts receivable: open invoices"
|
||||
domain: books
|
||||
---
|
||||
|
||||
# ar-open-invoices
|
||||
|
||||
Accounts receivable: open invoices.
|
||||
|
||||
## Description
|
||||
|
||||
Lists outstanding invoices and aging summary from QuickBooks Online.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Read-only invoice data.
|
||||
- No payment processing.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: availability
|
||||
description: "Check and display appointment availability"
|
||||
domain: operations
|
||||
---
|
||||
|
||||
# availability
|
||||
|
||||
Check and display appointment availability.
|
||||
|
||||
## Description
|
||||
|
||||
Shows available time slots for appointments based on the salon's schedule and service durations.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Deterministic gap math from tools.
|
||||
- No booking without owner confirmation.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: books-snapshot
|
||||
description: "QuickBooks snapshot: P&L, balance, cash"
|
||||
domain: books
|
||||
---
|
||||
|
||||
# books-snapshot
|
||||
|
||||
QuickBooks snapshot: P&L, balance, cash.
|
||||
|
||||
## Description
|
||||
|
||||
Provides a high-level financial snapshot from QuickBooks Online: profit and loss, balance sheet summary, and cash position.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Read-only QuickBooks data.
|
||||
- No write/update/delete operations.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: client-card
|
||||
description: "Client profile and history"
|
||||
domain: clients
|
||||
---
|
||||
|
||||
# client-card
|
||||
|
||||
Client profile and history.
|
||||
|
||||
## Description
|
||||
|
||||
Displays a client's profile, visit history, preferences, and notes.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Read-only client data.
|
||||
- No PII export without owner consent.
|
||||
@@ -1,5 +1,47 @@
|
||||
# `daily-board` (scaffold)
|
||||
# `daily-board`
|
||||
|
||||
**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).
|
||||
Builds a structured daily board from scheduling data: appointments, gaps, confirmation flags, and summary.
|
||||
|
||||
## What it does
|
||||
|
||||
- Loads appointment data from fixture JSON files
|
||||
- Computes gaps between appointments (≥30 min)
|
||||
- Flags appointments needing client confirmation
|
||||
- Labels all output as `📋 FIXTURE DATA` — never silent fake live data
|
||||
- Outputs structured text or JSON
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Text output (default)
|
||||
python skills/daily-board/scripts/build_board.py
|
||||
|
||||
# JSON output
|
||||
python skills/daily-board/scripts/build_board.py --format json
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `SKILL.md` | Skill spec and usage |
|
||||
| `scripts/build_board.py` | CLI entrypoint |
|
||||
| `../../skills/_lib/lumina_skills/domain.py` | Domain types |
|
||||
| `../../skills/_lib/lumina_skills/board_builder.py` | Board builder logic |
|
||||
| `../../skills/_lib/lumina_skills/providers/scheduling/fixture_provider.py` | Fixture loader |
|
||||
| `../../data/fixtures/scheduling/` | Fixture JSON files |
|
||||
|
||||
## Design references
|
||||
|
||||
- Use case: [A1 — Morning / day board](../../design/use-cases.md)
|
||||
- Scenario: [S8 — Morning board on WhatsApp](../../design/scenarios.md)
|
||||
- Deterministic boundary: [design/det-vs-inf.md](../../design/det-vs-inf.md)
|
||||
|
||||
## Future
|
||||
|
||||
- Live Vagaro adapter (S5)
|
||||
- Live Square adapter (S3)
|
||||
- Staff filtering
|
||||
- Multi-day boards
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
name: daily-board
|
||||
description: "Today's salon board: appointments, gaps, and confirmation flags"
|
||||
domain: operations
|
||||
---
|
||||
|
||||
# daily-board
|
||||
|
||||
Today's salon board: appointments, gaps, and confirmation flags.
|
||||
|
||||
## Description
|
||||
|
||||
Builds a structured daily board from scheduling data showing:
|
||||
- **Appointments** — time, client, service, staff, status
|
||||
- **Gaps** — unbooked slots ≥30 minutes between appointments
|
||||
- **Confirmation flags** — appointments that still need client confirmation
|
||||
- **Summary** — total booked time, gap time, appointment count
|
||||
|
||||
All data is labeled with its source. When using fixtures, output is clearly
|
||||
marked `📋 FIXTURE DATA` so the owner never sees silent fake live data.
|
||||
|
||||
## Data sources
|
||||
|
||||
| Source | Status | Label in output |
|
||||
|--------|--------|-----------------|
|
||||
| Fixtures (JSON) | ✅ Implemented | `📋 FIXTURE DATA` |
|
||||
| Vagaro | Not yet | `📅 LIVE DATA` (future) |
|
||||
| Square | Not yet | `📅 LIVE DATA` (future) |
|
||||
|
||||
## Constraints
|
||||
|
||||
- Deterministic facts from fixtures; no model inference for board data.
|
||||
- No silent send or publish.
|
||||
- Cancelled appointments are excluded from the board view.
|
||||
- Gaps under 30 minutes are not shown (too short for a meaningful slot).
|
||||
- Output always labels fixture/offline — never silent fake live data.
|
||||
|
||||
## Usage
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
# Build board from fixtures (default demo data)
|
||||
python skills/daily-board/scripts/build_board.py
|
||||
|
||||
# Build board for a specific fixture file
|
||||
python skills/daily-board/scripts/build_board.py \
|
||||
--fixtures data/fixtures/scheduling/claire_bennett_2026-07-28.json
|
||||
|
||||
# Output as JSON
|
||||
python skills/daily-board/scripts/build_board.py --format json
|
||||
|
||||
# Specify date and salon name explicitly
|
||||
python skills/daily-board/scripts/build_board.py \
|
||||
--date 2026-07-28 --salon "Lumina Hair Studio & Spa"
|
||||
```
|
||||
|
||||
### Programmatic
|
||||
|
||||
```python
|
||||
from lumina_skills.providers.scheduling.fixture_provider import load_fixtures
|
||||
from lumina_skills.board_builder import build_board, format_board_text
|
||||
|
||||
appointments = load_fixtures("data/fixtures/scheduling/claire_bennett_2026-07-28.json")
|
||||
board = build_board(appointments, date="2026-07-28", salon_name="Lumina Hair Studio & Spa")
|
||||
print(format_board_text(board))
|
||||
```
|
||||
|
||||
## Output format
|
||||
|
||||
### Text (default)
|
||||
|
||||
Structured text with sections for appointments, gaps, confirmation flags, and summary.
|
||||
|
||||
### JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"date": "2026-07-28",
|
||||
"salon_name": "Lumina Hair Studio & Spa",
|
||||
"source": "fixtures",
|
||||
"is_offline": true,
|
||||
"appointments": [...],
|
||||
"gaps": [...],
|
||||
"needs_confirmation": [...],
|
||||
"total_booked_minutes": 420,
|
||||
"total_gap_minutes": 120
|
||||
}
|
||||
```
|
||||
|
||||
## Design references
|
||||
|
||||
- Use case: [A1 — Morning / day board](../../design/use-cases.md)
|
||||
- Scenario: [S8 — Morning board on WhatsApp](../../design/scenarios.md)
|
||||
- Deterministic boundary: [design/det-vs-inf.md](../../design/det-vs-inf.md)
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a daily board from scheduling fixtures.
|
||||
|
||||
Usage:
|
||||
python build_board.py [--fixtures PATH] [--date YYYY-MM-DD] [--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.scheduling.fixture_provider import (
|
||||
load_fixtures,
|
||||
load_fixture_metadata,
|
||||
)
|
||||
from lumina_skills.board_builder import build_board, format_board_text
|
||||
|
||||
# Default fixture: Claire Bennett sample day.
|
||||
_DEFAULT_FIXTURE = _REPO_ROOT / "data" / "fixtures" / "scheduling" / "claire_bennett_2026-07-28.json"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Build a daily salon board from fixture data.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fixtures",
|
||||
type=pathlib.Path,
|
||||
default=_DEFAULT_FIXTURE,
|
||||
help="Path to fixture JSON file (default: Claire Bennett sample day).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--date",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Override board date (YYYY-MM-DD). Defaults to fixture date.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--salon",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Override salon name. Defaults to fixture salon_name.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--format",
|
||||
choices=["text", "json"],
|
||||
default="text",
|
||||
help="Output format (default: text).",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Load fixture data.
|
||||
try:
|
||||
appointments = load_fixtures(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
|
||||
|
||||
# Load metadata for defaults.
|
||||
try:
|
||||
meta = load_fixture_metadata(args.fixtures)
|
||||
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
|
||||
print(f"Warning: could not parse fixture metadata: {exc}", file=sys.stderr)
|
||||
meta = {}
|
||||
|
||||
date = args.date or meta.get("date", "unknown")
|
||||
salon_name = args.salon or meta.get("salon_name", "Unknown Salon")
|
||||
business_hours = meta.get("business_hours")
|
||||
|
||||
# Build the board.
|
||||
board = build_board(
|
||||
appointments=appointments,
|
||||
date=date,
|
||||
salon_name=salon_name,
|
||||
source="fixtures",
|
||||
business_hours=business_hours,
|
||||
)
|
||||
|
||||
# Output.
|
||||
if args.format == "json":
|
||||
print(json.dumps(board.to_dict(), indent=2))
|
||||
else:
|
||||
print(format_board_text(board))
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: draft-client-message
|
||||
description: "Draft outbound messages to clients (draft-first)"
|
||||
domain: clients
|
||||
---
|
||||
|
||||
# draft-client-message
|
||||
|
||||
Draft outbound messages to clients (draft-first).
|
||||
|
||||
## Description
|
||||
|
||||
Composes messages to clients for appointments, reminders, or follow-ups. Owner reviews and sends.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Draft-first: owner must approve before sending.
|
||||
- No silent send.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: draft-invoice
|
||||
description: "Draft invoices for clients (draft-first)"
|
||||
domain: books
|
||||
---
|
||||
|
||||
# draft-invoice
|
||||
|
||||
Draft invoices for clients (draft-first).
|
||||
|
||||
## Description
|
||||
|
||||
Creates draft invoices for client services. Owner reviews and sends.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Draft-first: owner must approve before sending.
|
||||
- No automatic payment collection.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: publish-boundary-test
|
||||
description: "Boundary test: verify publish/send denials"
|
||||
domain: system
|
||||
---
|
||||
|
||||
# publish-boundary-test
|
||||
|
||||
Boundary test: verify publish/send denials.
|
||||
|
||||
## Description
|
||||
|
||||
Test skill that verifies the sandbox correctly denies publish, send, and payment operations. Used for validation during install and upgrade.
|
||||
|
||||
## Constraints
|
||||
|
||||
- This skill should fail when attempting publish/send/pay.
|
||||
- If it succeeds, the policy is misconfigured.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: remember-forget
|
||||
description: "Confirm-to-remember persistence; forget entries"
|
||||
domain: system
|
||||
---
|
||||
|
||||
# remember-forget
|
||||
|
||||
Confirm-to-remember persistence; forget entries.
|
||||
|
||||
## Description
|
||||
|
||||
Manages persistent memory entries. Requires explicit owner confirmation before remembering. Supports forgetting previously stored entries.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Write only after structured confirmation.
|
||||
- No silent persistence.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: retail-stock
|
||||
description: "Retail product inventory levels"
|
||||
domain: operations
|
||||
---
|
||||
|
||||
# retail-stock
|
||||
|
||||
Retail product inventory levels.
|
||||
|
||||
## Description
|
||||
|
||||
Shows current stock levels for retail products, with low-stock alerts.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Deterministic thresholds from tools.
|
||||
- No automatic reordering.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: service-menu
|
||||
description: "Service catalog and pricing"
|
||||
domain: operations
|
||||
---
|
||||
|
||||
# service-menu
|
||||
|
||||
Service catalog and pricing.
|
||||
|
||||
## Description
|
||||
|
||||
Displays the salon's service menu with descriptions, durations, and prices.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Read-only catalog data.
|
||||
- No price changes without owner action.
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
name: setup-education
|
||||
description: "Owner-safe connect education and capability report"
|
||||
domain: system
|
||||
---
|
||||
|
||||
# setup-education
|
||||
|
||||
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,
|
||||
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
|
||||
|
||||
- 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,19 @@
|
||||
---
|
||||
name: social-draft
|
||||
description: "Draft social media posts (draft-first; vision aux)"
|
||||
domain: social
|
||||
---
|
||||
|
||||
# social-draft
|
||||
|
||||
Draft social media posts (draft-first; vision aux).
|
||||
|
||||
## Description
|
||||
|
||||
Creates draft social media posts using salon photos and content. Owner reviews and publishes.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Draft-first: owner must approve before publishing.
|
||||
- No silent publish.
|
||||
- Vision aux for photo understanding.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: vendor-inbox
|
||||
description: "Vendor communications and documents"
|
||||
domain: books
|
||||
---
|
||||
|
||||
# vendor-inbox
|
||||
|
||||
Vendor communications and documents.
|
||||
|
||||
## Description
|
||||
|
||||
Tracks vendor communications, invoices received, and related documents.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Read-only vendor data.
|
||||
- Draft-first for any vendor responses.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: vendor-spend
|
||||
description: "Vendor spending summary"
|
||||
domain: books
|
||||
---
|
||||
|
||||
# vendor-spend
|
||||
|
||||
Vendor spending summary.
|
||||
|
||||
## Description
|
||||
|
||||
Summarizes spending by vendor from QuickBooks Online.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Read-only financial data.
|
||||
- No money movement.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: weekly-digest
|
||||
description: "Weekly business digest for the owner"
|
||||
domain: social
|
||||
---
|
||||
|
||||
# weekly-digest
|
||||
|
||||
Weekly business digest for the owner.
|
||||
|
||||
## Description
|
||||
|
||||
Compiles a weekly summary of appointments, revenue, client activity, and key metrics.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Deterministic facts from tools; inference for summary wording.
|
||||
- Read-only data aggregation.
|
||||
+19
-9
@@ -1,12 +1,22 @@
|
||||
# Tests (scaffold)
|
||||
# Tests
|
||||
|
||||
**Status:** Structure only — no product tests until **build**.
|
||||
| Path | Purpose | Status |
|
||||
|------|---------|--------|
|
||||
| `unit/` | Deterministic domain/skill logic (no live model required) | ✅ Partial |
|
||||
| `contract/` | Provider/MCP allow-deny contracts | ⏳ |
|
||||
| `integration/` | Optional cheap-model dialogue paths | ⏳ |
|
||||
| `fixtures/` | Test-only fixtures | ⏳ |
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `unit/` | Deterministic domain/skill logic (no live model required) |
|
||||
| `contract/` | Provider/MCP allow-deny contracts |
|
||||
| `integration/` | Optional cheap-model dialogue paths |
|
||||
| `fixtures/` | Test-only fixtures |
|
||||
## Running tests
|
||||
|
||||
Design boundary: [design/det-vs-inf.md](../design/det-vs-inf.md).
|
||||
```bash
|
||||
# All unit tests
|
||||
python -m pytest tests/unit/ -v
|
||||
|
||||
# Specific test file
|
||||
python -m pytest tests/unit/test_board_builder.py -v
|
||||
```
|
||||
|
||||
## Design boundary
|
||||
|
||||
[design/det-vs-inf.md](../design/det-vs-inf.md) — unit tests cover the deterministic column without a live model.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Unit tests for Salon_Assistant deterministic logic."""
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user