Spaces:
Running
Running
Leandro von Werra
Bucket backup: a skip list with sane defaults, and a dashboard that admits failure (#34)
e721986 unverified | import type { Cli, Group, MoveTarget, RemoteInfo, RemoteMessage, Session, Tree } from './types'; | |
| const HEADERS = { 'content-type': 'application/json' }; | |
| // Like `json`, but keeps the server's own words — these routes fail for reasons | |
| // worth reading ("already exists here"). | |
| const jsonOrError = async (r: Response) => { | |
| const body = await r.json().catch(() => ({})); | |
| if (!r.ok) throw new Error(body.error || `${r.status}`); | |
| return body; | |
| }; | |
| const json = (r: Response) => { | |
| if (!r.ok) throw new Error(`${r.status}`); | |
| return r.json(); | |
| }; | |
| export const getClis = (): Promise<Cli[]> => fetch('/api/clis').then(json); | |
| export const getTree = (): Promise<Tree> => fetch('/api/tree').then(json); | |
| // path: '.' = the workspaces root; anything else is a workspace-relative dir. | |
| const normalizePath = (path?: string) => (path && path.trim() ? path : '.'); | |
| // Quickstart: create at the workspaces root, boot the CLI, and type the prompt | |
| // as soon as it's up — all server-side, no waiting in the UI. | |
| export const quickStart = (cli: string, prompt: string, name = '', path = '.'): Promise<Session> => | |
| fetch('/api/sessions', { method: 'POST', headers: HEADERS, body: JSON.stringify({ cli, name: name || undefined, path: path || '.', prompt: prompt || undefined }) }).then(json); | |
| export const createSession = (name: string, cli: string, groupId?: string, path?: string): Promise<Session> => | |
| fetch('/api/sessions', { method: 'POST', headers: HEADERS, body: JSON.stringify({ name, cli, groupId, path: normalizePath(path) }) }).then(json); | |
| export const listFolders = (p = ''): Promise<{ path: string; folders: string[] }> => | |
| fetch(`/api/folders?path=${encodeURIComponent(p)}`).then(json); | |
| export const stopSession = (id: string) => | |
| fetch(`/api/sessions/${id}/stop`, { method: 'POST' }).then(json); | |
| export const deleteSession = (id: string) => | |
| fetch(`/api/sessions/${id}`, { method: 'DELETE' }).then(json); | |
| export const renameSession = (id: string, name: string) => | |
| fetch(`/api/sessions/${id}`, { method: 'PUT', headers: HEADERS, body: JSON.stringify({ name }) }).then(json); | |
| export const createGroup = (name: string): Promise<Group> => | |
| fetch('/api/groups', { method: 'POST', headers: HEADERS, body: JSON.stringify({ name }) }).then(json); | |
| export const renameGroup = (id: string, name: string) => | |
| fetch(`/api/groups/${id}`, { method: 'PUT', headers: HEADERS, body: JSON.stringify({ name }) }).then(json); | |
| // Generic group patch: pane order (sessionIds) and/or tile layout (null = auto). | |
| export const updateGroup = (id: string, patch: { sessionIds?: string[]; layout?: { cols: number; rows: number } | null }) => | |
| fetch(`/api/groups/${id}`, { method: 'PUT', headers: HEADERS, body: JSON.stringify(patch) }).then(json); | |
| export const deleteGroup = (id: string) => | |
| fetch(`/api/groups/${id}`, { method: 'DELETE' }).then(json); | |
| export const move = (ref: string, to: MoveTarget) => | |
| fetch('/api/move', { method: 'POST', headers: HEADERS, body: JSON.stringify({ ref, to }) }).then(json); | |
| // Present only when a backup is unwell; null the rest of the time, so the | |
| // dashboard shows nothing in the normal case. | |
| export interface BackupHealth { | |
| failing: boolean; stale: boolean; failures: number; | |
| at: number | null; jobId: string | null; stage: string | null; | |
| message: string | null; reason: string | null; | |
| lastSuccessAt: number | null; jobsUrl: string; | |
| } | |
| export const getInfo = () => fetch('/api/info').then(json); | |
| export const dismissWelcome = () => fetch('/api/welcome/seen', { method: 'POST' }).then(json); | |
| export const setDemo = (active: boolean): Promise<{ ok: boolean; active: boolean }> => | |
| fetch('/api/demo', { method: 'POST', headers: HEADERS, body: JSON.stringify({ active }) }).then(json); | |
| export const relaunchSpace = (): Promise<{ ok: boolean; reason?: string }> => | |
| fetch('/api/relaunch', { method: 'POST' }).then(json); | |
| // ---- app self-update from the upstream repo ---- | |
| export interface UpdateCheck { | |
| ok: boolean; reason?: string; source?: string; sourceUrl?: string; | |
| current?: string | null; latest?: string | null; behind?: boolean; canUpdate?: boolean; | |
| } | |
| export const checkUpdate = (): Promise<UpdateCheck> => fetch('/api/update/check').then(json); | |
| export const runUpdate = (): Promise<{ ok: boolean; reason?: string; upToDate?: boolean }> => | |
| fetch('/api/update', { method: 'POST' }).then(json); | |
| export interface AmConfig { | |
| artifacts: { enabled: boolean; space: string; visibility: 'public' | 'private' }; | |
| jobs: { askAboveUsd: number }; | |
| archive: { after: 'week' | 'month' | 'never' }; | |
| backup: { every: BackupEvery; mirror: string; dataset: string; exclude: string[] }; | |
| defaultArtifactsSpace?: string; | |
| } | |
| export const getConfig = (): Promise<AmConfig> => fetch('/api/config').then(json); | |
| export const saveConfig = (c: AmConfig) => | |
| fetch('/api/config', { method: 'PUT', headers: HEADERS, body: JSON.stringify(c) }).then(json); | |
| // ---- bucket backup: a Job on the Hub does the copying (docs/bucket-backup.md) ---- | |
| export type BackupEvery = 'never' | '1h' | '3h' | '24h'; | |
| export interface BackupStatus { | |
| every: BackupEvery; | |
| source: string | null; | |
| mirror: string; | |
| dataset: string; | |
| defaults: { mirror: string; dataset: string }; | |
| hasToken: boolean; | |
| canRunNow: boolean; | |
| running: boolean; | |
| unavailable: string | null; | |
| // stage is null when the Hub did not answer — never guessed. | |
| last: { at: number; jobId: string | null; stage: string | null } | null; | |
| // One static URL lists every run by its `name=` label, so the row never | |
| // needs a job id to link to them. | |
| jobName: string; | |
| jobsUrl: string; | |
| // The tokens as stored, and the globs they expand to. | |
| exclude: string[]; | |
| excludePatterns: string[]; | |
| health: BackupHealth | null; | |
| failures: number; | |
| lastFailure: { at: number; jobId: string; stage: string; message: string | null; reason: string | null } | null; | |
| lastSuccessAt: number | null; | |
| nextDue: number | null; | |
| datasetPrivate: boolean | null; | |
| error: string | null; | |
| } | |
| export const backupStatus = (): Promise<BackupStatus> => fetch('/api/backup/status').then(json); | |
| // jsonOrError, not json: the refusals worth showing ("a backup is already | |
| // running") are in the body, and json() throws them away for a bare status. | |
| export const runBackup = (): Promise<{ job?: string }> => | |
| fetch('/api/backup/run', { method: 'POST' }).then(jsonOrError); | |
| export interface SecretsData { detected: string[]; notes: Record<string, string>; } | |
| export const getSecrets = (): Promise<SecretsData> => fetch('/api/secrets').then(json); | |
| export const saveSecrets = (notes: Record<string, string>) => | |
| fetch('/api/secrets', { method: 'PUT', headers: HEADERS, body: JSON.stringify({ notes }) }).then(json); | |
| export interface QuotaWindow { usedPercent?: number; resetsAt?: number; windowMinutes?: number; } | |
| export interface ProviderUsage { | |
| tokensToday?: number; costToday?: number; tokensWeek?: number; costWeek?: number; totalCost?: number; | |
| quota?: { fiveHour?: QuotaWindow; weekly?: QuotaWindow; opus?: QuotaWindow; updatedAt?: number; source?: 'live' | 'snapshot' } | null; | |
| } | |
| export interface Usage { providers: Record<string, ProviderUsage>; generatedAt: string; } | |
| export const getUsage = (provider?: string): Promise<Usage> => fetch(provider ? `/api/usage?provider=${provider}` : '/api/usage').then(json); | |
| // ---- overview (meta) ---- | |
| export interface TurnEntry { answer: string; answerMd: string; ts: number; } | |
| export interface MetaDigest { | |
| lastPromptText: string; lastPromptRaw?: string; lastPromptTs: number; | |
| lastAssistantText: string; lastAssistantMd: string; lastAssistantTs: number; | |
| sinceTurns: number; sinceToolCalls: number; sinceTools: Record<string, number>; sinceFiles: string[]; | |
| sinceTokens: number; | |
| running?: boolean; // task in flight (codex task_started/task_complete) | |
| turnsLog?: TurnEntry[]; // newest-first history of completed exchanges | |
| } | |
| export interface MetaSession extends Session { digest: MetaDigest | null } | |
| export const getMeta = (): Promise<{ sessions: MetaSession[]; generatedAt: string }> => | |
| fetch('/api/meta').then(json); | |
| // Targeted digest for one session (progressive tile fill); digest is null when | |
| // this CLI only resolves through the bulk pass. | |
| export const getMetaOne = (id: string): Promise<{ id: string; digest: MetaDigest | null }> => | |
| fetch(`/api/meta/${id}`).then(json); | |
| // ---------- remote agents ---------- | |
| // The pane polls this at the app's usual 2 s cadence. since=0 returns the tail; | |
| // a cursor returns only the delta. | |
| export const getRemoteLog = (id: string, since = 0): Promise<RemoteInfo & { messages: RemoteMessage[] }> => | |
| fetch(`/api/sessions/${id}/remote?since=${since}`).then(json); | |
| // The operator's turn goes through the same route a local agent's does, so the | |
| // server's deliver() shim decides what "typing at it" means. | |
| export const sayToRemote = (id: string, text: string) => sendInput(id, text); | |
| // text/plain, and free of secrets by design — safe to put on a clipboard. | |
| export const getRemotePrompt = (name: string): Promise<string> => | |
| fetch(`/api/remote/${encodeURIComponent(name)}/prompt`).then((r) => { | |
| if (!r.ok) throw new Error(`${r.status}`); | |
| return r.text(); | |
| }); | |
| export const setRemotePaused = (id: string, paused: boolean): Promise<RemoteInfo> => | |
| fetch(`/api/sessions/${id}/remote/paused`, { method: 'POST', headers: HEADERS, body: JSON.stringify({ paused }) }).then(json); | |
| export const sendInput = (id: string, text: string): Promise<{ ok: boolean; started?: boolean }> => | |
| fetch(`/api/sessions/${id}/input`, { method: 'POST', headers: HEADERS, body: JSON.stringify({ text }) }).then(json); | |
| // ---- push notifications ---- | |
| export const getPushKey = (): Promise<{ publicKey: string; devices: number }> => | |
| fetch('/api/push/key').then(json); | |
| export const subscribePush = (subscription: PushSubscription) => | |
| fetch('/api/push/subscribe', { method: 'POST', headers: HEADERS, body: JSON.stringify({ subscription }) }).then(json); | |
| export const unsubscribePush = (endpoint: string) => | |
| fetch('/api/push/unsubscribe', { method: 'POST', headers: HEADERS, body: JSON.stringify({ endpoint }) }).then(json); | |
| export const sendTestNotification = (): Promise<{ ok: boolean; sent: number; devices: number }> => | |
| fetch('/api/notify', { method: 'POST', headers: HEADERS, body: JSON.stringify({ title: 'Agent Manager', body: 'Test notification — agents can reach this device.' }) }).then(json); | |
| // ---- trace analytics ---- | |
| export interface TraceStats { | |
| turns: number; prompts: number; toolCalls: number; tools: Record<string, number>; | |
| web: number; tokensIn: number; tokensOut: number; cacheRead: number; | |
| firstTs: number; lastTs: number; files: number; | |
| } | |
| export interface SessionTraces extends TraceStats { id: string; name: string; cli: string; path: string | null; } | |
| export interface Traces { sessions: SessionTraces[]; totals: TraceStats; generatedAt: string; } | |
| export const getTraces = (): Promise<Traces> => fetch('/api/traces').then(json); | |
| // ---- files ---- | |
| // 'trace' is content-detected, not name-detected: a transcript is a .jsonl like | |
| // any other until /preview reads a line of it (see the route). | |
| export type FileKind = 'text' | 'markdown' | 'html' | 'image' | 'pdf' | 'binary' | 'trace'; | |
| export interface FileEntry { name: string; dir: boolean; size: number; mtime: number; kind?: FileKind; } | |
| export interface FileListing { path: string; root: string; entries: FileEntry[]; } | |
| export interface FilePreview { | |
| path: string; name: string; size: number; mtime: number; kind: FileKind; mime: string; | |
| /** Content tag a save is checked against — null for files too big to edit. */ | |
| tag?: string | null; | |
| text?: string; truncated?: boolean; reason?: string; | |
| /** kind==='trace': which harness wrote it (claude, codex, …). */ | |
| harness?: string | null; | |
| } | |
| export const listFiles = (id: string, p = ''): Promise<FileListing> => | |
| fetch(`/api/files/${id}?path=${encodeURIComponent(p)}`).then(json); | |
| export const previewFile = (id: string, p: string): Promise<FilePreview> => | |
| fetch(`/api/files/${id}/preview?path=${encodeURIComponent(p)}`).then(json); | |
| // One page of a transcript sitting in the workspace, shaped exactly like the | |
| // Trace pane's own pages so the same viewer renders it. | |
| export const getFileTracePage = async (id: string, p: string, offset = 0, limit = 200): Promise<TracePage> => { | |
| const r = await fetch(`/api/files/${id}/trace?path=${encodeURIComponent(p)}&offset=${offset}&limit=${limit}`); | |
| if (!r.ok) { | |
| const d = await r.json().catch(() => ({})); | |
| throw new TraceUnavailable(d.error || 'could not read this trace', d.code || 'no-trace'); | |
| } | |
| return r.json(); | |
| }; | |
| export const rawUrl = (id: string, p: string) => | |
| `/api/files/${id}/raw?path=${encodeURIComponent(p)}`; | |
| export const uploadFile = (id: string, p: string, file: File) => | |
| fetch(`/api/files/${id}/upload?path=${encodeURIComponent(p)}&name=${encodeURIComponent(file.name)}`, { | |
| method: 'POST', headers: { 'content-type': 'application/octet-stream' }, body: file, | |
| }).then(json); | |
| // Create an empty folder / an empty file inside `parent`. The server refuses a | |
| // name that already exists rather than overwriting it. | |
| export const createFolder = (id: string, parent: string, name: string) => | |
| fetch(`/api/files/${id}/mkdir`, { method: 'POST', headers: HEADERS, body: JSON.stringify({ path: parent, name }) }) | |
| .then(jsonOrError); | |
| export const createFile = (id: string, parent: string, name: string) => | |
| fetch(`/api/files/${id}/touch`, { method: 'POST', headers: HEADERS, body: JSON.stringify({ path: parent, name }) }) | |
| .then(jsonOrError); | |
| // Rename in place. The server takes a NAME, so this can never also move it. | |
| export const renameEntry = (id: string, p: string, name: string): Promise<{ path: string }> => | |
| fetch(`/api/files/${id}/rename`, { method: 'POST', headers: HEADERS, body: JSON.stringify({ path: p, name }) }) | |
| .then(jsonOrError); | |
| // Move an entry into another folder, keeping its name. `to` is the destination | |
| // folder ('' = the pane's root). | |
| export const moveEntry = (id: string, p: string, to: string): Promise<{ path: string }> => | |
| fetch(`/api/files/${id}/move`, { method: 'POST', headers: HEADERS, body: JSON.stringify({ path: p, to }) }) | |
| .then(jsonOrError); | |
| // Delete a file, or a folder and everything under it. | |
| export const deleteEntry = (id: string, p: string) => | |
| fetch(`/api/files/${id}/entry?path=${encodeURIComponent(p)}`, { method: 'DELETE' }).then(jsonOrError); | |
| export const downloadUrl = (id: string, p: string) => | |
| `/api/files/${id}/download?path=${encodeURIComponent(p)}`; | |
| // Save an edited text file. `base` is the content tag the editor loaded: the | |
| // server refuses the write if the file's bytes moved on since (an agent edited | |
| // it too). Deliberately NOT mtime — the bucket mount rewrites that on its own | |
| // when it syncs an object, which made every save look like a conflict. | |
| export const writeFile = async (id: string, p: string, text: string, base: string | null): Promise<{ size: number; mtime: number; tag: string | null }> => { | |
| const q = base ? `&base=${encodeURIComponent(base)}` : ''; | |
| const r = await fetch(`/api/files/${id}/write?path=${encodeURIComponent(p)}${q}`, { | |
| method: 'PUT', headers: { 'content-type': 'text/plain; charset=utf-8' }, body: text, | |
| }); | |
| // Unlike the rest of the API, a failed save has something worth reading in it | |
| // ("changed on disk since you opened it") — surface it instead of a number. | |
| const body = await r.json().catch(() => ({})); | |
| if (!r.ok) throw new Error(body.error || `${r.status}`); | |
| return body; | |
| }; | |
| // ---- session sharing (docs/session-sharing.md) ---- | |
| export interface ShareInfo { | |
| namespace: string | null; | |
| canShare: boolean; | |
| reason: 'no-hf-token' | 'unsupported-cli' | 'no-transcript' | null; | |
| lastShare: { repo: string; sha: string | null; visibility: string; at: string } | null; | |
| } | |
| export interface ShareResult { | |
| repo: string; | |
| visibility: 'public' | 'gated'; | |
| url: string; | |
| sha: string | null; | |
| trace: string; | |
| stats: { prompts: number; turns: number; toolCalls: number }; | |
| redaction: Record<string, number>; | |
| dropped: Record<string, number>; | |
| granted: string[]; | |
| grantErrors: { user: string; error: string }[]; | |
| } | |
| // Thrown when the redaction gate refuses a public share (HTTP 409). Carries the | |
| // rule names so the dialog can say exactly what tripped instead of "failed". | |
| export class RedactionBlocked extends Error { | |
| hits: Record<string, number>; | |
| constructor(message: string, hits: Record<string, number>) { | |
| super(message); | |
| this.name = 'RedactionBlocked'; | |
| this.hits = hits; | |
| } | |
| } | |
| export const getShareInfo = (id: string): Promise<ShareInfo> => fetch(`/api/sessions/${id}/share`).then(json); | |
| export const shareSession = async ( | |
| id: string, | |
| body: { visibility: 'public' | 'gated'; name?: string; grantTo?: string[] }, | |
| ): Promise<ShareResult> => { | |
| const r = await fetch(`/api/sessions/${id}/share`, { method: 'POST', headers: HEADERS, body: JSON.stringify(body) }); | |
| if (r.status === 409) { | |
| const d = await r.json().catch(() => ({})); | |
| throw new RedactionBlocked(d.error || 'blocked by the redaction gate', d.hits || {}); | |
| } | |
| if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `${r.status}`); | |
| return r.json(); | |
| }; | |
| export interface ShareAccess { accepted: string[]; pending: string[] } | |
| export const getShareAccess = (repo: string): Promise<ShareAccess> => | |
| fetch(`/api/share/access?repo=${encodeURIComponent(repo)}`).then(json); | |
| export const updateShareAccess = (repo: string, patch: { grant?: string[]; revoke?: string[] }): Promise<ShareAccess> => | |
| fetch('/api/share/access', { method: 'POST', headers: HEADERS, body: JSON.stringify({ repo, ...patch }) }).then(json); | |
| // ---- trace panel (docs/trace-panel-spec.md) ---- | |
| // Blocks, not fields: one renderer handles every harness, and a tool result can | |
| // be filed next to its call even when parallel tools finish out of order. | |
| export type TraceBlock = | |
| | { type: 'text'; text: string; more?: number } | |
| | { type: 'thinking'; text: string; more?: number } | |
| | { type: 'tool_use'; id?: string; name: string; text: string; more?: number } | |
| | { type: 'tool_result'; id?: string; text: string; more?: number; failed?: boolean } | |
| | { type: 'shell'; command: string; stdout?: string; stderr?: string; exitCode?: number } | |
| | { type: 'image'; src: string; mediaType?: string } | |
| | { type: 'compaction'; text: string }; | |
| export interface TraceTurn { | |
| role: 'user' | 'assistant' | 'system'; | |
| kind?: 'final' | 'update'; | |
| ts?: number; | |
| model?: string; | |
| usage?: { in: number; out: number; cacheRead?: number }; | |
| blocks: TraceBlock[]; | |
| } | |
| export interface TracePage { | |
| harness: string; | |
| harnessLabel: string; | |
| sessionId: string | null; | |
| title: string; | |
| model: string | null; | |
| cwd: string | null; | |
| firstTs: number; | |
| lastTs: number; | |
| usage: { in: number; out: number; cacheRead?: number } | null; | |
| // Bundles only: where this trace came from, and who shared it. | |
| source?: { repo: string | null; url: string | null; importedAt: string | null } | null; | |
| sharedBy?: string | null; | |
| // Something true about this trace that isn't a turn — e.g. reasoning the model | |
| // encrypted, which is absent rather than empty. | |
| note?: string | null; | |
| total: number; | |
| offset: number; | |
| limit: number; | |
| truncated: boolean; | |
| // Indices of the operator's prompts across the WHOLE session, so the pane can | |
| // jump to one that hasn't been fetched yet. | |
| userTurns: number[]; | |
| turns: TraceTurn[]; | |
| } | |
| // A 404 here is an expected state (no transcript yet, unsupported CLI, a codex | |
| // guardian rollout), so the reason travels with it for the pane to render. | |
| export class TraceUnavailable extends Error { | |
| code: string; | |
| constructor(message: string, code: string) { | |
| super(message); | |
| this.name = 'TraceUnavailable'; | |
| this.code = code; | |
| } | |
| } | |
| export const getTracePage = async (id: string, offset = 0, limit = 200): Promise<TracePage> => { | |
| const r = await fetch(`/api/trace/${id}?offset=${offset}&limit=${limit}`); | |
| if (r.status === 404) { | |
| const d = await r.json().catch(() => ({})); | |
| throw new TraceUnavailable(d.error || 'no trace for this session yet', d.code || 'no-trace'); | |
| } | |
| if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `${r.status}`); | |
| return r.json(); | |
| }; | |
| export interface TraceLocation { | |
| path: string; | |
| sessionId?: string | null; | |
| source: { kind: 'session' | 'bundle'; ref: string }; | |
| } | |
| export const getTraceLocation = async (id: string): Promise<TraceLocation> => { | |
| const r = await fetch(`/api/trace/${id}/location`); | |
| if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `${r.status}`); | |
| return r.json(); | |
| }; | |
| // Receiving: pull a shared trace off the Hub so a pane can render it. Accepts a | |
| // bare dataset id or a pasted dataset URL (the server normalizes). | |
| export interface ImportedBundle { | |
| ref: string; repo: string; sha: string; files: string[]; bytes: number; | |
| manifest?: { harness?: { name?: string; id?: string }; session?: { name?: string }; origin?: { user?: string } } | null; | |
| } | |
| export const importTraceBundle = async (repo: string): Promise<ImportedBundle> => { | |
| const r = await fetch('/api/trace/import', { method: 'POST', headers: HEADERS, body: JSON.stringify({ repo }) }); | |
| if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `${r.status}`); | |
| return r.json(); | |
| }; | |
| export interface BundleEntry { | |
| ref: string; repo: string | null; url: string | null; importedAt: string | null; | |
| harness: string | null; name: string | null; from: string | null; | |
| } | |
| export const listTraceBundles = (): Promise<{ bundles: BundleEntry[] }> => fetch('/api/trace/bundles').then(json); | |
| export const setTraceSource = (id: string, kind: 'session' | 'bundle', ref: string) => | |
| fetch(`/api/trace/${id}/source`, { method: 'PUT', headers: HEADERS, body: JSON.stringify({ kind, ref }) }).then(json); | |
| // ---- skills ---- | |
| export interface SkillFile { name: string; size: number; } | |
| export const listSkills = (): Promise<SkillFile[]> => fetch('/api/skills').then(json); | |
| export const getSkill = (name: string): Promise<{ name: string; content: string }> => | |
| fetch(`/api/skills/${encodeURIComponent(name)}`).then(json); | |
| export const saveSkill = (name: string, content: string) => | |
| fetch(`/api/skills/${encodeURIComponent(name)}`, { method: 'PUT', headers: { 'content-type': 'text/plain' }, body: content }).then(json); | |
| export const deleteSkill = (name: string) => | |
| fetch(`/api/skills/${encodeURIComponent(name)}`, { method: 'DELETE' }).then(json); | |