Initial commit: custom OpenClaw skills from docker-test

- workspace: capmetro-monitor, github-notifications, model-selector
- workspace-security: vt-monitor, monitor-unauthorized
- workspace-home: cron-manager, monitor-unauthorized
- extensions: vt-sentinel (VT-Sentinel plugin)

Includes sync.sh for pull/push, README, AGENTS.md, .gitignore.
This commit is contained in:
2026-02-16 15:32:44 +00:00
commit 3b7d6bb67c
37 changed files with 3358 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
---
name: capmetro-monitor
description: Monitor CapMetro (Austin, TX) service changes for specific routes. Checks tri-annual service change pages for Route 5 (Bus) and Route 500 (MetroRail), translates transit operator language into plain English summaries. Use for weekly monitoring of commute-relevant transit updates.
---
# CapMetro Service Change Monitor
Weekly monitoring of Austin transit route changes with plain-English summaries.
## What It Does
1. Checks CapMetro service change pages (tri-annual: Jan, Jun, Aug)
2. Filters for Route 5 (Bus) and Route 500 (MetroRail)
3. Detects new changes since last check
4. Returns structured JSON for processing
## Monitored Routes
- **Route 5** - Woodrow/East 12th (Bus)
- **Route 500** - MetroRail (Red Line)
## Usage
```bash
bash skills/capmetro-monitor/scripts/check-changes.sh
```
**Output when nothing new:**
```json
{"hasNew":false}
```
**Output with new changes:**
```json
{
"hasNew": true,
"newChanges": [
{
"url": "https://www.capmetro.org/servicechange/june-2026",
"title": "June 2026 Proposed Service Changes",
"id": "https://www.capmetro.org/servicechange/june-2026"
}
]
}
```
## Integration
Designed for weekly cron job that:
1. Runs check script
2. If `hasNew: true`, fetch full details and summarize in plain English
3. Translate transit terminology (timepoint, alignment, turnaround) for clarity
## State Tracking
State stored in `memory/capmetro-check-state.json`:
```json
{
"lastCheck": "2026-02-04T17:30:00Z",
"seenChanges": ["url1", "url2"]
}
```
## Requirements
- `curl` for web requests
- `jq` for JSON processing

View File

@@ -0,0 +1,63 @@
#!/bin/bash
# Check CapMetro service changes for Route 5 and Route 500
# Returns JSON with new changes since last check
set -e
STATE_FILE="${STATE_FILE:-memory/capmetro-check-state.json}"
WORKSPACE="${WORKSPACE:-/home/node/.openclaw/workspace}"
cd "$WORKSPACE"
# Initialize state if missing
if [ ! -f "$STATE_FILE" ]; then
mkdir -p "$(dirname "$STATE_FILE")"
echo '{"lastCheck":"1970-01-01T00:00:00Z","seenChanges":[]}' > "$STATE_FILE"
fi
NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# Fetch current service changes page
CHANGES=$(curl -s "https://www.capmetro.org/servicechange" | \
grep -oP 'href="/servicechange/[^"]+' | \
sed 's/href="//' | \
sort -u)
# Check each change period for Route 5 or Route 500
RELEVANT_CHANGES='[]'
for change_url in $CHANGES; do
FULL_URL="https://www.capmetro.org$change_url"
CONTENT=$(curl -s "$FULL_URL")
# Check if Route 5 or Route 500 mentioned
if echo "$CONTENT" | grep -qiE "(Route 5[^0-9]|Route 500)"; then
TITLE=$(echo "$CONTENT" | grep -oP '<title>\K[^<]+' | head -1)
RELEVANT_CHANGES=$(echo "$RELEVANT_CHANGES" | jq --arg url "$FULL_URL" --arg title "$TITLE" \
'. += [{"url":$url, "title":$title, "id":$url}]')
fi
done
# Load seen changes
SEEN=$(jq -r '.seenChanges // []' "$STATE_FILE")
# Find new changes
NEW_CHANGES=$(echo "$RELEVANT_CHANGES" | jq --argjson seen "$SEEN" '[
.[] | select(.id as $id | $seen | index($id) | not)
]')
NEW_COUNT=$(echo "$NEW_CHANGES" | jq 'length')
# Update state
ALL_IDS=$(echo "$RELEVANT_CHANGES" | jq -r '[.[].id]')
jq -n \
--arg now "$NOW" \
--argjson ids "$ALL_IDS" \
'{lastCheck:$now, seenChanges:$ids}' > "$STATE_FILE"
# Output
if [ "$NEW_COUNT" -eq 0 ]; then
echo '{"hasNew":false}'
exit 0
fi
jq -n --argjson changes "$NEW_CHANGES" '{hasNew:true, newChanges:$changes}'

View File

@@ -0,0 +1,182 @@
// Route 5 bus monitor — parses GTFS-RT feed for real-time vehicle positions
// Usage: node monitor-route5.js
const https = require('https');
const fs = require('fs');
const protobuf = require('/tmp/gtfs-rt/node_modules/protobufjs');
const FEED_URL = 'https://data.texas.gov/download/i5qp-g5fd/application/octet-stream';
const ROUTE_ID = '5';
const FIRST_STOP = '5854'; // Anderson/Northcross
const USER_STOP = '964'; // Woodrow/Choquette
const TRAVEL_SECS = 446; // 7 min 26 sec from first stop to user stop
// GTFS-RT proto definition (minimal, inline)
const PROTO = `
syntax = "proto2";
package transit_realtime;
message FeedMessage {
required FeedHeader header = 1;
repeated FeedEntity entity = 2;
}
message FeedHeader {
required string gtfs_realtime_version = 1;
optional uint64 timestamp = 2;
}
message FeedEntity {
required string id = 1;
optional TripUpdate trip_update = 3;
optional VehiclePosition vehicle = 4;
optional Alert alert = 5;
}
message TripUpdate {
optional TripDescriptor trip = 1;
optional VehicleDescriptor vehicle = 3;
repeated StopTimeUpdate stop_time_update = 2;
optional uint64 timestamp = 4;
message StopTimeUpdate {
optional uint32 stop_sequence = 1;
optional string stop_id = 4;
optional StopTimeEvent arrival = 2;
optional StopTimeEvent departure = 3;
enum ScheduleRelationship { SCHEDULED = 0; SKIPPED = 1; NO_DATA = 2; }
optional ScheduleRelationship schedule_relationship = 5;
}
}
message StopTimeEvent {
optional int32 delay = 1;
optional int64 time = 2;
optional int32 uncertainty = 3;
}
message VehiclePosition {
optional TripDescriptor trip = 1;
optional VehicleDescriptor vehicle = 8;
optional Position position = 2;
optional uint32 current_stop_sequence = 3;
optional string stop_id = 7;
enum VehicleStopStatus { INCOMING_AT = 0; STOPPED_AT = 1; IN_TRANSIT_TO = 2; }
optional VehicleStopStatus current_status = 4;
optional uint64 timestamp = 5;
enum CongestionLevel { UNKNOWN = 0; RUNNING_SMOOTHLY = 1; STOP_AND_GO = 2; CONGESTION = 3; SEVERE_CONGESTION = 4; }
optional CongestionLevel congestion_level = 6;
enum OccupancyStatus { EMPTY = 0; MANY_SEATS = 1; FEW_SEATS = 2; STANDING_ROOM = 3; CRUSHED = 4; FULL = 5; NOT_ACCEPTING = 6; }
optional OccupancyStatus occupancy_status = 9;
}
message TripDescriptor {
optional string trip_id = 1;
optional string route_id = 5;
optional uint32 direction_id = 6;
optional string start_time = 2;
optional string start_date = 3;
enum ScheduleRelationship { SCHEDULED = 0; ADDED = 1; UNSCHEDULED = 2; CANCELED = 3; }
optional ScheduleRelationship schedule_relationship = 4;
}
message VehicleDescriptor {
optional string id = 1;
optional string label = 2;
optional string license_plate = 3;
}
message Position {
required float latitude = 1;
required float longitude = 2;
optional float bearing = 3;
optional double odometer = 4;
optional float speed = 5;
}
message Alert {
repeated TimeRange active_period = 1;
repeated EntitySelector informed_entity = 5;
optional TranslatedString header_text = 10;
optional TranslatedString description_text = 11;
}
message TimeRange { optional uint64 start = 1; optional uint64 end = 2; }
message EntitySelector { optional string agency_id = 1; optional string route_id = 3; optional TripDescriptor trip = 4; optional string stop_id = 6; }
message TranslatedString { repeated Translation translation = 1; message Translation { required string text = 1; optional string language = 2; } }
`;
function fetch(url) {
return new Promise((resolve, reject) => {
https.get(url, (res) => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => resolve(Buffer.concat(chunks)));
res.on('error', reject);
}).on('error', reject);
});
}
async function main() {
const root = protobuf.parse(PROTO, { keepCase: true }).root;
const FeedMessage = root.lookupType('transit_realtime.FeedMessage');
const buf = await fetch(FEED_URL);
const feed = FeedMessage.decode(buf);
const now = Math.floor(Date.now() / 1000);
const cst = new Date((now - 6*3600) * 1000); // CST offset
const timeStr = cst.toISOString().replace('T', ' ').substring(0, 19) + ' CST';
// Filter Route 5 vehicles
const vehicles = feed.entity
.filter(e => e.vehicle && e.vehicle.trip && e.vehicle.trip.route_id === ROUTE_ID)
.map(e => {
const v = e.vehicle;
const age = now - (v.timestamp?.low || v.timestamp || 0);
const status = ['INCOMING_AT', 'STOPPED_AT', 'IN_TRANSIT_TO'][v.current_status] || 'UNKNOWN';
return {
vehicleId: v.vehicle?.label || v.vehicle?.id || 'unknown',
tripId: v.trip?.trip_id,
directionId: v.trip?.direction_id,
stopId: v.stop_id,
stopSequence: v.current_stop_sequence,
status,
lat: v.position?.latitude,
lon: v.position?.longitude,
speed: v.position?.speed,
bearing: v.position?.bearing,
ageSec: age,
timestamp: v.timestamp
};
});
// Filter Route 5 trip updates
const tripUpdates = feed.entity
.filter(e => e.trip_update && e.trip_update.trip && e.trip_update.trip.route_id === ROUTE_ID)
.map(e => {
const tu = e.trip_update;
const userStopUpdate = tu.stop_time_update?.find(s => s.stop_id === USER_STOP);
const firstStopUpdate = tu.stop_time_update?.find(s => s.stop_id === FIRST_STOP);
return {
tripId: tu.trip?.trip_id,
directionId: tu.trip?.direction_id,
vehicleId: tu.vehicle?.label || tu.vehicle?.id,
userStopDelay: userStopUpdate?.departure?.delay || userStopUpdate?.arrival?.delay || null,
userStopTime: userStopUpdate?.arrival?.time || userStopUpdate?.departure?.time || null,
firstStopDelay: firstStopUpdate?.departure?.delay || null,
firstStopTime: firstStopUpdate?.departure?.time || null,
totalStops: tu.stop_time_update?.length || 0
};
});
// Eastbound (direction 0) only
const ebVehicles = vehicles.filter(v => v.directionId === 0);
const ebUpdates = tripUpdates.filter(t => t.directionId === 0);
const result = {
timestamp: timeStr,
feedTimestamp: feed.header?.timestamp?.toString(),
route5_eastbound: {
activeVehicles: ebVehicles.length,
vehicles: ebVehicles,
tripUpdates: ebUpdates.filter(t => t.userStopDelay !== null || t.userStopTime !== null)
},
route5_all: {
totalVehicles: vehicles.length,
totalTripUpdates: tripUpdates.length
}
};
console.log(JSON.stringify(result, null, 2));
}
main().catch(e => console.error(JSON.stringify({ error: e.message })));

View File

@@ -0,0 +1,89 @@
#!/bin/bash
# Route 5 smart monitor — determines direction by time of day, launches background watcher
# Usage: bash monitor.sh [channel_id]
# Before 11 AM CST → Eastbound (morning commute)
# After 11 AM CST → Westbound (evening commute)
set -eo pipefail
CHANNEL="${1:-1467247377743347953}"
TU_URL="https://data.texas.gov/download/mqtr-wwpy/application%2Fjson"
STATE_DIR="/home/node/.openclaw/workspace/skills/capmetro-monitor/memory"
mkdir -p "$STATE_DIR"
NOW=$(date +%s)
CST_HOUR=$(TZ=America/Chicago date +%H)
if [ "$CST_HOUR" -lt 11 ]; then
DIRECTION=0
DIR_NAME="Eastbound"
FIRST_STOP="5854"
FIRST_STOP_NAME="Anderson/Northcross"
USER_STOP="964"
USER_STOP_NAME="Woodrow/Choquette"
TRAVEL_FIRST_TO_USER=446 # 7m26s
WALK_LEAD=0 # already near stop
else
DIRECTION=1
DIR_NAME="Westbound"
FIRST_STOP="4606"
FIRST_STOP_NAME="Tannehill/Webberville"
USER_STOP="5499"
USER_STOP_NAME="6th/West"
TRAVEL_FIRST_TO_USER=2384 # 39m44s
WALK_LEAD=900 # 15 min walk from office
HOME_STOP="1072"
HOME_STOP_NAME="Woodrow/Dwyce"
TRAVEL_USER_TO_HOME=1344 # 22m24s
fi
# Find next departure from first stop
TU=$(curl -sL --max-time 10 "$TU_URL" 2>/dev/null)
NEXT=$(echo "$TU" | jq --arg dir "$DIRECTION" --arg fs "$FIRST_STOP" --arg now "$NOW" '
[.entity[] |
select(.tripUpdate.trip.routeId == "5" and (.tripUpdate.trip.directionId | tostring) == $dir) |
{
tripId: .tripUpdate.trip.tripId,
depart: ([.tripUpdate.stopTimeUpdate[] | select(.stopId == $fs) | (.departure.time // .arrival.time)] | .[0])
}
] | [.[] | select(.depart != null and (.depart | tonumber) > ($now | tonumber))]
| sort_by(.depart | tonumber) | .[0]' 2>/dev/null)
TRIP_ID=$(echo "$NEXT" | jq -r '.tripId // empty')
DEPART_TS=$(echo "$NEXT" | jq -r '.depart // empty')
if [ -z "$TRIP_ID" ] || [ -z "$DEPART_TS" ]; then
echo '{"ok":false,"error":"No upcoming Route 5 departures found"}'
exit 1
fi
DEPART_CST=$(TZ=America/Chicago date -d "@$DEPART_TS" +"%I:%M %p" 2>/dev/null)
MINS_AWAY=$(( (DEPART_TS - NOW) / 60 ))
# Export config for the watcher
export DIRECTION DIR_NAME FIRST_STOP FIRST_STOP_NAME USER_STOP USER_STOP_NAME
export TRAVEL_FIRST_TO_USER WALK_LEAD TRIP_ID DEPART_TS CHANNEL
export HOME_STOP HOME_STOP_NAME TRAVEL_USER_TO_HOME
echo "{\"ok\":true,\"direction\":\"$DIR_NAME\",\"tripId\":\"$TRIP_ID\",\"firstStopDepart\":\"$DEPART_CST\",\"minsUntilDepart\":$MINS_AWAY,\"userStop\":\"$USER_STOP_NAME\"}"
# Kill any existing watcher
PID_FILE="$STATE_DIR/watcher.pid"
if [ -f "$PID_FILE" ]; then
OLD_PID=$(cat "$PID_FILE" 2>/dev/null)
if [ -n "$OLD_PID" ] && kill -0 "$OLD_PID" 2>/dev/null; then
kill "$OLD_PID" 2>/dev/null || true
fi
rm -f "$PID_FILE"
fi
# Dynamic timeout: time until departure + 5 min buffer for delays
WAIT_SECS=$(( DEPART_TS - NOW + 300 ))
[ "$WAIT_SECS" -lt 900 ] && WAIT_SECS=900 # minimum 15 min
# Calculate MAX_POLLS for the watcher (poll every 20s)
export MAX_POLLS=$(( WAIT_SECS / 20 ))
SCRIPT_DIR="$(dirname "$0")"
nohup timeout "$WAIT_SECS" bash "$SCRIPT_DIR/watch-departure-v2.sh" > /dev/null 2>&1 &
echo $! > "$PID_FILE"

View File

@@ -0,0 +1,58 @@
#!/bin/bash
# Route 5 on-demand monitor — checks real-time bus status
# Usage: bash route5-status.sh
set -eo pipefail
VP_URL="https://data.texas.gov/download/cuc7-ywmd/application%2Fjson"
TU_URL="https://data.texas.gov/download/mqtr-wwpy/application%2Fjson"
# Fetch both feeds in parallel
VP=$(curl -sL --max-time 10 "$VP_URL") &
TU=$(curl -sL --max-time 10 "$TU_URL") &
VP=$(curl -sL --max-time 10 "$VP_URL")
TU=$(curl -sL --max-time 10 "$TU_URL")
NOW=$(date +%s)
# Route 5 eastbound vehicles
VEHICLES=$(echo "$VP" | jq -c '[.entity[] | select(.vehicle.trip.routeId == "5" and .vehicle.trip.directionId == 0) | {
vehicleId: .vehicle.vehicle.label,
tripId: .vehicle.trip.tripId,
stopId: .vehicle.stopId,
status: .vehicle.currentStatus,
lat: .vehicle.position.latitude,
lon: .vehicle.position.longitude,
speed: .vehicle.position.speed
}]')
# Route 5 eastbound trip updates
TRIPS=$(echo "$TU" | jq -c --arg now "$NOW" '[.entity[] | select(.tripUpdate.trip.routeId == "5" and .tripUpdate.trip.directionId == 0) | {
tripId: .tripUpdate.trip.tripId,
firstStopDepart: ([.tripUpdate.stopTimeUpdate[] | select(.stopId == "5854") | (.departure.time // .arrival.time)] | .[0]),
userStopArrive: ([.tripUpdate.stopTimeUpdate[] | select(.stopId == "964") | (.arrival.time // .departure.time)] | .[0]),
userStopDelay: ([.tripUpdate.stopTimeUpdate[] | select(.stopId == "964") | (.arrival.delay // .departure.delay)] | .[0])
}] | [.[] | select(.firstStopDepart != null)] | sort_by(.firstStopDepart)')
# Format output
jq -n \
--argjson vehicles "$VEHICLES" \
--argjson trips "$TRIPS" \
--arg now "$NOW" '{
timestampUTC: ($now | tonumber | todate),
timestampCST: (($now | tonumber - 21600) | todate),
route: "5 - Woodrow/Lamar",
direction: "Eastbound → Downtown",
firstStop: "Anderson/Northcross (5854)",
userStop: "Woodrow/Choquette (964)",
scheduledTravel: "7m 26s",
activeVehicles: ($vehicles | length),
vehicles: $vehicles,
nextDepartures: [$trips[] | {
tripId,
firstStopDepart: (if .firstStopDepart then (.firstStopDepart | tonumber | todate) else null end),
userStopArrive: (if .userStopArrive then (.userStopArrive | tonumber | todate) else null end),
delaySec: .userStopDelay,
minsUntilDepart: (if .firstStopDepart then (((.firstStopDepart | tonumber) - ($now | tonumber)) / 60 | floor) else null end),
minsUntilArrive: (if .userStopArrive then (((.userStopArrive | tonumber) - ($now | tonumber)) / 60 | floor) else null end)
}]
}'

View File

@@ -0,0 +1,84 @@
#!/bin/bash
# Route 5 departure watcher v2 — direction-aware, runs in background
# Called by monitor.sh with env vars set
set -eo pipefail
VP_URL="https://data.texas.gov/download/cuc7-ywmd/application%2Fjson"
TOKEN=$(printenv DISCORD_BOT_TOKEN)
MAX_POLLS=${MAX_POLLS:-40}
POLL_INTERVAL=20
PID_FILE="/home/node/.openclaw/workspace/skills/capmetro-monitor/memory/watcher.pid"
# Clean up PID file on exit (success, timeout, or signal)
cleanup() { rm -f "$PID_FILE" 2>/dev/null; }
trap cleanup EXIT INT TERM
for i in $(seq 1 $MAX_POLLS); do
VP=$(curl -sL --max-time 8 "$VP_URL" 2>/dev/null)
VEHICLE=$(echo "$VP" | jq -c ".entity[] | select(.vehicle.trip.tripId == \"$TRIP_ID\")" 2>/dev/null)
if [ -z "$VEHICLE" ]; then
sleep $POLL_INTERVAL
continue
fi
STOP_ID=$(echo "$VEHICLE" | jq -r '.vehicle.stopId')
STATUS=$(echo "$VEHICLE" | jq -r '.vehicle.currentStatus')
VEH_ID=$(echo "$VEHICLE" | jq -r '.vehicle.vehicle.label')
# Bus has left the first stop
if [ "$STOP_ID" != "$FIRST_STOP" ] || { [ "$STATUS" = "IN_TRANSIT_TO" ] && [ "$STOP_ID" != "$FIRST_STOP" ]; }; then
ACTUAL_TS=$(date +%s)
ACTUAL_CST=$(TZ=America/Chicago date +"%I:%M %p")
DELAY=$((ACTUAL_TS - DEPART_TS))
DELAY_MIN=$((DELAY / 60))
if [ "$DELAY_MIN" -le 0 ]; then
STATUS_ICON="🟢"
STATUS_TEXT="On time"
elif [ "$DELAY_MIN" -le 2 ]; then
STATUS_ICON="🟡"
STATUS_TEXT="~${DELAY_MIN}min late"
else
STATUS_ICON="🔴"
STATUS_TEXT="${DELAY_MIN}min late"
fi
# Calculate ETAs
ETA_USER=$((ACTUAL_TS + TRAVEL_FIRST_TO_USER))
ETA_USER_CST=$(TZ=America/Chicago date -d "@$ETA_USER" +"%I:%M %p")
if [ "$DIRECTION" = "0" ]; then
# EASTBOUND: simple alert
MSG=$(printf '🚌 **Route 5 %s Departed!**\nBus %s left %s at %s\n%s %s\n📍 ETA at %s: **%s**' \
"$DIR_NAME" "$VEH_ID" "$FIRST_STOP_NAME" "$ACTUAL_CST" \
"$STATUS_ICON" "$STATUS_TEXT" "$USER_STOP_NAME" "$ETA_USER_CST")
else
# WESTBOUND: include leave-office time and home ETA
LEAVE_TS=$((ETA_USER - WALK_LEAD))
LEAVE_CST=$(TZ=America/Chicago date -d "@$LEAVE_TS" +"%I:%M %p")
ETA_HOME=$((ACTUAL_TS + TRAVEL_FIRST_TO_USER + TRAVEL_USER_TO_HOME))
ETA_HOME_CST=$(TZ=America/Chicago date -d "@$ETA_HOME" +"%I:%M %p")
MSG=$(printf '🚌 **Route 5 %s Departed!**\nBus %s left %s at %s\n%s %s\n\n🚶 **Leave office by %s** (15 min walk)\n📍 Bus arrives %s: **%s**\n🏠 Home (%s): **%s**' \
"$DIR_NAME" "$VEH_ID" "$FIRST_STOP_NAME" "$ACTUAL_CST" \
"$STATUS_ICON" "$STATUS_TEXT" \
"$LEAVE_CST" "$USER_STOP_NAME" "$ETA_USER_CST" \
"$HOME_STOP_NAME" "$ETA_HOME_CST")
fi
curl -s -X POST -H "Authorization: Bot $TOKEN" -H "Content-Type: application/json" \
-d "$(jq -n --arg c "$MSG" '{content: $c}')" \
"https://discord.com/api/v10/channels/$CHANNEL/messages" > /dev/null
exit 0
fi
sleep $POLL_INTERVAL
done
# Timeout
SCHED_CST=$(TZ=America/Chicago date -d "@$DEPART_TS" +"%I:%M %p" 2>/dev/null)
curl -s -X POST -H "Authorization: Bot $TOKEN" -H "Content-Type: application/json" \
-d "{\"content\":\"⚠️ Route 5 $DIR_NAME watcher timed out — could not confirm $SCHED_CST departure.\"}" \
"https://discord.com/api/v10/channels/$CHANNEL/messages" > /dev/null

View File

@@ -0,0 +1,103 @@
#!/bin/bash
# Route 5 departure watcher — runs in background, posts to Discord when bus departs
# Usage: bash watch-departure.sh <scheduled_time_UTC> [channel_id]
# Example: bash watch-departure.sh "2026-02-12T13:30:00Z" 1467247377743347953
set -eo pipefail
SCHED_DEPART="$1"
CHANNEL="${2:-1467247377743347953}" # Default: DM channel
VP_URL="https://data.texas.gov/download/cuc7-ywmd/application%2Fjson"
FIRST_STOP="5854"
USER_STOP="964"
TOKEN=$(printenv DISCORD_BOT_TOKEN)
MAX_POLLS=40 # ~13 minutes max watch time
POLL_INTERVAL=20 # seconds between polls
# Find the trip matching this scheduled departure
find_trip() {
local TU=$(curl -sL --max-time 10 "https://data.texas.gov/download/mqtr-wwpy/application%2Fjson" 2>/dev/null)
echo "$TU" | jq -r --arg sched "$SCHED_DEPART" '.entity[] |
select(.tripUpdate.trip.routeId == "5" and .tripUpdate.trip.directionId == 0) |
select([.tripUpdate.stopTimeUpdate[] | select(.stopId == "5854") |
((.departure.time // .arrival.time) | tonumber)] | .[0] == ($sched | sub("Z$";"") | strptime("%Y-%m-%dT%H:%M:%S") | mktime)) |
.tripUpdate.trip.tripId' 2>/dev/null | head -1
}
# Convert ISO to epoch
sched_epoch() {
date -d "$SCHED_DEPART" +%s 2>/dev/null || date -u -d "${SCHED_DEPART%Z}" +%s 2>/dev/null
}
SCHED_TS=$(sched_epoch)
SCHED_CST=$(TZ=America/Chicago date -d "@$SCHED_TS" +"%I:%M %p" 2>/dev/null)
# Find the trip ID
TRIP_ID=$(find_trip)
if [ -z "$TRIP_ID" ]; then
# Fallback: find closest eastbound trip
TU=$(curl -sL --max-time 10 "https://data.texas.gov/download/mqtr-wwpy/application%2Fjson" 2>/dev/null)
TRIP_ID=$(echo "$TU" | jq -r --arg ts "$SCHED_TS" '[.entity[] |
select(.tripUpdate.trip.routeId == "5" and .tripUpdate.trip.directionId == 0) |
{tripId: .tripUpdate.trip.tripId, depart: ([.tripUpdate.stopTimeUpdate[] | select(.stopId == "5854") | (.departure.time // .arrival.time)] | .[0] | tonumber)}] |
sort_by((. .depart - ($ts | tonumber)) | fabs) | .[0].tripId' 2>/dev/null)
fi
if [ -z "$TRIP_ID" ]; then
# Post error
curl -s -X POST -H "Authorization: Bot $TOKEN" -H "Content-Type: application/json" \
-d "{\"content\":\"⚠️ Could not find Route 5 trip for $SCHED_CST departure.\"}" \
"https://discord.com/api/v10/channels/$CHANNEL/messages" > /dev/null
exit 1
fi
# Poll until departure
for i in $(seq 1 $MAX_POLLS); do
VP=$(curl -sL --max-time 8 "https://data.texas.gov/download/cuc7-ywmd/application%2Fjson" 2>/dev/null)
VEHICLE=$(echo "$VP" | jq -c ".entity[] | select(.vehicle.trip.tripId == \"$TRIP_ID\")" 2>/dev/null)
if [ -z "$VEHICLE" ]; then
sleep $POLL_INTERVAL
continue
fi
STOP_ID=$(echo "$VEHICLE" | jq -r '.vehicle.stopId')
STATUS=$(echo "$VEHICLE" | jq -r '.vehicle.currentStatus')
SPEED=$(echo "$VEHICLE" | jq -r '.vehicle.position.speed')
VEH_ID=$(echo "$VEHICLE" | jq -r '.vehicle.vehicle.label')
# Bus has left the first stop
if [ "$STOP_ID" != "$FIRST_STOP" ] || ([ "$STATUS" = "IN_TRANSIT_TO" ] && [ "$STOP_ID" != "$FIRST_STOP" ]); then
DEPART_TS=$(date +%s)
DEPART_CST=$(TZ=America/Chicago date +"%I:%M:%S %p")
DELAY=$((DEPART_TS - SCHED_TS))
DELAY_MIN=$((DELAY / 60))
# Calculate ETA at user stop (7m26s from first stop)
ETA_TS=$((DEPART_TS + 446))
ETA_CST=$(TZ=America/Chicago date -d "@$ETA_TS" +"%I:%M %p" 2>/dev/null)
if [ "$DELAY_MIN" -le 0 ]; then
STATUS_MSG="🟢 On time"
elif [ "$DELAY_MIN" -le 2 ]; then
STATUS_MSG="🟡 ~${DELAY_MIN}min late"
else
STATUS_MSG="🔴 ${DELAY_MIN}min late"
fi
MSG="🚌 **Route 5 Departed!**\nBus ${VEH_ID} left Anderson/Northcross at ${DEPART_CST}\nScheduled: ${SCHED_CST} | ${STATUS_MSG}\n📍 ETA at Woodrow/Choquette: **${ETA_CST}**"
CONTENT=$(printf "$MSG")
curl -s -X POST -H "Authorization: Bot $TOKEN" -H "Content-Type: application/json" \
-d "$(jq -n --arg c "$CONTENT" '{content: $c}')" \
"https://discord.com/api/v10/channels/$CHANNEL/messages" > /dev/null
exit 0
fi
sleep $POLL_INTERVAL
done
# Timeout — bus never departed (or we missed it)
curl -s -X POST -H "Authorization: Bot $TOKEN" -H "Content-Type: application/json" \
-d "{\"content\":\"⚠️ Route 5 watcher timed out — could not confirm $SCHED_CST departure from Anderson/Northcross.\"}" \
"https://discord.com/api/v10/channels/$CHANNEL/messages" > /dev/null

View File

@@ -0,0 +1,127 @@
---
name: github-notifications
description: Check GitHub notifications for PR activity and major releases. Filters for PRs where user is mentioned/author, and major releases (v*.0.0) plus all Mirrowel/LLM-API-Key-Proxy dev builds. Tracks state to avoid duplicate alerts. Use for periodic GitHub notification checking via cron jobs or manual checks.
---
# GitHub Notifications Checker
Efficiently check GitHub notifications with smart filtering and state tracking.
## What It Does
1. **Fetches notifications** via GitHub CLI (`gh api`)
2. **Filters intelligently:**
- PRs where you're mentioned, author, or review requested
- Major releases (v*.0.0 format)
- ALL releases from `Mirrowel/LLM-API-Key-Proxy` (including dev builds)
- Excludes: rc/pre/beta/alpha/nightly releases
3. **Tracks state** to avoid duplicate notifications
4. **Returns JSON** for easy parsing
## Usage
### Basic Check
```bash
bash skills/github-notifications/scripts/check.sh
```
**Output when nothing new:**
```json
{"hasNew":false}
```
**Output with new activity:**
```json
{
"hasNew": true,
"newPRs": [
{
"repo": "openclaw/openclaw",
"title": "feat: Add cron silent mode",
"url": "https://api.github.com/repos/openclaw/openclaw/pulls/1234",
"updated": "2026-02-03T14:30:00Z",
"reason": "mention",
"id": "openclaw/openclaw#feat: Add cron silent mode"
}
],
"newReleases": [
{
"repo": "some/repo",
"title": "v2.0.0",
"updated": "2026-02-03T12:00:00Z",
"id": "some/repo@v2.0.0"
}
]
}
```
### Environment Variables
- `STATE_FILE` - Path to state tracking file (default: `memory/github-check-state.json`)
- `WORKSPACE` - Workspace directory (default: `/home/node/.openclaw/workspace`)
### State Tracking
State is stored in `memory/github-check-state.json`:
```json
{
"lastCheck": "2026-02-03T14:00:00Z",
"seenPRs": ["repo#PR Title", ...],
"seenReleases": ["repo@v1.0.0", ...]
}
```
## Integration with Cron
This skill is designed to work with OpenClaw cron jobs. The script handles all filtering and state management, only calling the LLM when there's actual content to summarize.
**Recommended cron setup:**
1. Script runs periodically (every 4 hours)
2. If `hasNew: false`, script exits silently - no LLM call, no message
3. If `hasNew: true`, cron job can format the summary and deliver it
This approach:
- ✅ Saves tokens (no LLM call when nothing new)
- ✅ Handles errors gracefully (GitHub API failures logged)
- ✅ Avoids duplicate notifications (state tracking)
- ✅ Faster execution (no LLM parsing)
## Error Handling
If GitHub API fails, returns:
```json
{
"error": "GitHub API failed",
"details": "..."
}
```
Check for `.error` field in output to detect failures.
## Auto-Dismiss Low-Value Notifications
```bash
# Dry run (see what would be dismissed)
DRY_RUN=true bash skills/github-notifications/scripts/auto-dismiss.sh
# Actually dismiss
bash skills/github-notifications/scripts/auto-dismiss.sh
```
**Auto-dismisses:**
- Title matches: nightly, preview, checkpoint, pre-release, canary, alpha, beta, snapshot
- Releases with empty release notes
**Output:**
```json
{"dismissed":3,"checked":12}
```
## Requirements
- `gh` CLI authenticated
- `jq` for JSON parsing
- GitHub token with `notifications` scope

View File

@@ -0,0 +1,74 @@
#!/bin/bash
# Auto-dismiss GitHub notifications matching certain patterns
# - Nightlies, previews, checkpoints, rc, etc (by title pattern)
# - Releases with no release notes
# Exempts specified repos from auto-dismiss
#
# Dismiss = PATCH (read) + DELETE thread + DELETE subscription
set -e
DRY_RUN="${DRY_RUN:-false}"
# Repos exempt from auto-dismiss (always show these)
EXEMPT_REPOS="Mirrowel/LLM-API-Key-Proxy|b3nw/LLM-API-Key-Proxy|pedramamini/Maestro"
# Patterns to auto-dismiss (case-insensitive)
DISMISS_PATTERNS="nightly|preview|checkpoint|pre-release|canary|alpha|beta|snapshot|-rc\.|rc[0-9]"
# Get all unread notifications
NOTIFICATIONS=$(gh api /notifications 2>/dev/null || echo "[]")
if [ "$NOTIFICATIONS" = "[]" ] || [ -z "$NOTIFICATIONS" ]; then
echo '{"dismissed":0,"checked":0}'
exit 0
fi
DISMISSED=0
TOTAL=$(echo "$NOTIFICATIONS" | jq 'length')
echo "$NOTIFICATIONS" | jq -c '.[]' | while read -r notif; do
ID=$(echo "$notif" | jq -r '.id')
TITLE=$(echo "$notif" | jq -r '.subject.title')
TYPE=$(echo "$notif" | jq -r '.subject.type')
URL=$(echo "$notif" | jq -r '.subject.url')
REPO=$(echo "$notif" | jq -r '.repository.full_name')
# Skip exempt repos
if echo "$REPO" | grep -qiE "$EXEMPT_REPOS"; then
continue
fi
SHOULD_DISMISS=false
REASON=""
# Check title patterns
if echo "$TITLE" | grep -qiE "$DISMISS_PATTERNS"; then
SHOULD_DISMISS=true
REASON="title_pattern"
fi
# Check releases with no notes
if [ "$TYPE" = "Release" ] && [ "$SHOULD_DISMISS" = "false" ]; then
RELEASE_BODY=$(gh api "$URL" --jq '.body // ""' 2>/dev/null || echo "")
if [ -z "$RELEASE_BODY" ] || [ "$RELEASE_BODY" = "null" ]; then
SHOULD_DISMISS=true
REASON="empty_release_notes"
fi
fi
if [ "$SHOULD_DISMISS" = "true" ]; then
if [ "$DRY_RUN" = "true" ]; then
echo "Would dismiss: [$REPO] $TITLE ($REASON)" >&2
else
# Full dismiss: mark read + delete thread + delete subscription
gh api -X PATCH "/notifications/threads/$ID" 2>/dev/null || true
gh api -X DELETE "/notifications/threads/$ID" 2>/dev/null || true
gh api -X DELETE "/notifications/threads/$ID/subscription" 2>/dev/null || true
echo "Dismissed: [$REPO] $TITLE ($REASON)" >&2
fi
DISMISSED=$((DISMISSED + 1))
fi
done
echo "{\"dismissed\":$DISMISSED,\"checked\":$TOTAL}"

View File

@@ -0,0 +1,104 @@
#!/bin/bash
# GitHub Notifications Checker
# Filters PRs and releases, tracks state, returns JSON summary
set -e
STATE_FILE="${STATE_FILE:-memory/github-check-state.json}"
WORKSPACE="${WORKSPACE:-/home/node/.openclaw/workspace}"
cd "$WORKSPACE"
# Initialize state file if missing
if [ ! -f "$STATE_FILE" ]; then
mkdir -p "$(dirname "$STATE_FILE")"
echo '{"lastCheck":"1970-01-01T00:00:00Z","seenPRs":[],"seenReleases":[]}' > "$STATE_FILE"
fi
# Load last check time
LAST_CHECK=$(jq -r '.lastCheck' "$STATE_FILE")
NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# Fetch notifications (PRs only)
PR_DATA=$(gh api 'notifications?all=true&per_page=100' 2>&1)
if [ $? -ne 0 ]; then
echo '{"error":"GitHub API failed","details":"'"${PR_DATA//\"/\\\"}"'"}' | jq .
exit 1
fi
# Filter PRs where user is mentioned or author
FILTERED_PRS=$(echo "$PR_DATA" | jq -r '[
.[] |
select(.subject.type == "PullRequest") |
select(.reason == "mention" or .reason == "author" or .reason == "review_requested") |
{
repo: .repository.full_name,
title: .subject.title,
url: .subject.url,
updated: .updated_at,
reason: .reason,
id: (.repository.full_name + "#" + .subject.title)
}
]')
# Filter releases
RELEASE_DATA=$(echo "$PR_DATA" | jq -r '[
.[] |
select(.subject.type == "Release") |
{
repo: .repository.full_name,
title: .subject.title,
updated: .updated_at,
id: (.repository.full_name + "@" + .subject.title)
}
]')
# Filter major releases (v*.0.0) + ALL Mirrowel/LLM-API-Key-Proxy releases
FILTERED_RELEASES=$(echo "$RELEASE_DATA" | jq -r '[
.[] |
select(
(.repo == "Mirrowel/LLM-API-Key-Proxy") or
(.title | test("^v[0-9]+\\.0\\.0"))
) |
select(.title | test("(rc|pre|beta|alpha|nightly)") | not)
]')
# Load seen items
SEEN_PRS=$(jq -r '.seenPRs // []' "$STATE_FILE")
SEEN_RELEASES=$(jq -r '.seenReleases // []' "$STATE_FILE")
# Find new items
NEW_PRS=$(echo "$FILTERED_PRS" | jq --argjson seen "$SEEN_PRS" '[
.[] | select(.id as $id | $seen | index($id) | not)
]')
NEW_RELEASES=$(echo "$FILTERED_RELEASES" | jq --argjson seen "$SEEN_RELEASES" '[
.[] | select(.id as $id | $seen | index($id) | not)
]')
# Count new items
NEW_PR_COUNT=$(echo "$NEW_PRS" | jq 'length')
NEW_RELEASE_COUNT=$(echo "$NEW_RELEASES" | jq 'length')
# Update state
ALL_PR_IDS=$(echo "$FILTERED_PRS" | jq -r '[.[].id]')
ALL_RELEASE_IDS=$(echo "$FILTERED_RELEASES" | jq -r '[.[].id]')
jq -n \
--arg now "$NOW" \
--argjson prIds "$ALL_PR_IDS" \
--argjson relIds "$ALL_RELEASE_IDS" \
'{lastCheck:$now, seenPRs:$prIds, seenReleases:$relIds}' \
> "$STATE_FILE"
# Output result
if [ "$NEW_PR_COUNT" -eq 0 ] && [ "$NEW_RELEASE_COUNT" -eq 0 ]; then
echo '{"hasNew":false}'
exit 0
fi
# Return new items
jq -n \
--argjson prs "$NEW_PRS" \
--argjson releases "$NEW_RELEASES" \
'{hasNew:true, newPRs:$prs, newReleases:$releases}'

View File

@@ -0,0 +1,55 @@
#!/bin/bash
# Cron wrapper for GitHub notifications
# 1. Auto-dismisses low-value notifications (nightlies, previews, empty releases)
# 2. Checks remaining notifications and formats for human consumption
set -e
WORKSPACE="${WORKSPACE:-/home/node/.openclaw/workspace}"
cd "$WORKSPACE"
# First: auto-dismiss low-value notifications
bash skills/github-notifications/scripts/auto-dismiss.sh >/dev/null 2>&1 || true
# Then: run the checker
RESULT=$(bash skills/github-notifications/scripts/check.sh)
# Check for errors
if echo "$RESULT" | jq -e '.error' > /dev/null 2>&1; then
ERROR_MSG=$(echo "$RESULT" | jq -r '.error')
ERROR_DETAILS=$(echo "$RESULT" | jq -r '.details')
echo "❌ **GitHub Check Failed**"
echo ""
echo "Error: $ERROR_MSG"
echo "\`\`\`"
echo "$ERROR_DETAILS" | head -20
echo "\`\`\`"
exit 0
fi
# Check if there's new activity
HAS_NEW=$(echo "$RESULT" | jq -r '.hasNew')
if [ "$HAS_NEW" != "true" ]; then
# Nothing new - stay completely silent (no output = no message)
exit 0
fi
# Format and output the summary
echo "🔔 **GitHub Activity Update**"
echo ""
# Process PRs
PR_COUNT=$(echo "$RESULT" | jq '.newPRs | length')
if [ "$PR_COUNT" -gt 0 ]; then
echo "**Pull Requests ($PR_COUNT new):**"
echo "$RESULT" | jq -r '.newPRs[] | "- **\(.repo)** #\(.title)\n Updated: \(.updated) | Reason: \(.reason)"'
echo ""
fi
# Process Releases
RELEASE_COUNT=$(echo "$RESULT" | jq '.newReleases | length')
if [ "$RELEASE_COUNT" -gt 0 ]; then
echo "**Releases ($RELEASE_COUNT new):**"
echo "$RESULT" | jq -r '.newReleases[] | "- **\(.repo)** `\(.title)`\n Released: \(.updated)"'
fi

View File

@@ -0,0 +1,86 @@
---
name: model-selector
description: Safely change an agent's primary and fallback models by validating IDs against the live LLM proxy model list. Use for model switches, fallback chain updates, and model-availability troubleshooting.
---
# Model Selector
## Core Rules
1. Validate model IDs against `/v1/models` before proposing changes.
2. Keep at least 2 fallback models.
3. Do not remove a primary model without setting a replacement.
4. Use exact IDs from the model catalog; do not guess.
5. Prefer provider diversity in fallbacks.
6. Get explicit user approval before writing config.
7. Treat `/model` as temporary; it creates per-session overrides.
8. After backend default changes, clear session pins and reset active sessions.
## Workflow
### 1) Fetch Available Models
```bash
bash {baseDir}/scripts/list-models.sh
bash {baseDir}/scripts/list-models.sh --providers
```
### 2) Validate Candidate IDs
```bash
bash {baseDir}/scripts/validate-model.sh "nvidia_nim/moonshotai/kimi-k2.5"
```
### 3) Inspect Current Configuration
```bash
bash {baseDir}/scripts/show-current.sh
```
### 4) Apply Backend Model Changes
```bash
# Primary only
bash {baseDir}/scripts/update-model.sh --primary "nanogpt/moonshotai/kimi-k2.5"
# Fallbacks only
bash {baseDir}/scripts/update-model.sh --fallbacks "nvidia_nim/moonshotai/kimi-k2.5,chutes/zai-org/GLM-5-TEE"
# Primary + fallbacks
bash {baseDir}/scripts/update-model.sh \
--primary "nanogpt/moonshotai/kimi-k2.5" \
--fallbacks "nvidia_nim/moonshotai/kimi-k2.5,chutes/zai-org/GLM-5-TEE"
```
### 5) Required Rollout Sequence (Do Not Skip)
1. Clear per-session model pins so defaults can apply.
2. Restart gateway so in-memory runtime state reloads config.
3. In active channels/threads, run `/reset` (or `/new`) before testing.
Use pin cleanup helper:
```bash
# Clear all session model pins for an agent
bash {baseDir}/scripts/clear-session-model-pins.sh --agent home
# Clear only one channel session family
bash {baseDir}/scripts/clear-session-model-pins.sh --agent home --channel 1470162839284224184
```
## Model ID Format
- Catalog ID format: `<provider>/<model-path>`
- Config reference format: `llm-proxy/<catalog-id>`
Examples:
- `nanogpt/moonshotai/kimi-k2.5` -> `llm-proxy/nanogpt/moonshotai/kimi-k2.5`
- `nvidia_nim/moonshotai/kimi-k2.5` -> `llm-proxy/nvidia_nim/moonshotai/kimi-k2.5`
For `/model` inside a session, use catalog IDs (without `llm-proxy/`).
## Troubleshooting Quick Checks
1. Model missing: rerun `list-models.sh` and validate exact ID.
2. Old model still used: clear session pins + restart gateway + `/reset`.
3. Unexpected fallbacks: confirm fallback chain order in `show-current.sh`.

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
clear-session-model-pins.sh --agent <agent-id> [--channel <channel-id>] [--sessions-file <path>]
Examples:
clear-session-model-pins.sh --agent home
clear-session-model-pins.sh --agent home --channel 1470162839284224184
Notes:
- Removes per-session "model" keys so agent defaults apply again.
- By default targets: /home/node/.openclaw/agents/<agent>/sessions/sessions.json
EOF
}
AGENT_ID=""
CHANNEL_ID=""
SESSIONS_FILE=""
while [[ $# -gt 0 ]]; do
case "$1" in
--agent)
AGENT_ID="${2:-}"
shift 2
;;
--channel)
CHANNEL_ID="${2:-}"
shift 2
;;
--sessions-file)
SESSIONS_FILE="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
if [[ -z "$AGENT_ID" ]]; then
echo "--agent is required" >&2
usage >&2
exit 1
fi
if [[ -z "$SESSIONS_FILE" ]]; then
SESSIONS_FILE="/home/node/.openclaw/agents/${AGENT_ID}/sessions/sessions.json"
fi
if [[ ! -f "$SESSIONS_FILE" ]]; then
echo "sessions file not found: $SESSIONS_FILE" >&2
exit 1
fi
python3 - <<PY
import json
from pathlib import Path
path = Path(${SESSIONS_FILE@Q})
channel = ${CHANNEL_ID@Q}
with path.open() as f:
data = json.load(f)
removed = 0
scanned = 0
for key, value in data.items():
if not isinstance(value, dict):
continue
scanned += 1
if channel:
if f"channel:{channel}" not in key:
continue
if "model" in value:
del value["model"]
removed += 1
with path.open("w") as f:
json.dump(data, f, indent=2)
f.write("\n")
print(f"scanned={scanned}")
print(f"removed_model_pins={removed}")
print(f"sessions_file={path}")
PY

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# list-models.sh — Query the LLM proxy /v1/models endpoint
# Usage:
# list-models.sh # List all model IDs (sorted)
# list-models.sh --providers # List unique provider names
# list-models.sh --json # Raw JSON response
set -euo pipefail
# Resolve proxy URL and API key from environment or defaults
PROXY_URL="${LLM_PROXY_URL:-https://llm-proxy.ext.ben.io/v1}"
PROXY_KEY="${PROXY_API_KEY:-${LLM_PROXY_API_KEY:-}}"
if [[ -z "$PROXY_KEY" ]]; then
# Try to read from openclaw config
for cfg_path in \
"${OPENCLAW_STATE_DIR:-$HOME/.openclaw/state}/openclaw.json" \
"${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw/config}/openclaw.json"; do
if [[ -f "$cfg_path" ]]; then
# Extract apiKey from llm-proxy provider config (handles JSON5 comments)
key=$(grep -A5 '"llm-proxy"' "$cfg_path" | grep '"apiKey"' | head -1 | sed 's/.*"apiKey"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' || true)
if [[ -n "$key" && "$key" != *'${'* ]]; then
PROXY_KEY="$key"
break
fi
fi
done
fi
if [[ -z "$PROXY_KEY" ]]; then
echo "ERROR: No API key found. Set PROXY_API_KEY or LLM_PROXY_API_KEY environment variable." >&2
exit 1
fi
# Strip trailing /v1 from PROXY_URL if present, then always append /v1/models
# This prevents double /v1/v1/ when LLM_PROXY_URL already includes /v1
PROXY_BASE="${PROXY_URL%/v1}"
PROXY_BASE="${PROXY_BASE%/}"
response=$(curl -s -f -H "Authorization: Bearer $PROXY_KEY" "${PROXY_BASE}/v1/models" 2>&1) || {
echo "ERROR: Failed to query ${PROXY_BASE}/v1/models" >&2
echo "$response" >&2
exit 1
}
case "${1:-}" in
--providers)
echo "$response" | python3 -c "
import sys, json
data = json.load(sys.stdin)
providers = sorted(set(m['id'].split('/')[0] for m in data.get('data', [])))
for p in providers:
print(p)
"
;;
--json)
echo "$response"
;;
--count)
echo "$response" | python3 -c "
import sys, json
data = json.load(sys.stdin)
print(len(data.get('data', [])))
"
;;
*)
echo "$response" | python3 -c "
import sys, json
data = json.load(sys.stdin)
models = sorted(m['id'] for m in data.get('data', []))
for m in models:
print(m)
"
;;
esac

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# show-current.sh — Display the current model configuration from openclaw state
# Usage: show-current.sh
set -euo pipefail
# Find the state file
STATE_FILE="${OPENCLAW_STATE_DIR:-$HOME/.openclaw/state}/openclaw.json"
if [[ ! -f "$STATE_FILE" ]]; then
# Try alternative locations
for alt in \
"/opt/openclaw/state/openclaw.json" \
"${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw/config}/openclaw.json"; do
if [[ -f "$alt" ]]; then
STATE_FILE="$alt"
break
fi
done
fi
if [[ ! -f "$STATE_FILE" ]]; then
echo "ERROR: Cannot find openclaw.json state file" >&2
echo "Searched:" >&2
echo " ${OPENCLAW_STATE_DIR:-$HOME/.openclaw/state}/openclaw.json" >&2
echo " /opt/openclaw/state/openclaw.json" >&2
echo " ${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw/config}/openclaw.json" >&2
exit 1
fi
echo "📁 Config file: $STATE_FILE"
echo ""
python3 -c "
import json, sys, re
# Read file and strip JSON5 comments for parsing
with open('$STATE_FILE', 'r') as f:
content = f.read()
# Strip single-line comments (// ...) but not inside strings
lines = content.split('\n')
cleaned = []
for line in lines:
stripped = line.rstrip()
s = stripped.lstrip()
if s.startswith('//'):
continue
in_string = False
result = []
i = 0
while i < len(stripped):
c = stripped[i]
if c == '\"' and (i == 0 or stripped[i-1] != '\\\\'):
in_string = not in_string
elif c == '/' and i + 1 < len(stripped) and stripped[i+1] == '/' and not in_string:
break
result.append(c)
i += 1
cleaned.append(''.join(result))
# Remove trailing commas (JSON5)
json_str = '\n'.join(cleaned)
json_str = re.sub(r',\s*([}\]])', r'\1', json_str)
try:
cfg = json.loads(json_str)
except json.JSONDecodeError:
try:
cfg = json.loads(content)
except json.JSONDecodeError as e:
print(f'ERROR: Failed to parse config: {e}', file=sys.stderr)
sys.exit(1)
agents = cfg.get('agents', {})
defaults = agents.get('defaults', {})
model = defaults.get('model', {})
if isinstance(model, str):
print(f'🎯 Primary: {model}')
print(f'⛓️ Fallbacks: (none configured)')
else:
primary = model.get('primary', '(not set)')
fallbacks = model.get('fallbacks', [])
print(f'🎯 Primary: {primary}')
print(f'⛓️ Fallbacks ({len(fallbacks)}):')
for i, fb in enumerate(fallbacks, 1):
print(f' {i}. {fb}')
# Check for per-agent model overrides
agent_list = agents.get('list', [])
overrides = [(a.get('id', '?'), a.get('model', '')) for a in agent_list if 'model' in a]
if overrides:
print()
print('⚠️ Per-agent model overrides:')
for aid, amodel in overrides:
print(f' {aid}: {amodel}')
" 2>&1

View File

@@ -0,0 +1,216 @@
#!/usr/bin/env bash
# update-model.sh — Update model configuration in openclaw state file
# Usage:
# update-model.sh --primary <model-id>
# update-model.sh --fallbacks <model1,model2,model3>
# update-model.sh --primary <model-id> --fallbacks <model1,model2>
#
# All model IDs are validated against /v1/models before writing.
# A backup of the current config is created before any changes.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PRIMARY=""
FALLBACKS=""
while [[ $# -gt 0 ]]; do
case "$1" in
--primary)
PRIMARY="$2"
shift 2
;;
--fallbacks)
FALLBACKS="$2"
shift 2
;;
--help|-h)
echo "Usage: update-model.sh [--primary <model-id>] [--fallbacks <model1,model2,...>]"
echo ""
echo "Options:"
echo " --primary Set the primary model (will be prefixed with llm-proxy/)"
echo " --fallbacks Comma-separated list of fallback models (min 2 required)"
echo ""
echo "All model IDs are validated against /v1/models before writing."
exit 0
;;
*)
echo "ERROR: Unknown argument: $1" >&2
exit 1
;;
esac
done
if [[ -z "$PRIMARY" && -z "$FALLBACKS" ]]; then
echo "ERROR: Must specify --primary and/or --fallbacks" >&2
exit 1
fi
# Find the state file
STATE_FILE="${OPENCLAW_STATE_DIR:-$HOME/.openclaw/state}/openclaw.json"
if [[ ! -f "$STATE_FILE" ]]; then
for alt in \
"/opt/openclaw/state/openclaw.json" \
"${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw/config}/openclaw.json"; do
if [[ -f "$alt" ]]; then
STATE_FILE="$alt"
break
fi
done
fi
if [[ ! -f "$STATE_FILE" ]]; then
echo "ERROR: Cannot find openclaw.json state file" >&2
exit 1
fi
echo "📁 Config file: $STATE_FILE"
# Validate all model IDs first
echo ""
echo "🔍 Validating model IDs against /v1/models..."
VALIDATION_FAILED=0
if [[ -n "$PRIMARY" ]]; then
# Strip llm-proxy/ prefix for validation
PRIMARY_CLEAN="${PRIMARY#llm-proxy/}"
if ! "$SCRIPT_DIR/validate-model.sh" "$PRIMARY_CLEAN" 2>&1; then
VALIDATION_FAILED=1
fi
fi
if [[ -n "$FALLBACKS" ]]; then
IFS=',' read -ra FB_ARRAY <<< "$FALLBACKS"
if [[ ${#FB_ARRAY[@]} -lt 2 ]]; then
echo "❌ ERROR: Minimum 2 fallback models required (got ${#FB_ARRAY[@]})" >&2
VALIDATION_FAILED=1
fi
for fb in "${FB_ARRAY[@]}"; do
fb_clean="${fb#llm-proxy/}"
fb_clean="$(echo "$fb_clean" | xargs)" # trim whitespace
if ! "$SCRIPT_DIR/validate-model.sh" "$fb_clean" 2>&1; then
VALIDATION_FAILED=1
fi
done
fi
if [[ $VALIDATION_FAILED -ne 0 ]]; then
echo ""
echo "❌ Validation failed. No changes made." >&2
exit 1
fi
# Create backup
BACKUP="${STATE_FILE}.backup.$(date +%Y%m%d-%H%M%S)"
cp "$STATE_FILE" "$BACKUP"
echo ""
echo "💾 Backup saved: $BACKUP"
# Apply changes using Python for safe JSON manipulation
python3 -c "
import json, sys, re
state_file = '$STATE_FILE'
primary = '${PRIMARY}'.strip() or None
fallbacks_raw = '${FALLBACKS}'.strip() or None
# Read and parse (handle JSON5 comments)
with open(state_file, 'r') as f:
content = f.read()
# Strip comments for parsing
lines = content.split('\n')
cleaned = []
for line in lines:
s = line.lstrip()
if s.startswith('//'):
continue
in_string = False
result = []
i = 0
while i < len(line):
c = line[i]
if c == '\"' and (i == 0 or line[i-1] != '\\\\'):
in_string = not in_string
elif c == '/' and i + 1 < len(line) and line[i+1] == '/' and not in_string:
break
result.append(c)
i += 1
cleaned.append(''.join(result))
# Remove trailing commas before } or ] (JSON5 feature)
json_str = '\n'.join(cleaned)
json_str = re.sub(r',\s*([}\]])', r'\1', json_str)
try:
cfg = json.loads(json_str)
except json.JSONDecodeError:
try:
cfg = json.loads(content)
except json.JSONDecodeError as e:
print(f'ERROR: Failed to parse config: {e}', file=sys.stderr)
sys.exit(1)
# Ensure path exists
if 'agents' not in cfg:
cfg['agents'] = {}
if 'defaults' not in cfg['agents']:
cfg['agents']['defaults'] = {}
if 'model' not in cfg['agents']['defaults']:
cfg['agents']['defaults']['model'] = {}
model = cfg['agents']['defaults']['model']
if isinstance(model, str):
model = {'primary': model}
cfg['agents']['defaults']['model'] = model
old_primary = model.get('primary', '(none)')
old_fallbacks = model.get('fallbacks', [])
# Apply primary
if primary:
# Ensure llm-proxy/ prefix
if not primary.startswith('llm-proxy/'):
primary = f'llm-proxy/{primary}'
model['primary'] = primary
# Apply fallbacks
if fallbacks_raw:
fbs = [fb.strip() for fb in fallbacks_raw.split(',') if fb.strip()]
fbs = [f'llm-proxy/{fb}' if not fb.startswith('llm-proxy/') else fb for fb in fbs]
model['fallbacks'] = fbs
# Remove per-agent model overrides that match the old primary
# (they were likely set by the same drift that caused the issue)
agent_list = cfg.get('agents', {}).get('list', [])
removed_overrides = []
for agent in agent_list:
if 'model' in agent:
removed_overrides.append((agent.get('id', '?'), agent['model']))
del agent['model']
# Write back
with open(state_file, 'w') as f:
json.dump(cfg, f, indent=2)
f.write('\n')
# Print summary
print()
print('✅ Configuration updated:')
print()
print(f' Primary: {old_primary} → {model.get(\"primary\", \"(none)\")}')
print(f' Fallbacks:')
for i, fb in enumerate(model.get('fallbacks', []), 1):
old_marker = '' if fb in old_fallbacks else ' (new)'
print(f' {i}. {fb}{old_marker}')
if removed_overrides:
print()
print(' 🧹 Cleared per-agent model overrides:')
for aid, amodel in removed_overrides:
print(f' {aid}: {amodel} → (uses default)')
" 2>&1
echo ""
echo "Done. Restart OpenClaw for changes to take effect."

View File

@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# validate-model.sh — Validate that a model ID exists in the LLM proxy
# Usage: validate-model.sh <model-id>
# Exit 0 if valid, 1 if not found
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ $# -lt 1 ]]; then
echo "Usage: validate-model.sh <model-id>" >&2
echo "Example: validate-model.sh nanogpt/deepseek-chat" >&2
exit 1
fi
MODEL_ID="$1"
# Strip llm-proxy/ prefix if present (user might pass the openclaw.json format)
MODEL_ID="${MODEL_ID#llm-proxy/}"
# Get the live model list
available=$("$SCRIPT_DIR/list-models.sh" 2>/dev/null) || {
echo "ERROR: Could not fetch model list from LLM proxy" >&2
exit 1
}
if echo "$available" | grep -qxF "$MODEL_ID"; then
echo "✅ Model '$MODEL_ID' is available"
exit 0
else
echo "❌ Model '$MODEL_ID' NOT found in /v1/models" >&2
# Suggest close matches
partial=$(echo "$available" | grep -i "$(echo "$MODEL_ID" | sed 's|.*/||')" | head -5)
if [[ -n "$partial" ]]; then
echo "" >&2
echo "Did you mean one of these?" >&2
echo "$partial" | sed 's/^/ /' >&2
fi
exit 1
fi