diff --git a/packages/aris-source-weatherkit/scripts/query.ts b/packages/aris-source-weatherkit/scripts/query.ts new file mode 100644 index 0000000..54c5e75 --- /dev/null +++ b/packages/aris-source-weatherkit/scripts/query.ts @@ -0,0 +1,181 @@ +#!/usr/bin/env bun + +/** + * Interactive CLI script to query WeatherKit directly. + * Prompts for credentials, coordinates, and optional settings, + * then prints the raw API response and processed feed items. + * Caches credentials locally and writes response JSON to a file. + * + * Usage: bun packages/aris-source-weatherkit/scripts/query.ts + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { createInterface } from "node:readline/promises" + +import { Context } from "@aris/core" +import { LocationKey } from "@aris/source-location" + +import { DefaultWeatherKitClient } from "../src/weatherkit" +import { WeatherSource, Units } from "../src/weather-source" + +const SCRIPT_DIR = import.meta.dirname +const CACHE_DIR = join(SCRIPT_DIR, ".cache") +const CREDS_PATH = join(CACHE_DIR, "credentials.json") + +interface CachedCredentials { + teamId: string + serviceId: string + keyId: string + privateKey: string + lat?: number + lng?: number +} + +function loadCachedCredentials(): CachedCredentials | null { + if (!existsSync(CREDS_PATH)) return null + try { + return JSON.parse(readFileSync(CREDS_PATH, "utf-8")) as CachedCredentials + } catch { + return null + } +} + +function saveCachedCredentials(creds: CachedCredentials): void { + mkdirSync(CACHE_DIR, { recursive: true }) + writeFileSync(CREDS_PATH, JSON.stringify(creds)) +} + +const rl = createInterface({ input: process.stdin, output: process.stdout }) + +async function prompt(question: string, defaultValue?: string): Promise { + const suffix = defaultValue ? ` [${defaultValue}]` : "" + const answer = await rl.question(`${question}${suffix}: `) + return answer.trim() || defaultValue || "" +} + +async function main(): Promise { + console.log("=== WeatherKit Query Tool ===\n") + + const cached = loadCachedCredentials() + + let teamId: string + let serviceId: string + let keyId: string + let privateKey: string + + if (cached) { + console.log(`Using cached credentials from ${CREDS_PATH}`) + console.log(` Team ID: ${cached.teamId}`) + console.log(` Service ID: ${cached.serviceId}`) + console.log(` Key ID: ${cached.keyId}\n`) + + const useCached = await prompt("Use cached credentials? (y/n)", "y") + if (useCached.toLowerCase() === "y") { + teamId = cached.teamId + serviceId = cached.serviceId + keyId = cached.keyId + privateKey = cached.privateKey + } else { + ;({ teamId, serviceId, keyId, privateKey } = await promptCredentials()) + } + } else { + console.log(`Credentials will be cached to ${CREDS_PATH}\n`) + ;({ teamId, serviceId, keyId, privateKey } = await promptCredentials()) + } + + // Location + const defaultLat = cached?.lat?.toString() ?? "37.7749" + const defaultLng = cached?.lng?.toString() ?? "-122.4194" + const lat = parseFloat(await prompt("Latitude", defaultLat)) + const lng = parseFloat(await prompt("Longitude", defaultLng)) + + if (Number.isNaN(lat) || Number.isNaN(lng)) { + console.error("Invalid coordinates") + process.exit(1) + } + + const credentials = { privateKey, keyId, teamId, serviceId } + saveCachedCredentials({ ...credentials, lat, lng }) + + // Options + const unitsInput = await prompt("Units (metric/imperial)", "metric") + const units = unitsInput === "imperial" ? Units.imperial : Units.metric + + // Raw API query + console.log("\n--- Raw WeatherKit Response ---\n") + const client = new DefaultWeatherKitClient(credentials) + const raw = await client.fetch({ lat, lng }) + console.log(JSON.stringify(raw, null, 2)) + + // Write JSON to file + const outPath = join(CACHE_DIR, "response.json") + writeFileSync(outPath, JSON.stringify(raw)) + console.log(`\nResponse written to ${outPath}`) + + // Processed feed items via WeatherSource + console.log("\n--- Processed Feed Items ---\n") + const source = new WeatherSource({ client, units }) + const context = new Context() + context.set([[LocationKey, { lat, lng, accuracy: 10, timestamp: new Date() }]]) + + const items = await source.fetchItems(context) + for (const item of items) { + console.log(`[${item.type}] ${item.id}`) + console.log(` signals: ${JSON.stringify(item.signals)}`) + if (item.slots) { + console.log(` slots:`) + for (const [name, slot] of Object.entries(item.slots)) { + console.log(` ${name}: "${slot.description}" -> ${slot.content ?? "(unfilled)"}`) + } + } + console.log(` data: ${JSON.stringify(item.data, null, 4)}`) + console.log() + } + + const feedPath = join(CACHE_DIR, "feed-items.json") + writeFileSync(feedPath, JSON.stringify(items, null, 2)) + console.log(`Feed items written to ${feedPath}`) + console.log(`Total: ${items.length} items`) + rl.close() +} + +async function promptCredentials(): Promise { + const teamId = await prompt("Apple Team ID") + if (!teamId) { + console.error("Team ID is required") + process.exit(1) + } + + const serviceId = await prompt("Service ID") + if (!serviceId) { + console.error("Service ID is required") + process.exit(1) + } + + const keyId = await prompt("Key ID") + if (!keyId) { + console.error("Key ID is required") + process.exit(1) + } + + console.log("\nPaste your private key (PEM format). Enter an empty line when done:") + const keyLines: string[] = [] + for await (const line of rl) { + if (line.trim() === "") break + keyLines.push(line) + } + const privateKey = keyLines.join("\n") + if (!privateKey) { + console.error("Private key is required") + process.exit(1) + } + + return { teamId, serviceId, keyId, privateKey } +} + +main().catch((err) => { + console.error("Error:", err) + rl.close() + process.exit(1) +}) diff --git a/packages/aris-source-weatherkit/src/prompts/current-weather-insight.txt b/packages/aris-source-weatherkit/src/prompts/current-weather-insight.txt new file mode 100644 index 0000000..1d30a0c --- /dev/null +++ b/packages/aris-source-weatherkit/src/prompts/current-weather-insight.txt @@ -0,0 +1,7 @@ +Max 12 words. Plain language, no hedging. Now + what's next. + +Examples: +- "Clear tonight, warming up. Rain by Saturday." +- "Clearing soon with strong winds overnight. Light rain Thursday." +- "Sunny all day. Grab sunscreen." +- "Cloudy tonight, warming to 15°. Rain Monday." \ No newline at end of file diff --git a/packages/aris-source-weatherkit/src/weather-source.test.ts b/packages/aris-source-weatherkit/src/weather-source.test.ts index 88b4994..90ab54a 100644 --- a/packages/aris-source-weatherkit/src/weather-source.test.ts +++ b/packages/aris-source-weatherkit/src/weather-source.test.ts @@ -176,6 +176,34 @@ describe("WeatherSource", () => { expect(uniqueIds.size).toBe(ids.length) }) + + test("current weather item has insight slot", async () => { + const source = new WeatherSource({ client: mockClient }) + const context = createMockContext({ lat: 37.7749, lng: -122.4194 }) + + const items = await source.fetchItems(context) + const currentItem = items.find((i) => i.type === WeatherFeedItemType.Current) + + expect(currentItem).toBeDefined() + expect(currentItem!.slots).toBeDefined() + expect(currentItem!.slots!.insight).toBeDefined() + expect(currentItem!.slots!.insight!.description).toBeString() + expect(currentItem!.slots!.insight!.description.length).toBeGreaterThan(0) + expect(currentItem!.slots!.insight!.content).toBeNull() + }) + + test("non-current items do not have slots", async () => { + const source = new WeatherSource({ client: mockClient }) + const context = createMockContext({ lat: 37.7749, lng: -122.4194 }) + + const items = await source.fetchItems(context) + const nonCurrentItems = items.filter((i) => i.type !== WeatherFeedItemType.Current) + + expect(nonCurrentItems.length).toBeGreaterThan(0) + for (const item of nonCurrentItems) { + expect(item.slots).toBeUndefined() + } + }) }) describe("no reactive methods", () => { diff --git a/packages/aris-source-weatherkit/src/weather-source.ts b/packages/aris-source-weatherkit/src/weather-source.ts index 92df059..7e820d7 100644 --- a/packages/aris-source-weatherkit/src/weather-source.ts +++ b/packages/aris-source-weatherkit/src/weather-source.ts @@ -4,6 +4,7 @@ import { Context, TimeRelevance, UnknownActionError } from "@aris/core" import { LocationKey } from "@aris/source-location" import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items" +import currentWeatherInsightPrompt from "./prompts/current-weather-insight.txt" import { WeatherKey, type Weather } from "./weather-context" import { DefaultWeatherKitClient, @@ -309,6 +310,12 @@ function createCurrentWeatherFeedItem( windSpeed: convertSpeed(current.windSpeed, units), }, signals, + slots: { + insight: { + description: currentWeatherInsightPrompt, + content: null, + }, + }, } }