← Public packages
@kody/raycast
Raycast launcher: store execute commands in package storage and run them over minted webhooks.
extension/src/kody-client.ts
181 lines · 4.9 KB · TypeScriptimport { 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 };