d6b74c42f6
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.
223 lines
6.5 KiB
Bash
223 lines
6.5 KiB
Bash
#!/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
|
|
}
|