From 6c6088675fbf7d3a6fe4890238d89f926fce12d5 Mon Sep 17 00:00:00 2001 From: Miles McGuire Date: Wed, 1 Apr 2026 16:23:12 +0200 Subject: [PATCH 1/4] Add maintenance windows and backups tools - list_maintenance_windows: Lists maintenance schedules for Vitess databases with human-readable formatting (day, time, frequency, duration) and optional window history showing start/finish times of past runs - list_backups: Lists backup policies (schedule, retention, next run) and optionally fetches recent backup history with size, duration, and state Both tools follow the existing inline-fetch pattern and use PlanetScaleAPIError for error handling. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/gram.ts | 6 +- src/tools/list-backups.ts | 224 ++++++++++++++++++++++++++ src/tools/list-maintenance-windows.ts | 211 ++++++++++++++++++++++++ 3 files changed, 440 insertions(+), 1 deletion(-) create mode 100644 src/tools/list-backups.ts create mode 100644 src/tools/list-maintenance-windows.ts diff --git a/src/gram.ts b/src/gram.ts index 9459ebf..3a89350 100644 --- a/src/gram.ts +++ b/src/gram.ts @@ -6,6 +6,8 @@ import { executeWriteQueryGram } from "./tools/execute-write-query.ts"; import { getInsightsGram } from "./tools/get-insights.ts"; import { listClusterSizesGram } from "./tools/list-cluster-sizes.ts"; import { searchDocumentationGram } from "./tools/search-documentation.ts"; +import { listMaintenanceWindowsGram } from "./tools/list-maintenance-windows.ts"; +import { listBackupsGram } from "./tools/list-backups.ts"; const gram = new Gram({ envSchema: { @@ -25,6 +27,8 @@ const gram = new Gram({ .extend(executeWriteQueryGram) .extend(getInsightsGram) .extend(listClusterSizesGram) - .extend(searchDocumentationGram); + .extend(searchDocumentationGram) + .extend(listMaintenanceWindowsGram) + .extend(listBackupsGram); export default gram; diff --git a/src/tools/list-backups.ts b/src/tools/list-backups.ts new file mode 100644 index 0000000..cb609ab --- /dev/null +++ b/src/tools/list-backups.ts @@ -0,0 +1,224 @@ +import { Gram } from "@gram-ai/functions"; +import { z } from "zod"; +import { PlanetScaleAPIError } from "../lib/planetscale-api.ts"; +import { getAuthToken, getAuthHeader } from "../lib/auth.ts"; + +const API_BASE = "https://api.planetscale.com/v1"; + +interface BackupPolicy { + id: string; + type: string; + display_name: string; + name: string; + target: "production" | "development"; + retention_value: number; + retention_unit: string; + frequency_value: number; + frequency_unit: string; + schedule_time: string; + schedule_day: number | null; + schedule_week: number | null; + created_at: string; + updated_at: string; + last_ran_at: string | null; + next_run_at: string | null; + required: boolean; +} + +interface Backup { + id: string; + type: string; + name: string; + state: "pending" | "running" | "success" | "failed" | "canceled" | "ignored"; + size: number; + estimated_storage_cost: number; + created_at: string; + updated_at: string; + started_at: string | null; + expires_at: string | null; + completed_at: string | null; + deleted_at: string | null; + pvc_size: number; + protected: boolean; + required: boolean; + backup_policy: BackupPolicy | null; + database_branch: { id: string; name: string } | null; + restored_branches: { id: string; name: string }[]; +} + +interface PaginatedList { + type: string; + current_page: number; + next_page: number | null; + next_page_url: string | null; + prev_page: number | null; + prev_page_url: string | null; + data: T[]; +} + +async function fetchJson(url: string, authHeader: string): Promise { + const response = await fetch(url, { + method: "GET", + headers: { Authorization: authHeader, Accept: "application/json" }, + }); + + if (!response.ok) { + let details: unknown; + try { details = await response.json(); } catch { details = await response.text(); } + throw new PlanetScaleAPIError(`API request failed: ${response.statusText}`, response.status, details); + } + + return (await response.json()) as T; +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB", "PB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; +} + +function formatPolicy(p: BackupPolicy) { + const freq = + p.frequency_unit === "hour" && p.frequency_value === 24 + ? "daily" + : `every ${p.frequency_value} ${p.frequency_unit}${p.frequency_value > 1 ? "s" : ""}`; + const retention = `${p.retention_value} ${p.retention_unit}${p.retention_value > 1 ? "s" : ""}`; + + return { + id: p.id, + name: p.display_name, + target: p.target, + schedule: `${freq} at ${p.schedule_time} UTC`, + retention, + required: p.required, + last_ran_at: p.last_ran_at, + next_run_at: p.next_run_at, + }; +} + +function formatBackup(b: Backup) { + const duration = + b.started_at && b.completed_at + ? `${((new Date(b.completed_at).getTime() - new Date(b.started_at).getTime()) / 60000).toFixed(0)} min` + : null; + + return { + id: b.id, + name: b.name, + state: b.state, + size: formatBytes(b.size), + started_at: b.started_at, + completed_at: b.completed_at, + duration, + expires_at: b.expires_at, + policy: b.backup_policy?.display_name ?? null, + protected: b.protected, + }; +} + +export const listBackupsGram = new Gram().tool({ + name: "list_backups", + description: + "List backup policies and recent backups for a PlanetScale database. Shows configured backup schedules (frequency, retention, next run) and recent backup history with size, duration, and state. Useful for verifying backup health, checking when the last successful backup ran, or understanding retention policies.", + inputSchema: { + organization: z.string().describe("PlanetScale organization name"), + database: z.string().describe("Database name"), + branch: z + .string() + .optional() + .describe( + "Branch name to list backups for (e.g., 'main'). Required when include_backups is true.", + ), + include_backups: z + .boolean() + .optional() + .describe( + "Fetch recent backups for the specified branch (default: false). Requires branch to be set.", + ), + backup_state: z + .enum(["pending", "running", "success", "failed", "canceled", "ignored"]) + .optional() + .describe("Filter backups by state (default: all states)."), + per_page: z + .number() + .optional() + .describe( + "Number of recent backups to return when include_backups is true (default: 5, max: 25).", + ), + }, + async execute(ctx, input) { + try { + const env = + Object.keys(ctx.env).length > 0 + ? (ctx.env as Record) + : process.env; + + const auth = getAuthToken(env); + if (!auth) { + return ctx.text("Error: No PlanetScale authentication configured."); + } + + const { organization, database } = input; + if (!organization || !database) { + return ctx.text("Error: organization and database are required."); + } + + const authHeader = getAuthHeader(env); + const e = encodeURIComponent; + + const policies = await fetchJson>( + `${API_BASE}/organizations/${e(organization)}/databases/${e(database)}/backup-policies`, + authHeader, + ); + + const formattedPolicies = policies.data.map(formatPolicy); + + if (!input.include_backups) { + return ctx.json({ + organization, + database, + policies: formattedPolicies, + }); + } + + if (!input.branch) { + return ctx.text( + "Error: branch is required when include_backups is true.", + ); + } + + const perPage = Math.min(input.per_page ?? 5, 25); + const params = new URLSearchParams({ per_page: String(perPage) }); + if (input.backup_state) params.set("state", input.backup_state); + + const backups = await fetchJson>( + `${API_BASE}/organizations/${e(organization)}/databases/${e(database)}/branches/${e(input.branch)}/backups?${params}`, + authHeader, + ); + + return ctx.json({ + organization, + database, + branch: input.branch, + policies: formattedPolicies, + recent_backups: backups.data.map(formatBackup), + }); + } catch (error) { + if (error instanceof PlanetScaleAPIError) { + if (error.statusCode === 404) { + return ctx.text( + "Error: Not found. Check that the organization, database, and branch names are correct. (status: 404)", + ); + } + return ctx.text( + `Error: ${error.message} (status: ${error.statusCode})`, + ); + } + if (error instanceof Error) { + return ctx.text(`Error: ${error.message}`); + } + return ctx.text("Error: An unexpected error occurred"); + } + }, +}); diff --git a/src/tools/list-maintenance-windows.ts b/src/tools/list-maintenance-windows.ts new file mode 100644 index 0000000..c11097d --- /dev/null +++ b/src/tools/list-maintenance-windows.ts @@ -0,0 +1,211 @@ +import { Gram } from "@gram-ai/functions"; +import { z } from "zod"; +import { PlanetScaleAPIError } from "../lib/planetscale-api.ts"; +import { getAuthToken, getAuthHeader } from "../lib/auth.ts"; + +const API_BASE = "https://api.planetscale.com/v1"; + +interface Actor { + id: string; + type: string; + display_name: string; + avatar_url: string; +} + +interface DatabaseBranch { + id: string; + type: string; + name: string; + created_at: string; + updated_at: string; +} + +interface MaintenanceSchedule { + id: string; + type: string; + name: string; + created_at: string; + updated_at: string; + last_window_datetime: string | null; + next_window_datetime: string | null; + duration: number; + day: number; + hour: number; + week: number | null; + frequency_value: number; + frequency_unit: "day" | "week" | "month" | "once"; + enabled: boolean; + expires_at: string | null; + deadline_at: string | null; + required: boolean; + pending_vitess_version_update: boolean; + pending_vitess_version: string | null; + actor: Actor; + database_branch: DatabaseBranch; +} + +interface MaintenanceWindow { + id: string; + type: string; + created_at: string; + updated_at: string; + started_at: string; + finished_at: string | null; +} + +interface PaginatedList { + type: string; + current_page: number; + next_page: number | null; + next_page_url: string | null; + prev_page: number | null; + prev_page_url: string | null; + data: T[]; +} + +const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Every day"]; + +async function fetchJson(url: string, authHeader: string): Promise { + const response = await fetch(url, { + method: "GET", + headers: { Authorization: authHeader, Accept: "application/json" }, + }); + + if (!response.ok) { + let details: unknown; + try { details = await response.json(); } catch { details = await response.text(); } + throw new PlanetScaleAPIError(`API request failed: ${response.statusText}`, response.status, details); + } + + return (await response.json()) as T; +} + +function formatSchedule(s: MaintenanceSchedule) { + const day = DAY_NAMES[s.day] ?? `day ${s.day}`; + const time = `${String(s.hour).padStart(2, "0")}:00 UTC`; + const freq = + s.frequency_unit === "once" + ? "one-time" + : `every ${s.frequency_value > 1 ? `${s.frequency_value} ${s.frequency_unit}s` : s.frequency_unit}`; + + return { + id: s.id, + name: s.name, + branch: s.database_branch.name, + schedule: `${freq}, ${day} at ${time}, ${s.duration}h window`, + enabled: s.enabled, + required: s.required, + next_window: s.next_window_datetime, + last_window: s.last_window_datetime, + pending_vitess_update: s.pending_vitess_version_update + ? s.pending_vitess_version + : null, + }; +} + +export const listMaintenanceWindowsGram = new Gram().tool({ + name: "list_maintenance_windows", + description: + "Vitess/MySQL databases only. List maintenance schedules and their recent windows for a PlanetScale database. Shows when maintenance is scheduled (day, time, frequency, duration), whether a Vitess version update is pending, and the history of recent maintenance windows with start/finish times. Useful for understanding when a database was or will be under maintenance.", + inputSchema: { + organization: z.string().describe("PlanetScale organization name"), + database: z.string().describe("Database name"), + include_windows: z + .boolean() + .optional() + .describe( + "Fetch recent maintenance windows for each schedule (default: false). Shows the start/finish times of past maintenance runs.", + ), + windows_per_schedule: z + .number() + .optional() + .describe( + "Number of recent windows to fetch per schedule when include_windows is true (default: 5, max: 25).", + ), + }, + async execute(ctx, input) { + try { + const env = + Object.keys(ctx.env).length > 0 + ? (ctx.env as Record) + : process.env; + + const auth = getAuthToken(env); + if (!auth) { + return ctx.text("Error: No PlanetScale authentication configured."); + } + + const { organization, database } = input; + if (!organization || !database) { + return ctx.text("Error: organization and database are required."); + } + + const authHeader = getAuthHeader(env); + const e = encodeURIComponent; + + const schedules = await fetchJson>( + `${API_BASE}/organizations/${e(organization)}/databases/${e(database)}/maintenance-schedules`, + authHeader, + ); + + if (schedules.data.length === 0) { + return ctx.json({ + organization, + database, + schedules: [], + message: "No maintenance schedules found for this database.", + }); + } + + const formatted = schedules.data.map(formatSchedule); + + if (!input.include_windows) { + return ctx.json({ organization, database, schedules: formatted }); + } + + const perPage = Math.min(input.windows_per_schedule ?? 5, 25); + + const windowResults = await Promise.allSettled( + schedules.data.map((s) => + fetchJson>( + `${API_BASE}/organizations/${e(organization)}/databases/${e(database)}/maintenance-schedules/${e(s.id)}/windows?per_page=${perPage}`, + authHeader, + ).then((res) => ({ + schedule_id: s.id, + windows: res.data.map((w) => ({ + started_at: w.started_at, + finished_at: w.finished_at, + })), + })), + ), + ); + + const windowsBySchedule = new Map(); + for (const r of windowResults) { + if (r.status === "fulfilled") { + windowsBySchedule.set(r.value.schedule_id, r.value.windows); + } + } + + const schedulesWithWindows = formatted.map((s) => ({ + ...s, + recent_windows: windowsBySchedule.get(s.id) ?? [], + })); + + return ctx.json({ organization, database, schedules: schedulesWithWindows }); + } catch (error) { + if (error instanceof PlanetScaleAPIError) { + if (error.statusCode === 404) { + return ctx.text( + "Error: Not found. Check that the organization and database names are correct, and that the database is a Vitess/MySQL database (maintenance schedules are not available for Postgres). (status: 404)", + ); + } + return ctx.text(`Error: ${error.message} (status: ${error.statusCode})`); + } + if (error instanceof Error) { + return ctx.text(`Error: ${error.message}`); + } + return ctx.text("Error: An unexpected error occurred"); + } + }, +}); From 46ddc676dc97fde3dfe121a55bff19f9fc742329 Mon Sep 17 00:00:00 2001 From: Miles McGuire Date: Wed, 1 Apr 2026 17:15:37 +0200 Subject: [PATCH 2/4] Address review feedback: deduplicate shared code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export apiRequest from planetscale-api.ts and use it in both tools instead of duplicated fetchJson helpers - Export formatBytes from list-cluster-sizes.ts and reuse it in list-backups.ts instead of a divergent copy - Fix inconsistent frequency formatting in list-backups ("every 1 hour" → "every hour") to match list-maintenance-windows Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/planetscale-api.ts | 2 +- src/tools/list-backups.ts | 37 +++++---------------------- src/tools/list-cluster-sizes.ts | 2 +- src/tools/list-maintenance-windows.ts | 27 ++++--------------- 4 files changed, 14 insertions(+), 54 deletions(-) diff --git a/src/lib/planetscale-api.ts b/src/lib/planetscale-api.ts index b5957fa..94fb9e3 100644 --- a/src/lib/planetscale-api.ts +++ b/src/lib/planetscale-api.ts @@ -71,7 +71,7 @@ export class PlanetScaleAPIError extends Error { } } -async function apiRequest( +export async function apiRequest( endpoint: string, authHeader: string, options: RequestInit = {} diff --git a/src/tools/list-backups.ts b/src/tools/list-backups.ts index cb609ab..468c71d 100644 --- a/src/tools/list-backups.ts +++ b/src/tools/list-backups.ts @@ -1,9 +1,8 @@ import { Gram } from "@gram-ai/functions"; import { z } from "zod"; -import { PlanetScaleAPIError } from "../lib/planetscale-api.ts"; +import { PlanetScaleAPIError, apiRequest } from "../lib/planetscale-api.ts"; import { getAuthToken, getAuthHeader } from "../lib/auth.ts"; - -const API_BASE = "https://api.planetscale.com/v1"; +import { formatBytes } from "./list-cluster-sizes.ts"; interface BackupPolicy { id: string; @@ -56,33 +55,11 @@ interface PaginatedList { data: T[]; } -async function fetchJson(url: string, authHeader: string): Promise { - const response = await fetch(url, { - method: "GET", - headers: { Authorization: authHeader, Accept: "application/json" }, - }); - - if (!response.ok) { - let details: unknown; - try { details = await response.json(); } catch { details = await response.text(); } - throw new PlanetScaleAPIError(`API request failed: ${response.statusText}`, response.status, details); - } - - return (await response.json()) as T; -} - -function formatBytes(bytes: number): string { - if (bytes === 0) return "0 B"; - const units = ["B", "KB", "MB", "GB", "TB", "PB"]; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; -} - function formatPolicy(p: BackupPolicy) { const freq = p.frequency_unit === "hour" && p.frequency_value === 24 ? "daily" - : `every ${p.frequency_value} ${p.frequency_unit}${p.frequency_value > 1 ? "s" : ""}`; + : `every ${p.frequency_value > 1 ? `${p.frequency_value} ${p.frequency_unit}s` : p.frequency_unit}`; const retention = `${p.retention_value} ${p.retention_unit}${p.retention_value > 1 ? "s" : ""}`; return { @@ -167,8 +144,8 @@ export const listBackupsGram = new Gram().tool({ const authHeader = getAuthHeader(env); const e = encodeURIComponent; - const policies = await fetchJson>( - `${API_BASE}/organizations/${e(organization)}/databases/${e(database)}/backup-policies`, + const policies = await apiRequest>( + `/organizations/${e(organization)}/databases/${e(database)}/backup-policies`, authHeader, ); @@ -192,8 +169,8 @@ export const listBackupsGram = new Gram().tool({ const params = new URLSearchParams({ per_page: String(perPage) }); if (input.backup_state) params.set("state", input.backup_state); - const backups = await fetchJson>( - `${API_BASE}/organizations/${e(organization)}/databases/${e(database)}/branches/${e(input.branch)}/backups?${params}`, + const backups = await apiRequest>( + `/organizations/${e(organization)}/databases/${e(database)}/branches/${e(input.branch)}/backups?${params}`, authHeader, ); diff --git a/src/tools/list-cluster-sizes.ts b/src/tools/list-cluster-sizes.ts index 28e36e1..afd551b 100644 --- a/src/tools/list-cluster-sizes.ts +++ b/src/tools/list-cluster-sizes.ts @@ -47,7 +47,7 @@ export interface TierSummary { /** * Format byte count to human-readable string (e.g. 1073741824 -> "1 GB") */ -function formatBytes(bytes: number): string { +export function formatBytes(bytes: number): string { if (bytes === 0) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; const i = Math.floor(Math.log(bytes) / Math.log(1024)); diff --git a/src/tools/list-maintenance-windows.ts b/src/tools/list-maintenance-windows.ts index c11097d..9a6df51 100644 --- a/src/tools/list-maintenance-windows.ts +++ b/src/tools/list-maintenance-windows.ts @@ -1,10 +1,8 @@ import { Gram } from "@gram-ai/functions"; import { z } from "zod"; -import { PlanetScaleAPIError } from "../lib/planetscale-api.ts"; +import { PlanetScaleAPIError, apiRequest } from "../lib/planetscale-api.ts"; import { getAuthToken, getAuthHeader } from "../lib/auth.ts"; -const API_BASE = "https://api.planetscale.com/v1"; - interface Actor { id: string; type: string; @@ -65,21 +63,6 @@ interface PaginatedList { const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Every day"]; -async function fetchJson(url: string, authHeader: string): Promise { - const response = await fetch(url, { - method: "GET", - headers: { Authorization: authHeader, Accept: "application/json" }, - }); - - if (!response.ok) { - let details: unknown; - try { details = await response.json(); } catch { details = await response.text(); } - throw new PlanetScaleAPIError(`API request failed: ${response.statusText}`, response.status, details); - } - - return (await response.json()) as T; -} - function formatSchedule(s: MaintenanceSchedule) { const day = DAY_NAMES[s.day] ?? `day ${s.day}`; const time = `${String(s.hour).padStart(2, "0")}:00 UTC`; @@ -143,8 +126,8 @@ export const listMaintenanceWindowsGram = new Gram().tool({ const authHeader = getAuthHeader(env); const e = encodeURIComponent; - const schedules = await fetchJson>( - `${API_BASE}/organizations/${e(organization)}/databases/${e(database)}/maintenance-schedules`, + const schedules = await apiRequest>( + `/organizations/${e(organization)}/databases/${e(database)}/maintenance-schedules`, authHeader, ); @@ -167,8 +150,8 @@ export const listMaintenanceWindowsGram = new Gram().tool({ const windowResults = await Promise.allSettled( schedules.data.map((s) => - fetchJson>( - `${API_BASE}/organizations/${e(organization)}/databases/${e(database)}/maintenance-schedules/${e(s.id)}/windows?per_page=${perPage}`, + apiRequest>( + `/organizations/${e(organization)}/databases/${e(database)}/maintenance-schedules/${e(s.id)}/windows?per_page=${perPage}`, authHeader, ).then((res) => ({ schedule_id: s.id, From ec54e34af99324b167a8ac9f30ef93b3f5752ef5 Mon Sep 17 00:00:00 2001 From: Miles McGuire Date: Wed, 1 Apr 2026 17:17:04 +0200 Subject: [PATCH 3/4] Move formatBytes to shared lib/format.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avoids cross-tool imports — both list-cluster-sizes and list-backups now import from lib/format.ts instead of one tool importing from the other. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/format.ts | 11 +++++++++++ src/tools/list-backups.ts | 2 +- src/tools/list-cluster-sizes.ts | 12 +----------- 3 files changed, 13 insertions(+), 12 deletions(-) create mode 100644 src/lib/format.ts diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..5bedf20 --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,11 @@ +/** + * Format byte count to human-readable string (e.g. 1073741824 -> "1 GB") + */ +export function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + const value = bytes / Math.pow(1024, i); + const unit = units[Math.min(i, units.length - 1)]; + return value % 1 === 0 ? `${value} ${unit}` : `${value.toFixed(1)} ${unit}`; +} diff --git a/src/tools/list-backups.ts b/src/tools/list-backups.ts index 468c71d..6f2b0fc 100644 --- a/src/tools/list-backups.ts +++ b/src/tools/list-backups.ts @@ -2,7 +2,7 @@ import { Gram } from "@gram-ai/functions"; import { z } from "zod"; import { PlanetScaleAPIError, apiRequest } from "../lib/planetscale-api.ts"; import { getAuthToken, getAuthHeader } from "../lib/auth.ts"; -import { formatBytes } from "./list-cluster-sizes.ts"; +import { formatBytes } from "../lib/format.ts"; interface BackupPolicy { id: string; diff --git a/src/tools/list-cluster-sizes.ts b/src/tools/list-cluster-sizes.ts index afd551b..d3ef690 100644 --- a/src/tools/list-cluster-sizes.ts +++ b/src/tools/list-cluster-sizes.ts @@ -44,17 +44,7 @@ export interface TierSummary { storage_options?: string[]; } -/** - * Format byte count to human-readable string (e.g. 1073741824 -> "1 GB") - */ -export function formatBytes(bytes: number): string { - if (bytes === 0) return "0 B"; - const units = ["B", "KB", "MB", "GB", "TB"]; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - const value = bytes / Math.pow(1024, i); - const unit = units[Math.min(i, units.length - 1)]; - return value % 1 === 0 ? `${value} ${unit}` : `${value.toFixed(1)} ${unit}`; -} +import { formatBytes } from "../lib/format.ts"; /** * Format CPU string for display (e.g. "1" -> "1 vCPU", "1/2" -> "1/2 vCPU") From 778443448d713e36f6afd0ee97846ee3e2964172 Mon Sep 17 00:00:00 2001 From: Miles McGuire Date: Wed, 1 Apr 2026 17:53:32 +0200 Subject: [PATCH 4/4] Move formatBytes import to top of list-cluster-sizes.ts Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tools/list-cluster-sizes.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/tools/list-cluster-sizes.ts b/src/tools/list-cluster-sizes.ts index d3ef690..b52a0bc 100644 --- a/src/tools/list-cluster-sizes.ts +++ b/src/tools/list-cluster-sizes.ts @@ -2,6 +2,7 @@ import { Gram } from "@gram-ai/functions"; import { z } from "zod"; import { PlanetScaleAPIError } from "../lib/planetscale-api.ts"; import { getAuthToken, getAuthHeader } from "../lib/auth.ts"; +import { formatBytes } from "../lib/format.ts"; const API_BASE = "https://api.planetscale.com/v1"; @@ -44,8 +45,6 @@ export interface TierSummary { storage_options?: string[]; } -import { formatBytes } from "../lib/format.ts"; - /** * Format CPU string for display (e.g. "1" -> "1 vCPU", "1/2" -> "1/2 vCPU") */