diff --git a/README.md b/README.md index e03e8bb..492cb8f 100644 --- a/README.md +++ b/README.md @@ -59,10 +59,11 @@ Two ways, both write the same identity registry the transport reads: It validates the key against Center, then writes `~/.openclaw/fabric-identity.json`. One-time and persistent — *not* per login; the plugin's transport logs in and stays connected on its own. - `AGENT_ID` env wins; otherwise `--agent-id` is required. Other flags: - `--center`, `--identity-file`, `--openclaw-path` (env equivalents: - `FABRIC_API_KEY`, `FABRIC_CENTER_API_BASE`, `FABRIC_IDENTITY_FILE`, - `OPENCLAW_PATH`). Restart the gateway afterwards. + **Only `AGENT_ID` is read from the environment** — if unset, `--agent-id` + is required. `--api-key` is flag-only (never from the env). Other flags: + `--center`, `--identity-file`, `--openclaw-path` (sensible defaults; + `--center` also falls back to `openclaw.json`). Restart the gateway + afterwards. ## Config @@ -90,6 +91,11 @@ Then `openclaw gateway restart`. `create-report-channel` (report) / `create-discussion-channel` (discuss) - `discussion-complete` — post a summary, then close the channel (closed → history readable; new posts → `409`) +- `fabric-canvas` — manage a channel's single pinned canvas document; one + tool, four `action`s: `read` (current canvas or null) · `share` + (create/replace; caller becomes sharer) · `update` (edit in place; + sharer-only) · `close` (remove; sharer-only). `share` needs + `title`/`format`(`md`|`html`|`text`)/`source`. ## Install / build diff --git a/bin/fabric-register.mjs b/bin/fabric-register.mjs index 27f904d..ae1ec02 100644 --- a/bin/fabric-register.mjs +++ b/bin/fabric-register.mjs @@ -14,14 +14,15 @@ * fabric-register --api-key fak_xxx # uses $AGENT_ID * fabric-register --agent-id echo --api-key fak_xxx * - * Flags / env: - * --agent-id (or env AGENT_ID; one of them required) - * --api-key (or env FABRIC_API_KEY; required) - * --center (or env FABRIC_CENTER_API_BASE; else openclaw.json; - * else http://localhost:7001/api) - * --identity-file (or env FABRIC_IDENTITY_FILE; else - * ~/.openclaw/fabric-identity.json) - * --openclaw-path (or env OPENCLAW_PATH; else ~/.openclaw) + * Only AGENT_ID is read from the environment; everything else is a flag. + * + * Flags: + * --agent-id required unless the AGENT_ID env var is set + * --api-key required (flag only — never from the environment) + * --center else openclaw.json channels.fabric.centerApiBase; + * else http://localhost:7001/api + * --identity-file default ~/.openclaw/fabric-identity.json + * --openclaw-path default ~/.openclaw * -h | --help */ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; @@ -51,14 +52,16 @@ const HELP = `fabric-register — bind an OpenClaw agent to a Fabric Center API fabric-register --api-key fak_xxx # agent id from $AGENT_ID fabric-register --agent-id --api-key fak_xxx - --agent-id required unless $AGENT_ID is set - --api-key required (or env FABRIC_API_KEY) - --center Center API base (or env FABRIC_CENTER_API_BASE; - else openclaw.json channels.fabric.centerApiBase; - else http://localhost:7001/api) + --agent-id required unless the AGENT_ID env var is set + --api-key required (flag only — never read from the env) + --center Center API base (else openclaw.json + channels.fabric.centerApiBase; else + http://localhost:7001/api) --identity-file default ~/.openclaw/fabric-identity.json --openclaw-path default ~/.openclaw -h, --help + +Only AGENT_ID is taken from the environment; everything else is a flag. `; function fail(msg) { @@ -81,22 +84,17 @@ async function main() { fail('no agent id: set the AGENT_ID environment variable or pass --agent-id '); } - const apiKey = - (typeof a['api-key'] === 'string' && a['api-key'].trim()) || - (process.env.FABRIC_API_KEY && process.env.FABRIC_API_KEY.trim()); - if (!apiKey) fail('missing --api-key (or env FABRIC_API_KEY)'); + // api key: flag ONLY — never from the environment. + const apiKey = typeof a['api-key'] === 'string' && a['api-key'].trim(); + if (!apiKey) fail('missing --api-key (flag only)'); const openclawPath = resolve( (typeof a['openclaw-path'] === 'string' && a['openclaw-path']) || - process.env.OPENCLAW_PATH || join(homedir(), '.openclaw'), ); - // center api base: flag > env > openclaw.json > default - let center = - (typeof a.center === 'string' && a.center) || - process.env.FABRIC_CENTER_API_BASE || - ''; + // center api base: flag > openclaw.json > default + let center = (typeof a.center === 'string' && a.center) || ''; if (!center) { try { const cfg = JSON.parse(readFileSync(join(openclawPath, 'openclaw.json'), 'utf8')); @@ -110,7 +108,6 @@ async function main() { const identityFile = resolve( (typeof a['identity-file'] === 'string' && a['identity-file']) || - process.env.FABRIC_IDENTITY_FILE || join(openclawPath, 'fabric-identity.json'), ); diff --git a/dist/fabric/src/fabric-client.js b/dist/fabric/src/fabric-client.js index c1f3f4f..99a30af 100644 --- a/dist/fabric/src/fabric-client.js +++ b/dist/fabric/src/fabric-client.js @@ -21,6 +21,24 @@ export class FabricClient { } return (await res.json()); } + // Generic JSON request (GET/PUT/PATCH/DELETE). Empty 2xx body -> null + // (Fabric returns an empty body when a channel has no canvas). + async req(method, url, auth, body) { + 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); + } // Exchange an agent API key for a Fabric user session (+ guild tokens). agentLogin(apiKey) { return this.post(`${this.centerApiBase}/auth/agent/login`, { apiKey }); @@ -50,4 +68,24 @@ export class FabricClient { joinChannel(guildEndpoint, guildToken, channelId) { return this.post(`${guildEndpoint}/api/channels/${channelId}/join`, {}, guildToken); } + // ---- channel canvas (one pinned doc per channel) ---- + canvasUrl(endpoint, channelId) { + return `${endpoint}/api/channels/${channelId}/canvas`; + } + // null when the channel has no canvas + getCanvas(endpoint, token, channelId) { + return this.req('GET', this.canvasUrl(endpoint, channelId), token); + } + // share / replace (caller becomes the sharer) + shareCanvas(endpoint, token, channelId, body) { + return this.req('PUT', this.canvasUrl(endpoint, channelId), token, body); + } + // update in place (original sharer only — else the guild returns 403) + updateCanvas(endpoint, token, channelId, body) { + return this.req('PATCH', this.canvasUrl(endpoint, channelId), token, body); + } + // remove ("close") the canvas (original sharer only) + removeCanvas(endpoint, token, channelId) { + return this.req('DELETE', this.canvasUrl(endpoint, channelId), token); + } } diff --git a/dist/fabric/src/tools.js b/dist/fabric/src/tools.js index c93f35b..80b1c52 100644 --- a/dist/fabric/src/tools.js +++ b/dist/fabric/src/tools.js @@ -89,4 +89,78 @@ export function registerFabricTools(api, client, identity) { 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) => ({ + 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) => { + 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 = {}; + 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)}` }; + } + }, + })); } diff --git a/src/fabric-client.ts b/src/fabric-client.ts index 8c82c22..6bae623 100644 --- a/src/fabric-client.ts +++ b/src/fabric-client.ts @@ -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( + method: string, + url: string, + auth?: string, + body?: unknown, + ): Promise { + 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 { return this.post(`${this.centerApiBase}/auth/agent/login`, { apiKey }); @@ -86,4 +110,57 @@ export class FabricClient { joinChannel(guildEndpoint: string, guildToken: string, channelId: string): Promise { 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 { + 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 { + 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, + ): Promise { + 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 { + 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; +}; diff --git a/src/tools.ts b/src/tools.ts index d1c4ead..217db53 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -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)}` }; + } + }, + })); }