Implement E1 setup-education and capability report (fixtures).

Add owner-safe lesson catalog, fixture-backed capability statuses, CLI, and unit tests without live OAuth or connect scripts.
This commit is contained in:
Ty
2026-07-27 13:10:57 -07:00
parent 69b67498ff
commit 76df8b7ab6
16 changed files with 1955 additions and 7 deletions
+3
View File
@@ -13,6 +13,9 @@ Shared deterministic library used by Salon_Assistant skills. All code here is
| `domain.py` | ✅ | Domain types: `Appointment`, `Gap`, `DayBoard`, `AppointmentStatus` |
| `board_builder.py` | ✅ | Deterministic board builder: gaps, confirmation flags, formatting |
| `providers/scheduling/fixture_provider.py` | ✅ | Fixture JSON loader for scheduling data |
| `setup/capability_report.py` | ✅ | Capability report domain model + builder (E6) |
| `setup/lesson_catalog.py` | ✅ | Static setup education lessons (E1) |
| `providers/setup/fixture_provider.py` | ✅ | Fixture JSON loader for capability state |
| `providers/scheduling/` | ⏳ | Vagaro / Square adapters (future) |
| `providers/books/` | ⏳ | QuickBooks Online adapters (future) |
| `providers/mcp/` | ⏳ | MCP client helpers / allowlist metadata (future) |
@@ -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 (17, 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 (17).
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
+44 -3
View File
@@ -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)
+120 -3
View File
@@ -10,9 +10,126 @@ Owner-safe connect education and capability report.
## Description
Guides the owner through connecting their SaaS integrations (Square, QBO, Vagaro) and messaging channels. Provides a capability report showing what is connected.
Guides the owner through connecting their SaaS integrations (Square, QBO, Vagaro)
and messaging channels. Provides a capability report showing what is connected,
what is offline/fixtures, and what was skipped.
All education text is **owner-safe**: browser/vendor UI steps only. Never
terminal, docker, nano, or shell instructions.
## What it does
- Presents 7 setup education lessons (matching `docs/SETUP_UX.md` steps 17)
- Builds a capability report from fixture or live connection state
- Labels all fixture data as `📋 FIXTURE DATA` — never silent fake live data
- Validates that education text never contains forbidden keywords
- Outputs structured text or JSON
## Data sources
| Source | Status | Label in output |
|--------|--------|-----------------|
| Fixtures (JSON) | ✅ Implemented | `📋 FIXTURE DATA` |
| Live connection state | Not yet | `LIVE DATA` (future) |
## Constraints
- Owner-safe: no terminal instructions.
- Browser/vendor UI steps only.
- Deterministic facts from fixtures; no model inference for capability data.
- Owner-safe: no terminal/docker/nano/shell instructions in education text.
- Fixtures/stubs only — no real OAuth, no live secrets.
- Fixture `is_fixture` flag always `true` — never silent as live.
## Usage
### CLI
```bash
# Build capability report from fixtures (default demo data)
python skills/setup-education/scripts/build_capability_report.py
# JSON output
python skills/setup-education/scripts/build_capability_report.py --format json
# Show a specific lesson
python skills/setup-education/scripts/build_capability_report.py --lesson 4
# Show all lessons
python skills/setup-education/scripts/build_capability_report.py --all-lessons
# Use a different fixture
python skills/setup-education/scripts/build_capability_report.py \
--fixtures data/fixtures/setup/capability_matrix_all_connected.json
```
### Programmatic
```python
from lumina_skills.providers.setup.fixture_provider import load_capability_fixture
from lumina_skills.setup.capability_report import format_capability_report_text
from lumina_skills.setup.lesson_catalog import get_lesson, format_lesson_text
# Capability report
report = load_capability_fixture("data/fixtures/setup/capability_matrix.json")
print(format_capability_report_text(report))
# Individual lesson
lesson = get_lesson(4)
print(format_lesson_text(lesson))
```
## Output format
### Text (default)
Structured text grouped by area (identity, profile, channels, scheduling, books)
with emoji status labels and a summary line.
### JSON
```json
{
"salon_name": "Lumina Hair Studio & Spa",
"is_fixture": true,
"capabilities": [
{
"area": "channels",
"provider": "whatsapp",
"status": "connected",
"label": "✅ Connected",
"details": "WhatsApp channel active"
}
],
"summary": {
"connected": 4,
"offline": 2,
"skipped_or_later": 2,
"errors": 0,
"all_connected": false
}
}
```
## Files
| Path | Purpose |
|------|---------|
| `SKILL.md` | Skill spec and usage |
| `scripts/build_capability_report.py` | CLI entrypoint |
| `../../skills/_lib/lumina_skills/setup/capability_report.py` | Domain model + builder |
| `../../skills/_lib/lumina_skills/setup/lesson_catalog.py` | Static lesson steps |
| `../../skills/_lib/lumina_skills/providers/setup/fixture_provider.py` | Fixture loader |
| `../../data/fixtures/setup/` | Fixture JSON files |
## Design references
- Use case: [E1 — Educational setup](../../design/use-cases.md)
- Use case: [E6 — Capability report](../../design/use-cases.md)
- Use case: [E7 — Degraded mode](../../design/use-cases.md)
- Setup UX: [docs/SETUP_UX.md](../../docs/SETUP_UX.md)
- Deterministic boundary: [design/det-vs-inf.md](../../design/det-vs-inf.md)
## Future
- Live connection state provider (replaces fixtures)
- Per-lesson progress tracking
- Automated lesson sequencing
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Build a capability report from setup fixtures.
Usage:
python build_capability_report.py [--fixtures PATH] [--salon NAME] [--format text|json]
All data is labeled as fixture/offline never silent fake live data.
"""
from __future__ import annotations
import argparse
import json
import pathlib
import sys
# Ensure the _lib package is importable regardless of cwd.
_REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
sys.path.insert(0, str(_REPO_ROOT / "skills" / "_lib"))
from lumina_skills.providers.setup.fixture_provider import (
load_capability_fixture,
load_fixture_metadata,
)
from lumina_skills.setup.capability_report import format_capability_report_text
from lumina_skills.setup.lesson_catalog import (
format_all_lessons_text,
format_lesson_text,
get_lesson,
get_all_lessons,
)
# Default fixture: Claire Bennett demo capability matrix.
_DEFAULT_FIXTURE = _REPO_ROOT / "data" / "fixtures" / "setup" / "capability_matrix.json"
def main() -> int:
parser = argparse.ArgumentParser(
description="Build a capability report from setup fixture data.",
)
parser.add_argument(
"--fixtures",
type=pathlib.Path,
default=_DEFAULT_FIXTURE,
help="Path to capability fixture JSON file.",
)
parser.add_argument(
"--salon",
type=str,
default=None,
help="Override salon name.",
)
parser.add_argument(
"--format",
choices=["text", "json"],
default="text",
help="Output format (default: text).",
)
parser.add_argument(
"--lesson",
type=int,
default=None,
help="Show a specific setup lesson (1-7) instead of the capability report.",
)
parser.add_argument(
"--all-lessons",
action="store_true",
help="Show all setup lessons instead of the capability report.",
)
args = parser.parse_args()
# Lesson mode.
if args.lesson is not None:
lesson = get_lesson(args.lesson)
if lesson is None:
print(f"Error: No lesson found for step {args.lesson}", file=sys.stderr)
return 1
print(format_lesson_text(lesson))
return 0
if args.all_lessons:
print(format_all_lessons_text())
return 0
# Capability report mode.
try:
report = load_capability_fixture(args.fixtures)
except FileNotFoundError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
except (json.JSONDecodeError, KeyError, ValueError) as exc:
print(f"Error parsing fixture: {exc}", file=sys.stderr)
return 1
# Override salon name if requested.
if args.salon:
from lumina_skills.setup.capability_report import build_capability_report
report = build_capability_report(
capabilities=report.capabilities,
salon_name=args.salon,
is_fixture=report.is_fixture,
)
# Output.
if args.format == "json":
print(json.dumps(report.to_dict(), indent=2))
else:
print(format_capability_report_text(report))
return 0
if __name__ == "__main__":
raise SystemExit(main())