Skill: iclems/airfrance-afkl (version 1.0.1)

--- name: airfrance-afkl description: Track Air France flights using the Air France–KLM Open Data APIs (Flight Status). Use when the user gives a flight number/date (e.g., AF007 on 2026-01-29) and wants monitoring, alerts (delay/gate/aircraft changes), or analysis (previous-flight chain, aircraft tail number → cabin recency / Wi‑Fi). Also use when setting up or tuning polling schedules within API rate limits. --- # Air France (AFKL Open Data) flight tracker ## Quick start (one-off status) 1) Create an API key (and optional secret) - Register on: https://developer.airfranceklm.com - Subscribe to the Open Data product(s) you need (at least **Flight Status API**) - Generate credentials (API key; some accounts also provide an API secret) 2) Provide API credentials (do not print them): - Preferred: env vars `AFKL_API_KEY` (and optional `AFKL_API_SECRET`) - Or files in your state dir (`CLAWDBOT_STATE_DIR` or `./state`): - `afkl_api_key.txt` (chmod 600) - `afkl_api_secret.txt` (chmod 600, optional) 2) Query flight status: - Run: `node skills/airfrance-afkl/scripts/afkl_flightstatus_query.mjs --carrier AF --flight 7 --origin JFK --dep-date 2026-01-29` Notes: - Send `Accept: */*` (API returns `application/hal+json`). - Keep within limits: **<= 1 request/sec**. When making multiple calls, sleep ~1100ms between them. ## Start monitoring (watcher) Use when the user wants proactive updates. - Run: `node skills/airfrance-afkl/scripts/afkl_watch_flight.mjs --carrier AF --flight 7 --origin JFK --dep-date 2026-01-29` What it does: - Fetches the operational flight(s) for the date window. - Emits a single message only when something meaningful changes. - Also follows the **previous-flight chain** (`flightRelations.previousFlightData.id`) up to a configurable depth and alerts if a previous segment is delayed/cancelled. Polling strategy (default): - >36h before departure: at most every **60 min** - 36h→12h: every **30 min** - 12h→3h: every **15 min** - 3h→departure: every **5–10 min** (stay under daily quota) - After departure: every **30 min** until arrival Implementation detail: run cron every 5–15 min, but the script self-throttles using a state file so it won’t hit the API when it’s not time. The watcher prints **no output** when nothing changed (so cron jobs can send only when stdout is non-empty). ## Input shorthand Preferred user-facing format: - `AF7 demain` / `AF7 jeudi` Interpretation rule: - The day always refers to the **departure date** (not arrival). Implementation notes: - Convert relative day words to a departure date in the user’s timezone unless the origin timezone is explicitly known. - When ambiguous (long-haul crossing midnight), prefer the departure local date at the origin if origin is known. (For scripts, still pass `--origin` + `--dep-date YYYY-MM-DD`.) ## Interpret “interesting” fields See `references/fields.md` for: - `flightRelations` (prev/next) - `places.*` (terminal/gate/check-in zone) - `times.*` (scheduled/estimated/latest/actual) - `aircraft` (type, registration) - “parking position” / stand-type hints (when present) - Wi‑Fi hints and how to reason about cabin recency ## Cabin recency / upgrade heuristics When aircraft registration is available: - Use tail number to infer **sub-fleet** and likely cabin generation. - If data suggests older config (or no Wi‑Fi), upgrading can be more/less worth it. Be conservative: - Open Data often doesn’t expose exact seat model; treat this as **best-effort**.

path: README.md
content: # airfrance-afkl-skill

Clawdbot skill to track **Air France flights** using the **Air France–KLM Open Data APIs** (Flight Status).

It’s designed for day-to-day travel monitoring:
- Flight status + schedule vs latest published times
- Terminal / gate (when published)
- Boarding close time (when available)
- Aircraft tail number + cabin configuration summary (best-effort)
- Previous-flight chain (useful to estimate delay risk)
- Compact alerts: you only get pinged when displayed fields change

## Prerequisites

1) Register and create an API key on the AFKL developer portal:
- https://developer.airfranceklm.com

2) Subscribe to Open Data products (at least **Flight Status API**).

## Configuration

Provide credentials **via environment variables** (recommended):

```bash
export AFKL_API_KEY="..."
# optional
export AFKL_API_SECRET="..."
```

Or via files in your state dir:

- Set `CLAWDBOT_STATE_DIR` to your state directory (or it falls back to `./state`)
- Create:
  - `afkl_api_key.txt`
  - `afkl_api_secret.txt` (optional)

## Usage

### One-off query

```bash
node skills/airfrance-afkl/scripts/afkl_flightstatus_query.mjs \
  --carrier AF --flight 7 --origin JFK --dep-date 2026-01-29
```

### Watcher (poll + change-only output)

```bash
node skills/airfrance-afkl/scripts/afkl_watch_flight.mjs \
  --carrier AF --flight 7 --origin JFK --dep-date 2026-01-29 --prev-depth 2
```

Output behavior:
- **Prints nothing** when nothing changed
- Prints a **ready-to-send multi-line message** when something changed

The message is designed to be compact and human-readable:
- Emojis for 🛫/🕤/🛬/✈️/📶
- Weekday + date + time (to avoid timezone ambiguity)
- Tail + aircraft type + cabin config summary
- Wi‑Fi line (fast/slow) when available
- Previous-flight chain summary (e.g. `↩️ Dubai → Paris → New York (on time)`)
- Optional headline line highlighting what changed (new time, inbound delay, new aircraft, boarding started)


## Rate limits

AFKL Open Data is rate-limited (commonly **1 request/second** and a daily quota).
The watcher is designed to be schedule-aware and to avoid unnecessary calls.

## Notes / disclaimers

- Aircraft “intel” (age, first flight, etc.) is **best-effort** and relies on public sources.
- Cabin configuration identifiers can vary by fleet and may not guarantee the exact seat product.

## License

MIT (recommended) — add a LICENSE file if you want.


path: scripts/aircraft_intel.mjs
content: #!/usr/bin/env node
// Best-effort aircraft intel via public sources.
// Currently: Planespotters public API (no key) + local cache.

import fs from 'node:fs';

function getArg(name, def) {
  const i = process.argv.indexOf(`--${name}`);
  if (i === -1) return def;
  const v = process.argv[i + 1];
  return (!v || v.startsWith('--')) ? def : v;
}

const regRaw = (getArg('reg') || '').toUpperCase();
if (!regRaw) {
  console.error('Usage: aircraft_intel.mjs --reg F-HXXX');
  process.exit(2);
}

function normalizeRegs(input) {
  const r = String(input).toUpperCase().replace(/\s+/g,'');
  const out = new Set([r]);
  // Common Air France registrations: FGSQB -> F-GSQB
  if (/^F[A-Z0-9]{4}$/.test(r)) out.add(`F-${r.slice(1)}`);
  if (/^F-[A-Z0-9]{4}$/.test(r)) out.add(`F${r.slice(2)}`);
  return Array.from(out);
}

const regs = normalizeRegs(regRaw);
const primary = regs[0];

const CACHE_PATH = process.env.CLAWDBOT_STATE_DIR ? `${process.env.CLAWDBOT_STATE_DIR}/aircraft_intel_cache.json` : '/tmp/aircraft_intel_cache.json';
let cache = {};
if (fs.existsSync(CACHE_PATH)) {
  try { cache = JSON.parse(fs.readFileSync(CACHE_PATH, 'utf8')); } catch {}
}

const now = Date.now();
const cached = cache[primary];
if (cached && cached.expiresAtMs && now < cached.expiresAtMs) {
  console.log(JSON.stringify({ ok: true, reg: primary, tried: regs, source: 'cache', intel: cached.intel }, null, 2));
  process.exit(0);
}

async function fetchJson(url) {
  const resp = await fetch(url, { headers: { 'accept': 'application/json' } });
  const text = await resp.text();
  let json;
  try { json = JSON.parse(text); } catch { json = null; }
  if (!resp.ok || !json) {
    const err = new Error(`HTTP ${resp.status}`);
    err.body = text.slice(0, 300);
    throw err;
  }
  return json;
}

let intel = { reg: primary };
try {
  // Planespotters API: https://api.planespotters.net/pub/photos/reg/{reg}
  for (const r of regs) {
    try {
      const url = `https://api.planespotters.net/pub/photos/reg/${encodeURIComponent(r)}`;
      const j = await fetchJson(url);
      const photo = (j.photos && j.photos[0]) || null;
      const ac = photo && photo.aircraft || null;
      if (ac) {
        intel = {
          reg: r,
          icao24: ac.icao24 || null,
          manufacturer: ac.manufacturer || null,
          model: ac.model || null,
          msn: ac.msn || null,
          lineNumber: ac.lineNumber || null,
          operator: ac.operator || null,
          owner: ac.owner || null,
          firstFlightDate: ac.firstFlightDate || null,
          deliveryDate: ac.deliveryDate || null,
          ageYears: ac.ageYears ?? null,
        };
        break;
      }
    } catch {}
  }
} catch {
  intel = { reg: primary, error: 'lookup_failed' };
}

// Cache for 7 days (store under primary key)
cache[primary] = { intel, expiresAtMs: now + 7*24*3600*1000 };
try { fs.writeFileSync(CACHE_PATH, JSON.stringify(cache, null, 2)); } catch {}

console.log(JSON.stringify({ ok: true, reg: primary, tried: regs, source: 'live', intel }, null, 2));


path: scripts/afkl_watch_flight.mjs
content: #!/usr/bin/env node
// Generic watcher: emits JSON for the cron runner, or sends messages when invoked manually.
import fs from 'node:fs';
import path from 'node:path';
import { afklFetchJson, getStateDir } from './afkl_http.mjs';

const STATE_DIR = getStateDir();
try { fs.mkdirSync(STATE_DIR, { recursive: true }); } catch {}
const AIRCRAFT_CACHE_PATH = path.join(STATE_DIR, 'aircraft_intel_cache.json');
const AIRCRAFT_STATIC_CACHE_PATH = path.join(STATE_DIR, 'aircraft_static_cache.json');

function ymdInTz(date, timeZone) {
  const parts = new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(date);
  const get = (t) => parts.find(p => p.type === t)?.value;
  return `${get('year')}-${get('month')}-${get('day')}`;
}

function prettyWhen(iso, { timeZone, locale = 'fr-FR' } = {}) {
  if (!iso) return '';
  const d = new Date(iso);
  if (!Number.isFinite(d.getTime())) return iso;

  const now = new Date();
  const tz = timeZone || 'Europe/Paris';
  const ymdNow = ymdInTz(now, tz);
  const ymdEvt = ymdInTz(d, tz);

  const toDayNum = (ymd) => {
    const [Y,M,D] = ymd.split('-').map(Number);
    return Math.floor(Date.UTC(Y, M-1, D) / 86400000);
  };
  const diff = toDayNum(ymdEvt) - toDayNum(ymdNow);

  let dayLabel;
  // User preference: always show weekday + date (no today/tomorrow wording).
  const weekday = new Intl.DateTimeFormat(locale, { timeZone: tz, weekday: 'long' }).format(d);
  const dayMonth = new Intl.DateTimeFormat(locale, { timeZone: tz, day: '2-digit', month: 'short' }).format(d);
  const time = new Intl.DateTimeFormat(locale, { timeZone: tz, hour: '2-digit', minute: '2-digit' }).format(d).replace(':','h');
  return `${weekday} ${dayMonth} ${time}`;
}

function normalizeRegs(r) {
  const s = String(r || '').toUpperCase().replace(/\s+/g,'');
  const out = new Set([s]);
  if (/^F[A-Z0-9]{4}$/.test(s)) out.add(`F-${s.slice(1)}`);
  if (/^F-[A-Z0-9]{4}$/.test(s)) out.add(`F${s.slice(2)}`);
  return Array.from(out);
}

function loadJson(path) {
  if (!fs.existsSync(path)) return null;
  try { return JSON.parse(fs.readFileSync(path, 'utf8')); } catch { return null; }
}

async function aircraftIntel(reg) {
  if (!reg) return null;
  const tried = normalizeRegs(reg);
  const primary = tried[0];

  // 1) Static cache (never expires)
  const staticCache = loadJson(AIRCRAFT_STATIC_CACHE_PATH) || {};
  for (const t of tried) {
    if (staticCache[t]) return { reg: t, ...staticCache[t] };
  }

  // 2) Soft cache (expires)
  let cache = loadJson(AIRCRAFT_CACHE_PATH) || {};
  const now = Date.now();
  const cached = cache[primary];
  if (cached && cached.expiresAtMs && now < cached.expiresAtMs) return cached.intel;

  // 3) Best-effort Planespotters
  try {
    for (const t of tried) {
      try {
        const url = `https://api.planespotters.net/pub/photos/reg/${encodeURIComponent(t)}`;
        const resp = await fetch(url, { headers: { 'accept': 'application/json' } });
        const text = await resp.text();
        const j = JSON.parse(text);
        const photo = (j.photos && j.photos[0]) || null;
        const ac = photo && photo.aircraft || null;
        if (ac) {
          const intel = {
            reg: t,
            manufacturer: ac.manufacturer || null,
            model: ac.model || null,
            msn: ac.msn || null,
            firstFlightDate: ac.firstFlightDate || null,
            deliveryDate: ac.deliveryDate || null,
            ageYears: ac.ageYears ?? null,
          };
          cache[primary] = { intel, expiresAtMs: now + 30*24*3600*1000 };
          try { fs.writeFileSync(AIRCRAFT_CACHE_PATH, JSON.stringify(cache, null, 2)); } catch {}
          return intel;
        }
      } catch {}
    }
  } catch {}

  cache[primary] = { intel: { reg: primary, lookup: 'not_found' }, expiresAtMs: now + 24*3600*1000 };
  try { fs.writeFileSync(AIRCRAFT_CACHE_PATH, JSON.stringify(cache, null, 2)); } catch {}
  return cache[primary].intel;
}


function getArg(name, def) {
  const i = process.argv.indexOf(`--${name}`);
  if (i === -1) return def;
  const v = process.argv[i + 1];
  return (!v || v.startsWith('--')) ? def : v;
}

const carrier = (getArg('carrier', 'AF') || 'AF').toUpperCase();
const flight = String(parseInt(getArg('flight'), 10));
const origin = (getArg('origin') || '').toUpperCase();
const depDate = getArg('dep-date');
const prevDepth = parseInt(getArg('prev-depth', '2'), 10);
const stateKey = `${carrier}${flight}_${origin}_${depDate}`;
const statePath = path.join(STATE_DIR, `afkl_watch_${stateKey}.json`);

if (!flight || !depDate || !origin) {
  console.log(JSON.stringify({ shouldNotify: true, message: 'AFKL watcher: missing args. Need --carrier --flight --origin --dep-date' }));
  process.exit(0);
}

function parseIso(s) {
  const t = Date.parse(s || '');
  return Number.isFinite(t) ? t : null;
}

function originTimeZone(origin) {
  // Minimal mapping; extend as needed.
  if (origin === 'JFK' || origin === 'EWR') return 'America/New_York';
  if (origin === 'CDG' || origin === 'ORY') return 'Europe/Paris';
  return 'UTC';
}

function pickFlight(data, { origin, carrier, flight, depDate }) {
  const flights = data.operationalFlights || [];
  const tz = originTimeZone(origin);

  // Prefer the operational flight whose *departure local date* matches depDate.
  const matches = flights.filter(f =>
    (f.airline && f.airline.code) === carrier &&
    String(f.flightNumber) === String(flight) &&
    Array.isArray(f.route) && f.route[0] === origin
  );

  for (const f of matches) {
    const leg = (f.flightLegs && f.flightLegs[0]) || {};
    const depIso = leg?.departureInformation?.times?.scheduled;
    if (!depIso) continue;
    const ymd = ymdInTz(new Date(depIso), tz);
    if (ymd === depDate) return f;
  }

  return matches[0] || flights[0];
}

function summarizeFlight(f) {
  const leg = (f.flightLegs && f.flightLegs[0]) || {};
  const depTimes = (leg.departureInformation && leg.departureInformation.times) || {};
  const arrTimes = (leg.arrivalInformation && leg.arrivalInformation.times) || {};
  const boardingTimes = (leg.departureInformation && leg.departureInformation.boardingTimes) || {};
  const legStatuses = leg.otherFlightLegStatuses || {};
  const depPlaces = (((leg.departureInformation||{}).airport||{}).places) || {};
  const arrPlaces = (((leg.arrivalInformation||{}).airport||{}).places) || {};
  const route = Array.isArray(f.route) ? f.route : [];
  const aircraft = leg.aircraft || {};
  const depAirport = (leg.departureInformation && leg.departureInformation.airport) || {};
  const arrAirport = (leg.arrivalInformation && leg.arrivalInformation.airport) || {};
  const depCity = (depAirport.city && (depAirport.city.nameLangTranl || depAirport.city.name)) || '';
  const arrCity = (arrAirport.city && (arrAirport.city.nameLangTranl || arrAirport.city.name)) || '';
  const depAirportName = depAirport.nameLangTranl || depAirport.name || '';
  const arrAirportName = arrAirport.nameLangTranl || arrAirport.name || '';

  // In practice we see: scheduled, latestPublished, estimated.
  const depBest = depTimes.latestPublished || depTimes.estimated || depTimes.latest || depTimes.actual || '';
  const arrBest = arrTimes.latestPublished || arrTimes.estimated || arrTimes.latest || arrTimes.actual || '';

  return {
    id: f.id,
    carrier: (f.airline && f.airline.code) || '',
    flightNumber: String(f.flightNumber || ''),
    scheduleDate: f.flightScheduleDate,
    route,
    depCity,
    arrCity,
    depAirportName,
    arrAirportName,

    statusPublic: f.flightStatusPublic || '',
    statusPublicTxt: f.flightStatusPublicLangTransl || '',
    publishedStatus: leg.publishedStatus || leg.statusName || leg.status || '',

    // Times
    depScheduled: depTimes.scheduled || '',
    depBest,
    boardingPlanned: boardingTimes.plannedBoardingTime || '',
    boardingClosed: boardingTimes.gateCloseTime || '',
    boardingStatus: legStatuses.boardingStatus || '',
    arrScheduled: arrTimes.scheduled || '',
    arrBest,
    arrEstimated: arrTimes.estimated || '',

    // Places
    terminal: depPlaces.terminalCode || depPlaces.boardingTerminal || '',
    gate: (depPlaces.gateNumber && depPlaces.gateNumber.join(',')) || (depPlaces.paxDepartureGate && depPlaces.paxDepartureGate.join(',')) || '',
    checkInZone: (depPlaces.checkInZone && depPlaces.checkInZone.join(',')) || '',

    // Arrival position can hint gate vs remote stand when explicitly provided.
    arrivalPositionTerminal: (Array.isArray(arrPlaces.arrivalPositionTerminal) ? arrPlaces.arrivalPositionTerminal.join(',') : (arrPlaces.arrivalPositionTerminal || '')),
    arrivalPositionPier: (Array.isArray(arrPlaces.arrivalPositionPier) ? arrPlaces.arrivalPositionPier.join(',') : (arrPlaces.arrivalPositionPier || '')),

    // Aircraft
    aircraftReg: aircraft.registration || '',
    aircraftType: aircraft.typeCode || '',
    aircraftTypeName: aircraft.typeName || '',
    subFleet: aircraft.subFleetCodeId || '',
    physicalPaxConfiguration: aircraft.physicalPaxConfiguration || '',
    operationalConfiguration: aircraft.operationalConfiguration || '',
    wifiEnabled: aircraft.wifiEnabled ?? null,
    highSpeedWifi: aircraft.highSpeedWifi ?? null,
    satelliteConnectivityOnBoard: aircraft.satelliteConnectivityOnBoard ?? null,

    // Previous-flight chain
    prevId: (f.flightRelations && f.flightRelations.previousFlightData && f.flightRelations.previousFlightData.id) || '',
    prevDate: (f.flightRelations && f.flightRelations.previousFlightData && f.flightRelations.previousFlightData.flightScheduleDate) || '',
  };
}

function computeNextIntervalMs(nowMs, depMs) {
  if (!depMs) return 60*60*1000;
  const dt = depMs - nowMs;
  if (dt > 36*3600*1000) return 60*60*1000;
  if (dt > 12*3600*1000) return 30*60*1000;
  if (dt > 3*3600*1000) return 15*60*1000;
  if (dt > 0) return 10*60*1000;
  // after departure
  return 30*60*1000;
}

async function fetchById(id) {
  // Throttle is handled by caller (cron frequency + nextRunAt in state).
  return afklFetchJson(`https://api.airfranceklm.com/opendata/flightstatus/${encodeURIComponent(id)}`);
}

const now = Date.now();
let state = null;
if (fs.existsSync(statePath)) {
  try { state = JSON.parse(fs.readFileSync(statePath, 'utf8')); } catch {}
}

if (state?.nextCheckAtMs && now < state.nextCheckAtMs) {
  // No output = no notification.
  process.exit(0);
}

// Cache previous-flight path by tail to avoid extra API calls.
const prevPathCache = state?.prevPathCache || {};

// Query operational flights list
const startRange = `${depDate}T00:00:00Z`;
const endRange = new Date(Date.parse(`${depDate}T00:00:00Z`) + 48*3600*1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
const listUrl = `https://api.airfranceklm.com/opendata/flightstatus/?${new URLSearchParams({
  startRange,
  endRange,
  movementType: 'D',
  origin,
  carrierCode: carrier,
  flightNumber: flight,
  pageSize: '10',
  pageNumber: '0',
  timeOriginType: 'S',
  timeType: 'L',
}).toString()}`;

let data;
try {
  data = await afklFetchJson(listUrl);
} catch (e) {
  const msg = `AF${flight} watcher: erreur API (${e.status||'?'})`;
  console.log(JSON.stringify({ shouldNotify: true, message: msg }));
  process.exit(0);
}

const f = pickFlight(data, { origin, carrier, flight, depDate });
if (!f) {
  console.log(JSON.stringify({ shouldNotify: true, message: `AF${flight} watcher: vol introuvable (API vide).` }));
  process.exit(0);
}

const snap = summarizeFlight(f);
// Enrich aircraft (best-effort, cached)
if (snap.aircraftReg) {
  snap.aircraftIntel = await aircraftIntel(snap.aircraftReg);
}
const depMs = parseIso(snap.depScheduled) || parseIso(snap.depBest);
const nextInterval = computeNextIntervalMs(now, depMs);

// Follow previous-flight chain (depth-limited)
// Note: we already did 1 API call to list flights; respect 1 req/s by sleeping before the first chained call.
const prevAlerts = [];
const prevChain = [];
let prevId = snap.prevId;

const tailKey = (snap.aircraftReg || '').replace(/^F([A-Z0-9]{4})$/, 'F-$1');
const cachedPrevPath = tailKey ? prevPathCache[tailKey] : null;
const effectivePrevDepth = cachedPrevPath ? Math.min(prevDepth, 1) : prevDepth;

if (effectivePrevDepth > 0 && prevId) await new Promise(r => setTimeout(r, 1100));
for (let d=0; d<effectivePrevDepth && prevId; d++) {
  // Respect 1 req/s: sleep 1100ms between calls when chaining
  if (d>0) await new Promise(r => setTimeout(r, 1100));
  let prev;
  try { prev = await fetchById(prevId); } catch { break; }
  const prevSnap = summarizeFlight(prev);
  prevChain.push({ depth: d+1, id: prevId, snap: prevSnap });

  const st = (prevSnap.statusPublic || prevSnap.publishedStatus || '').toUpperCase();
  if (st.includes('CANCEL') || st.includes('DELAY') || st.includes('DIVERT')) {
    prevAlerts.push({ depth: d+1, id: prevId, status: prevSnap.statusPublic || prevSnap.publishedStatus, depBest: prevSnap.depBest, arrBest: prevSnap.arrBest, route: prevSnap.route });
  }
  prevId = prevSnap.prevId;
}

// Compute previous-flight summary + build/cache previous-flight path like: Dubai → Paris → New York
if (prevChain.length) {
  const p = prevChain[0].snap;
  const prevArrBest = p.arrBest || p.arrScheduled;
  const prevArrMs = parseIso(prevArrBest);
  const prevArrSchedMs = parseIso(p.arrScheduled);
  let delayMin = 0;
  if (prevArrMs && prevArrSchedMs) delayMin = Math.round((prevArrMs - prevArrSchedMs) / 60000);
  const city = p.depCity || p.route[0];
  const st = (p.statusPublic || p.publishedStatus || '').toUpperCase();
  const label = st.includes('DELAY') ? 'DELAYED' : (st.includes('CANCEL') ? 'CANCELLED' : (st.includes('ON_TIME') || st.includes('ONTIME') ? 'ON TIME' : st || ''));
  snap.prevSummary = `${city}|${label}|${delayMin}`;
}

if (tailKey) {
  if (cachedPrevPath) {
    snap.prevPath = cachedPrevPath;
  } else if (prevChain.length >= 2) {
    const deep = prevChain[prevChain.length - 1].snap;
    const first = prevChain[0].snap;
    const cities = [deep.depCity || deep.route[0], deep.arrCity || deep.route[1], first.arrCity || first.route[1]].filter(Boolean);
    const uniq = cities.filter((c, i) => i === 0 || c !== cities[i - 1]);
    const pathStr = uniq.join(' → ');
    snap.prevPath = pathStr;
    prevPathCache[tailKey] = pathStr;
  }
}

// Compare to previous state
const prev = state?.snapshot || null;
const changes = [];
if (prev) {
  for (const k of ['statusPublic','publishedStatus','depScheduled','depBest','boardingClosed','boardingStatus','arrScheduled','arrBest','terminal','gate','arrivalPositionTerminal','aircraftReg','aircraftType','aircraftTypeName','physicalPaxConfiguration','operationalConfiguration','wifiEnabled','highSpeedWifi','satelliteConnectivityOnBoard','aircraftIntel','prevSummary','prevPath']) {
    if ((prev[k]||'') !== (snap[k]||'')) changes.push(k);
  }
}

const nextState = { snapshot: snap, nextCheckAtMs: now + nextInterval, updatedAt: new Date().toISOString(), prevPathCache };
fs.writeFileSync(statePath, JSON.stringify(nextState, null, 2));

// simple airport→tz mapping (extend as needed)
const tz = originTimeZone(origin) === 'UTC' ? 'Europe/Paris' : originTimeZone(origin);

if (!prev) {
  const depPretty = prettyWhen(snap.depScheduled || snap.depBest, { timeZone: tz });
  const routeStr = (snap.depCity && snap.arrCity) ? `${snap.depCity}→${snap.arrCity}` : snap.route.join('→');
  const where = `${snap.terminal ? ` • T${snap.terminal}` : ''}${snap.gate ? ` Gate ${snap.gate}` : ''}`;
  console.log(`🛫 ${carrier}${snap.flightNumber} (${routeStr}) — suivi activé\n🕤 Départ: ${depPretty}${where}`);
  process.exit(0);
}

if (changes.length === 0 && prevAlerts.length === 0) {
  // No output = no notification.
  process.exit(0);
}

function parseCabinConfig(cfg) {
  // Example: P004J058W028Y206
  const m = String(cfg||'').match(/P(\d+)J(\d+)W(\d+)Y(\d+)/);
  if (!m) return null;
  const [_, p, j, w, y] = m;
  return { first: Number(p), business: Number(j), premium: Number(w), economy: Number(y) };
}

function wifiLine(snap) {
  const hs = snap.highSpeedWifi;
  const we = snap.wifiEnabled;
  const sat = snap.satelliteConnectivityOnBoard;

  // Output format: emoji + the word "wifi" + text fast/slow.
  if (hs === true || hs === 'Y') return `⚡️📶 wifi fast${(sat === true || sat === 'Y') ? ' • 📡' : ''}`;
  if (we === true || we === 'Y') return `📶 wifi slow${(sat === true || sat === 'Y') ? ' • 📡' : ''}`;
  if (we === false || we === 'N') return `📶 wifi off`;
  return '';
}

const lines = [];

// Headline: highlight what changed
if (prev) {
  const old = prev;

  const headlineParts = [];

  // Time changes
  if (changes.includes('depBest') || changes.includes('depScheduled')) {
    headlineParts.push('🟡 nouveau départ');
  }
  if (changes.includes('arrBest') || changes.includes('arrScheduled')) {
    headlineParts.push('🟡 nouvelle arrivée');
  }

  // Gate/terminal
  if (changes.includes('terminal') || changes.includes('gate')) {
    headlineParts.push('🟦 gate/terminal update');
  }

  // Boarding started
  if (changes.includes('boardingStatus') && String(old.boardingStatus||'').toLowerCase().includes('not') && !String(snap.boardingStatus||'').toLowerCase().includes('not')) {
    headlineParts.push('🟢 boarding started');
  }

  // Aircraft/config
  if (changes.includes('aircraftReg') || changes.includes('aircraftType') || changes.includes('physicalPaxConfiguration') || changes.includes('operationalConfiguration')) {
    headlineParts.push('🚨 nouvel avion/config');
  }

  // Inbound impact
  if (changes.includes('prevSummary')) {
    const parts = String(snap.prevSummary||'').split('|');
    const label = (parts[1]||'').toUpperCase();
    const delayMin = Number(parts[2]||0);
    if (label === 'DELAYED' && delayMin >= 10) headlineParts.push(`⚠️ inbound +${delayMin}m`);
    if (label === 'CANCELLED') headlineParts.push('⚠️ inbound cancelled');
  }

  if (headlineParts.length) {
    lines.push(headlineParts.join(' • '));
  }

  // Extra detail for aircraft/config change
  if (changes.includes('aircraftReg') || changes.includes('aircraftType') || changes.includes('physicalPaxConfiguration') || changes.includes('operationalConfiguration')) {
    const oldCfg = old.operationalConfiguration || old.physicalPaxConfiguration || '';
    const newCfg = snap.operationalConfiguration || snap.physicalPaxConfiguration || '';
    if ((old.aircraftReg||'') && (snap.aircraftReg||'') && old.aircraftReg !== snap.aircraftReg) {
      lines.push(`Ancien tail: ${String(old.aircraftReg).replace(/^F([A-Z0-9]{4})$/, 'F-$1')} → Nouveau: ${String(snap.aircraftReg).replace(/^F([A-Z0-9]{4})$/, 'F-$1')}`);
    }
    if (oldCfg && newCfg && oldCfg !== newCfg) {
      lines.push(`Config: ${oldCfg} → ${newCfg}`);
    }
  }
}

const routeStr = (snap.depCity && snap.arrCity) ? `${snap.depCity}→${snap.arrCity}` : snap.route.join('→');
const statusRaw = snap.statusPublic || snap.publishedStatus || '—';
const statusPretty = String(statusRaw)
  .split('_')
  .map(w => w ? (w[0].toUpperCase() + w.slice(1).toLowerCase()) : '')
  .join(' ');
lines.push(`🛫 ${carrier}${snap.flightNumber} (${routeStr}) — ${statusPretty}`);
if (snap.depScheduled) {
  const depPretty = prettyWhen(snap.depScheduled, { timeZone: tz });
  const depBestPretty = snap.depBest ? prettyWhen(snap.depBest, { timeZone: tz }) : '';
  const depStr = (depBestPretty && depBestPretty !== depPretty) ? `${depPretty} → ${depBestPretty}` : depPretty;
  const closed = snap.boardingClosed ? prettyWhen(snap.boardingClosed, { timeZone: tz }) : '';
  const closedTime = closed ? closed.split(' ').slice(-1)[0] : '';
  const where = `${origin}${snap.terminal ? ` T${snap.terminal}` : ''}${snap.gate ? ` Gate ${snap.gate}` : ''}`;
  const bs = (snap.boardingStatus || '').trim();
  const bsShow = bs && !/^not\s*open$/i.test(bs) ? ` • boarding: ${bs}` : '';
  lines.push(`🕤 Départ: ${depStr}${closedTime ? ` (boarding close ${closedTime})` : ''}${bsShow} — ${where}`);
}
if (snap.arrScheduled) {
  const dstTz = (snap.route[1] === 'CDG') ? 'Europe/Paris' : tz;
  const arrPretty = prettyWhen(snap.arrScheduled, { timeZone: dstTz });
  const arrBestPretty = snap.arrBest ? prettyWhen(snap.arrBest, { timeZone: dstTz }) : '';
  const arrStr = (arrBestPretty && arrBestPretty !== arrPretty) ? `${arrPretty} → ${arrBestPretty}` : arrPretty;
  const dst = snap.route[1] || '';
  const dstTerm = snap.arrivalPositionTerminal ? ` ${snap.arrivalPositionTerminal}` : '';
  lines.push(`🛬 Arrivée: ${arrStr} — ${dst}${dstTerm}`);
}
// (terminal/gate already included in departure line)

// Aircraft line (compact + cabin config pretty)
if (snap.aircraftType || snap.aircraftReg) {
  const cfg = parseCabinConfig(snap.operationalConfiguration || snap.physicalPaxConfiguration);
  const cfgPretty = cfg ? ` • ${cfg.first} First / ${cfg.business} Business / ${cfg.premium} Premium / ${cfg.economy} Eco` : '';
  const wifi = wifiLine(snap);
  const age = (snap.aircraftIntel && (snap.aircraftIntel.ageYears != null)) ? `(~${snap.aircraftIntel.ageYears} ans)` : '';
  const tail = snap.aircraftReg ? snap.aircraftReg.replace(/^F([A-Z0-9]{4})$/, 'F-$1') : '';
  lines.push(`✈️ ${tail} ${age} • ${snap.aircraftType} ${snap.aircraftTypeName}${cfgPretty}`.trim());
  if (wifi) lines.push(wifi);

}

// Previous flight (short)
if (prevChain.length && snap.prevSummary) {
  const [city, label, delayMinRaw] = String(snap.prevSummary).split('|');
  const delayMin = Number(delayMinRaw || 0);
  const delayStr = (Math.abs(delayMin) >= 5) ? ` (${delayMin > 0 ? '+' : ''}${delayMin}m)` : '';
  const statusSuffix = label === 'ON TIME' ? '(on time)' : (label === 'DELAYED' ? `(delayed ${delayMin} mins)` : (label === 'CANCELLED' ? '(cancelled)' : `(${String(label||'').toLowerCase()})`));

  if (snap.prevPath) {
    lines.push(`↩️ ${snap.prevPath} ${statusSuffix}`);
  } else {
    lines.push(`↩️ ${city} • ${label}${delayStr}`);
  }
}
console.log(lines.join('\n'));


path: scripts/afkl_flightstatus_query.mjs
content: #!/usr/bin/env node
import { afklFetchJson } from './afkl_http.mjs';

function getArg(name, def) {
  const i = process.argv.indexOf(`--${name}`);
  if (i === -1) return def;
  const v = process.argv[i + 1];
  return (!v || v.startsWith('--')) ? def : v;
}

const carrier = (getArg('carrier', 'AF') || 'AF').toUpperCase();
const flight = String(parseInt(getArg('flight'), 10));
const origin = (getArg('origin') || '').toUpperCase();
const depDate = getArg('dep-date'); // YYYY-MM-DD local at origin

if (!flight || !depDate || !origin) {
  console.error('Usage: afkl_flightstatus_query.mjs --carrier AF --flight 7 --origin JFK --dep-date 2026-01-29');
  process.exit(2);
}

// Use a wide range window (UTC) around the departure date.
const startRange = `${depDate}T00:00:00Z`;
const endRange = new Date(Date.parse(`${depDate}T00:00:00Z`) + 48*3600*1000).toISOString().replace(/\.\d{3}Z$/, 'Z');

const params = new URLSearchParams({
  startRange,
  endRange,
  movementType: 'D',
  origin,
  carrierCode: carrier,
  flightNumber: flight,
  pageSize: '10',
  pageNumber: '0',
  timeOriginType: 'S',
  timeType: 'L',
});

const url = `https://api.airfranceklm.com/opendata/flightstatus/?${params.toString()}`;

const data = await afklFetchJson(url);
console.log(JSON.stringify({ ok: true, url, data }, null, 2));


path: scripts/afkl_http.mjs
content: #!/usr/bin/env node
// Minimal AFKL Open Data HTTP helper.
import fs from 'node:fs';
import path from 'node:path';

export function getStateDir() {
  // Prefer explicit env.
  if (process.env.CLAWDBOT_STATE_DIR) return process.env.CLAWDBOT_STATE_DIR;
  if (process.env.AFKL_STATE_DIR) return process.env.AFKL_STATE_DIR;

  // Clawdbot default workspace state dir (when running on the Snowi host).
  const defaultDir = '/home/cwehrung/clawd/state';
  try {
    if (fs.existsSync(defaultDir)) return defaultDir;
  } catch {}

  // Fallback for community usage.
  return path.resolve(process.cwd(), 'state');
}

export function loadCreds() {
  // Prefer env vars for community/shared skill usage.
  const apiKeyEnv = (process.env.AFKL_API_KEY || '').trim();
  const apiSecretEnv = (process.env.AFKL_API_SECRET || '').trim();

  const stateDir = getStateDir();
  const apiKeyPath = path.join(stateDir, 'afkl_api_key.txt');
  const apiSecretPath = path.join(stateDir, 'afkl_api_secret.txt');

  const [REDACTED_SECRET] || (fs.existsSync(apiKeyPath) ? fs.readFileSync(apiKeyPath, 'utf8').trim() : '');
  const apiSecret = apiSecretEnv || (fs.existsSync(apiSecretPath) ? fs.readFileSync(apiSecretPath, 'utf8').trim() : '');
  return { apiKey, apiSecret };
}

export async function afklFetchJson(url, { accept = '*/*' } = {}) {
  const { apiKey, apiSecret } = loadCreds();
  if (!apiKey) throw new Error('Missing AFKL API key (state/afkl_api_key.txt)');

  const headers = {
    // The gateway is picky: Accept */* yields application/hal+json reliably.
    'Accept': accept,
    'API-Key': apiKey,
  };
  if (apiSecret) headers['API-Secret'] = apiSecret;

  const resp = await fetch(url, { headers });
  const text = await resp.text();
  let json = null;
  try { json = JSON.parse(text); } catch {}
  if (!resp.ok) {
    const err = new Error(`AFKL HTTP ${resp.status}`);
    err.status = resp.status;
    err.body = json || text.slice(0, 400);
    err.url = url;
    throw err;
  }
  return json;
}


path: references/fields.md
content: # AFKL Flight Status (Open Data) — fields to watch

This is a practical mapping for alerting + “is my flight at risk?”. Exact field names may vary by API version.

## Identifiers
- `id` (e.g. `20260130+AF+0007`) — can be fetched directly:
  - `GET https://api.airfranceklm.com/opendata/flightstatus/{id}`

## Status
- `flightStatusPublic` / `flightStatusPublicLangTransl`
- Per-leg `publishedStatus`, `status`, `statusName`

Heuristic:
- Alert on any change in public status, or when estimated times move by ≥10–15 min.

## Times
Usually under `flightLegs[0].departureInformation.times` and `arrivalInformation.times`:
- `scheduled` — baseline
- `estimated` / `latest` — “best current”
- `actual` — when departed/arrived

## Airport / terminal / gate
Typically under `departureInformation.airport.places`:
- `terminalCode` / `boardingTerminal`
- `gateNumber` / `paxDepartureGate`
- `checkInZone`

## Previous-flight chain (very useful)
Under `flightRelations.previousFlightData`:
- `id`, `flightScheduleDate`, `airlineCode`, `flightNumber`

Why it’s useful:
- If the aircraft is late arriving for the previous segment, it increases delay risk.

Practical approach:
- Fetch previous flight by `id`, check its status + ETA.
- Optionally fetch `previous of previous` (depth 2) if you’re far from departure.

## Aircraft information
Often under `flightLegs[0].aircraft`:
- `registration` (tail number)
- `typeCode`, `typeName`
- `subFleetCodeId`
- `physicalPaxConfiguration`, `operationalConfiguration`
- `wifiEnabled`, `highSpeedWifi`, `satelliteConnectivityOnBoard`

What to do with it:
- Tail number → best-effort enrichment via public sources (see `scripts/aircraft_intel.mjs`).
- `operationalConfiguration` / `physicalPaxConfiguration` → can hint cabin generation (best-effort; codes vary).
- `highSpeedWifi=true` is the most actionable “good Wi‑Fi” signal exposed here.

## Parking / stand / bus vs jetbridge (best-effort)
Some feeds expose stand/parking position type; when present, it can hint:
- contact stand (jetbridge) vs remote stand (bus)

Treat as:
- “nice to have”; don’t promise accuracy unless the field is explicitly present.

## Rate limits
- Global: 1 request/sec (don’t burst)
- Daily: keep total requests low (schedule-aware polling)

## State directory (community-friendly)
The scripts store state/caches under `CLAWDBOT_STATE_DIR` (or `AFKL_STATE_DIR`), falling back to `./state`.


Security scan verdicts — ClawScan: clean | static analysis: suspicious | VirusTotal: suspicious | SkillSpector: clean (LOW)