Skip to content

extension/src/kody-client.ts

181 lines · 4.9 KB · TypeScript
Raw
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<FileConfig | null> {
  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<Preferences>();
}

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<string, unknown>;
  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<string, string>) {
  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<T>(
  route: "list-commands" | "run",
  params: Record<string, unknown>,
): Promise<T> {
  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<string>(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<string, unknown>;
}) {
  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 };