feat(plugin): fabric-canvas tool; fabric-register env=AGENT_ID only

- bin/fabric-register.mjs: only AGENT_ID is read from the environment;
  --api-key is flag-only (no FABRIC_API_KEY); dropped FABRIC_CENTER_API_BASE
  / FABRIC_IDENTITY_FILE / OPENCLAW_PATH env fallbacks (flags + sensible
  defaults; --center still falls back to openclaw.json).
- New fabric-canvas tool (one tool, four actions): read / share / update /
  close the channel's single pinned canvas. Backed by FabricClient
  get/share/update/removeCanvas (GET/PUT/PATCH/DELETE; empty 2xx body ->
  null). update/close are sharer-only server-side.
- README updated.

Verified: client-level smoke against the running guild —
read(empty→null) → share(v1) → read → update(v2) → close(→null) all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-16 15:28:13 +01:00
parent 26c12533fb
commit aaabb0ddb0
6 changed files with 299 additions and 28 deletions

View File

@@ -29,6 +29,30 @@ export class FabricClient {
return (await res.json()) as T;
}
// Generic JSON request (GET/PUT/PATCH/DELETE). Empty 2xx body -> null
// (Fabric returns an empty body when a channel has no canvas).
private async req<T>(
method: string,
url: string,
auth?: string,
body?: unknown,
): Promise<T> {
const res = await fetch(url, {
method,
headers: {
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
...(auth ? { authorization: `Bearer ${auth}` } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`${method} ${url} -> ${res.status} ${text}`);
}
const text = await res.text();
return (text ? JSON.parse(text) : null) as T;
}
// Exchange an agent API key for a Fabric user session (+ guild tokens).
agentLogin(apiKey: string): Promise<FabricSession> {
return this.post<FabricSession>(`${this.centerApiBase}/auth/agent/login`, { apiKey });
@@ -86,4 +110,57 @@ export class FabricClient {
joinChannel(guildEndpoint: string, guildToken: string, channelId: string): Promise<unknown> {
return this.post(`${guildEndpoint}/api/channels/${channelId}/join`, {}, guildToken);
}
// ---- channel canvas (one pinned doc per channel) ----
private canvasUrl(endpoint: string, channelId: string): string {
return `${endpoint}/api/channels/${channelId}/canvas`;
}
// null when the channel has no canvas
getCanvas(
endpoint: string,
token: string,
channelId: string,
): Promise<FabricCanvas | null> {
return this.req('GET', this.canvasUrl(endpoint, channelId), token);
}
// share / replace (caller becomes the sharer)
shareCanvas(
endpoint: string,
token: string,
channelId: string,
body: CanvasInput,
): Promise<FabricCanvas> {
return this.req('PUT', this.canvasUrl(endpoint, channelId), token, body);
}
// update in place (original sharer only — else the guild returns 403)
updateCanvas(
endpoint: string,
token: string,
channelId: string,
body: Partial<CanvasInput>,
): Promise<FabricCanvas> {
return this.req('PATCH', this.canvasUrl(endpoint, channelId), token, body);
}
// remove ("close") the canvas (original sharer only)
removeCanvas(endpoint: string, token: string, channelId: string): Promise<unknown> {
return this.req('DELETE', this.canvasUrl(endpoint, channelId), token);
}
}
export type CanvasFormat = 'md' | 'html' | 'text';
export type CanvasInput = { title: string; format: CanvasFormat; source: string };
export type FabricCanvas = {
channelId: string;
sharerUserId: string;
title: string;
format: CanvasFormat;
source: string;
version: number;
createdAt: string;
updatedAt: string;
};

View File

@@ -120,4 +120,83 @@ export function registerFabricTools(
return { ok: true, closed: true };
},
}));
// fabric-canvas: share / update / read / close the channel's single
// pinned canvas document (one tool, four actions). update/close are
// sharer-only server-side (the guild returns 403 otherwise).
api.registerTool((ctx: Ctx) => ({
name: 'fabric-canvas',
description:
"Manage a channel's pinned canvas document. action: " +
"read (current canvas or null) | share (create/replace; you become " +
'the sharer) | update (edit in place; sharer only) | close (remove; ' +
'sharer only).',
parameters: {
type: 'object',
additionalProperties: false,
required: ['action', 'guildNodeId', 'channelId'],
properties: {
action: { type: 'string', enum: ['read', 'share', 'update', 'close'] },
guildNodeId: { type: 'string' },
channelId: { type: 'string' },
title: { type: 'string', description: 'share: required; update: optional' },
format: {
type: 'string',
enum: ['md', 'html', 'text'],
description: 'share: required; update: optional',
},
source: {
type: 'string',
description: 'document body. share: required; update: optional',
},
},
},
execute: async (p: {
action: 'read' | 'share' | 'update' | 'close';
guildNodeId: string;
channelId: string;
title?: string;
format?: 'md' | 'html' | 'text';
source?: string;
}) => {
const agentId = ctx.agentId;
if (!agentId) return { ok: false, error: 'no agent context' };
const { guild, token } = await ctxGuild(agentId, p.guildNodeId);
const ep = guild.endpoint;
switch (p.action) {
case 'read': {
const canvas = await client.getCanvas(ep, token, p.channelId);
return { ok: true, canvas };
}
case 'share': {
if (!p.title || !p.format || p.source === undefined) {
return { ok: false, error: 'share requires title, format, and source' };
}
const canvas = await client.shareCanvas(ep, token, p.channelId, {
title: p.title,
format: p.format,
source: p.source,
});
return { ok: true, canvas };
}
case 'update': {
const body: Partial<{ title: string; format: 'md' | 'html' | 'text'; source: string }> = {};
if (p.title !== undefined) body.title = p.title;
if (p.format !== undefined) body.format = p.format;
if (p.source !== undefined) body.source = p.source;
if (Object.keys(body).length === 0) {
return { ok: false, error: 'update needs at least one of title/format/source' };
}
const canvas = await client.updateCanvas(ep, token, p.channelId, body);
return { ok: true, canvas };
}
case 'close': {
await client.removeCanvas(ep, token, p.channelId);
return { ok: true, removed: true };
}
default:
return { ok: false, error: `unknown action ${String(p.action)}` };
}
},
}));
}