Implement S7 operator connect helpers for SaaS and channels.

Add dry-run-default connect dispatcher and per-integration scripts with gitignored local capability state, docs, and unit tests. Mutations require --apply and use nemohermes/openshell only.
This commit is contained in:
Ty
2026-07-27 13:32:42 -07:00
parent 76df8b7ab6
commit d6b74c42f6
17 changed files with 2229 additions and 35 deletions
+222
View File
@@ -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
}