import { getPreferenceValues, LocalStorage } from "@raycast/api"; import { homedir } from "node:os"; import { join } from "node:path"; import { readFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; type Preferences = { listCommandsWebhookUrl?: string; runWebhookUrl?: string; cacheTtlSeconds?: string; }; type FileConfig = { apiBaseUrl?: string; token?: string; listCommandsWebhookUrl?: string; runWebhookUrl?: string; }; export type RaycastCommandSummary = { id: string; title: string; description: string; icon: string; codeBytes: number; createdAt: string; updatedAt: string; }; const CACHE_KEY = "kody-commands-cache-v1"; const CONFIG_PATH = join(homedir(), ".config", "kody", "raycast.json"); type CachePayload = { fetchedAt: number; commands: RaycastCommandSummary[]; }; async function loadFileConfig(): Promise { try { const raw = await readFile(CONFIG_PATH, "utf8"); const parsed = JSON.parse(raw) as FileConfig; if (parsed && typeof parsed === "object") return parsed; } catch { // missing or invalid } return null; } function prefs(): Preferences { return getPreferenceValues(); } function cacheTtlMs() { const seconds = Number(prefs().cacheTtlSeconds || "300"); if (!Number.isFinite(seconds) || seconds < 0) return 300_000; return Math.floor(seconds * 1000); } export function getErrorMessage(error: unknown): string { if (error instanceof Error) return error.message; if (typeof error === "string") return error; try { return JSON.stringify(error); } catch { return "Unknown error"; } } function normalizeResult(value: unknown): unknown { if (!value || typeof value !== "object" || Array.isArray(value)) return value; const record = value as Record; if ("result" in record) return record.result; if ("data" in record) return record.data; if ("output" in record) return record.output; return value; } async function postJson(url: string, body: unknown, headers: Record) { const response = await fetch(url, { method: "POST", headers: { "content-type": "application/json", ...headers, }, body: JSON.stringify(body), }); const text = await response.text(); let parsed: unknown = text; try { parsed = text ? JSON.parse(text) : null; } catch { parsed = text; } if (!response.ok) { throw new Error(`Kody request failed (${response.status}): ${getErrorMessage(parsed)}`); } return normalizeResult(parsed); } async function callApi( route: "list-commands" | "run", params: Record, ): Promise { const fileConfig = await loadFileConfig(); const values = prefs(); // Prefer minted webhook URLs (external clients cannot use package-app handoff). const webhookUrl = (route === "list-commands" ? fileConfig?.listCommandsWebhookUrl || values.listCommandsWebhookUrl : fileConfig?.runWebhookUrl || values.runWebhookUrl)?.trim() || ""; if (webhookUrl) { return (await postJson( webhookUrl, { params }, { "idempotency-key": `raycast:${route}:${randomUUID()}` }, )) as T; } // Package-app bearer path only works after a Kody browser handoff — keep as last resort. if (fileConfig?.apiBaseUrl && fileConfig?.token) { const base = fileConfig.apiBaseUrl.replace(/\/+$/, ""); return (await postJson( `${base}/api/${route}`, { params }, { authorization: `Bearer ${fileConfig.token}`, "idempotency-key": `raycast:${route}:${randomUUID()}`, }, )) as T; } throw new Error( `Missing launcher config. Expected ${CONFIG_PATH} with listCommandsWebhookUrl + runWebhookUrl (or Raycast prefs).`, ); } export async function listCommands(options: { forceRefresh?: boolean } = {}) { const ttl = cacheTtlMs(); if (!options.forceRefresh) { const raw = await LocalStorage.getItem(CACHE_KEY); if (raw) { try { const cached = JSON.parse(raw) as CachePayload; if (cached?.fetchedAt && Array.isArray(cached.commands) && Date.now() - cached.fetchedAt < ttl) { return cached.commands; } } catch { // ignore } } } const result = await callApi<{ commands: RaycastCommandSummary[] }>("list-commands", {}); const commands = Array.isArray(result?.commands) ? result.commands : []; await LocalStorage.setItem( CACHE_KEY, JSON.stringify({ fetchedAt: Date.now(), commands } satisfies CachePayload), ); return commands; } export async function runCommand(input: { commandId?: string; code?: string; params?: Record; }) { return await callApi("run", { commandId: input.commandId, code: input.code, params: input.params ?? {}, }); } export function stringifyResult(value: unknown): string { if (typeof value === "string") return value; return JSON.stringify(value, null, 2); } export { CONFIG_PATH };