@2en/clawly-plugins 1.4.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/calendar.ts +257 -0
- package/email.ts +313 -0
- package/gateway-fetch.ts +41 -0
- package/index.ts +12 -20
- package/openclaw.plugin.json +3 -40
- package/package.json +4 -3
- package/clawhub2gateway.ts +0 -405
- package/memory.ts +0 -187
package/calendar.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calendar integration via Clawly backend.
|
|
3
|
+
*
|
|
4
|
+
* Gateway methods (external RPC):
|
|
5
|
+
* - calendar.listCalendars — list available calendars
|
|
6
|
+
* - calendar.listEvents — list events in a time range
|
|
7
|
+
* - calendar.createEvent — create a new calendar event
|
|
8
|
+
*
|
|
9
|
+
* Agent tools (LLM-callable):
|
|
10
|
+
* - calendar_list_calendars — list available calendars
|
|
11
|
+
* - calendar_list_events — list events in a time range
|
|
12
|
+
* - calendar_create_event — create a new calendar event
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type {PluginApi} from './index'
|
|
16
|
+
import type {GatewayCfg, HandlerResult} from './gateway-fetch'
|
|
17
|
+
import {gatewayFetch, toToolResult} from './gateway-fetch'
|
|
18
|
+
|
|
19
|
+
// ── Handlers ──
|
|
20
|
+
|
|
21
|
+
async function handleListCalendars(
|
|
22
|
+
cfg: GatewayCfg,
|
|
23
|
+
params: Record<string, unknown>,
|
|
24
|
+
): Promise<HandlerResult> {
|
|
25
|
+
const qs = typeof params.provider === 'string' ? `?provider=${params.provider}` : ''
|
|
26
|
+
const res = await gatewayFetch(cfg, `/calendar/calendars${qs}`)
|
|
27
|
+
const data = await res.json()
|
|
28
|
+
if (!res.ok)
|
|
29
|
+
return {
|
|
30
|
+
ok: false,
|
|
31
|
+
error: {code: data.error ?? 'error', message: data.message ?? res.statusText},
|
|
32
|
+
}
|
|
33
|
+
return {ok: true, data}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function handleListEvents(
|
|
37
|
+
cfg: GatewayCfg,
|
|
38
|
+
params: Record<string, unknown>,
|
|
39
|
+
): Promise<HandlerResult> {
|
|
40
|
+
const calendarId = typeof params.calendarId === 'string' ? params.calendarId : ''
|
|
41
|
+
const start =
|
|
42
|
+
typeof params.start === 'number'
|
|
43
|
+
? params.start
|
|
44
|
+
: typeof params.start === 'string'
|
|
45
|
+
? Number(params.start)
|
|
46
|
+
: 0
|
|
47
|
+
const end =
|
|
48
|
+
typeof params.end === 'number'
|
|
49
|
+
? params.end
|
|
50
|
+
: typeof params.end === 'string'
|
|
51
|
+
? Number(params.end)
|
|
52
|
+
: 0
|
|
53
|
+
|
|
54
|
+
if (!calendarId || !start || !end)
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
error: {code: 'invalid_params', message: 'calendarId, start, and end are required'},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const qs = new URLSearchParams({calendar_id: calendarId, start: String(start), end: String(end)})
|
|
61
|
+
if (typeof params.provider === 'string') qs.set('provider', params.provider)
|
|
62
|
+
|
|
63
|
+
const res = await gatewayFetch(cfg, `/calendar/events?${qs.toString()}`)
|
|
64
|
+
const data = await res.json()
|
|
65
|
+
if (!res.ok)
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
error: {code: data.error ?? 'error', message: data.message ?? res.statusText},
|
|
69
|
+
}
|
|
70
|
+
return {ok: true, data}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function handleCreateEvent(
|
|
74
|
+
cfg: GatewayCfg,
|
|
75
|
+
params: Record<string, unknown>,
|
|
76
|
+
): Promise<HandlerResult> {
|
|
77
|
+
const calendarId = typeof params.calendarId === 'string' ? params.calendarId : ''
|
|
78
|
+
const title = typeof params.title === 'string' ? params.title : ''
|
|
79
|
+
const startTime = typeof params.startTime === 'number' ? params.startTime : 0
|
|
80
|
+
const endTime = typeof params.endTime === 'number' ? params.endTime : 0
|
|
81
|
+
|
|
82
|
+
if (!calendarId || !title || !startTime || !endTime)
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
error: {
|
|
86
|
+
code: 'invalid_params',
|
|
87
|
+
message: 'calendarId, title, startTime, and endTime are required',
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const res = await gatewayFetch(cfg, '/calendar/events', {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
body: JSON.stringify({
|
|
94
|
+
calendarId,
|
|
95
|
+
title,
|
|
96
|
+
startTime,
|
|
97
|
+
endTime,
|
|
98
|
+
description: typeof params.description === 'string' ? params.description : undefined,
|
|
99
|
+
location: typeof params.location === 'string' ? params.location : undefined,
|
|
100
|
+
participants: Array.isArray(params.participants) ? params.participants : undefined,
|
|
101
|
+
provider: typeof params.provider === 'string' ? params.provider : undefined,
|
|
102
|
+
}),
|
|
103
|
+
})
|
|
104
|
+
const data = await res.json()
|
|
105
|
+
if (!res.ok)
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
error: {code: data.error ?? 'error', message: data.message ?? res.statusText},
|
|
109
|
+
}
|
|
110
|
+
return {ok: true, data}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Registration ──
|
|
114
|
+
|
|
115
|
+
export function registerCalendar(api: PluginApi, cfg: GatewayCfg) {
|
|
116
|
+
// Gateway methods (external RPC)
|
|
117
|
+
|
|
118
|
+
api.registerGatewayMethod('calendar.listCalendars', async ({params, respond}) => {
|
|
119
|
+
try {
|
|
120
|
+
const r = await handleListCalendars(cfg, params)
|
|
121
|
+
r.ok ? respond(true, r.data) : respond(false, undefined, r.error)
|
|
122
|
+
} catch (err) {
|
|
123
|
+
respond(false, undefined, {
|
|
124
|
+
code: 'error',
|
|
125
|
+
message: err instanceof Error ? err.message : String(err),
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
api.registerGatewayMethod('calendar.listEvents', async ({params, respond}) => {
|
|
131
|
+
try {
|
|
132
|
+
const r = await handleListEvents(cfg, params)
|
|
133
|
+
r.ok ? respond(true, r.data) : respond(false, undefined, r.error)
|
|
134
|
+
} catch (err) {
|
|
135
|
+
respond(false, undefined, {
|
|
136
|
+
code: 'error',
|
|
137
|
+
message: err instanceof Error ? err.message : String(err),
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
api.registerGatewayMethod('calendar.createEvent', async ({params, respond}) => {
|
|
143
|
+
try {
|
|
144
|
+
const r = await handleCreateEvent(cfg, params)
|
|
145
|
+
r.ok ? respond(true, r.data) : respond(false, undefined, r.error)
|
|
146
|
+
} catch (err) {
|
|
147
|
+
respond(false, undefined, {
|
|
148
|
+
code: 'error',
|
|
149
|
+
message: err instanceof Error ? err.message : String(err),
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
// Agent tools (LLM-callable)
|
|
155
|
+
|
|
156
|
+
api.registerTool({
|
|
157
|
+
name: 'calendar_list_calendars',
|
|
158
|
+
description:
|
|
159
|
+
'List available calendars for the connected account. ' +
|
|
160
|
+
'If no calendar account is connected, the result will contain a CONNECTION_REQUIRED error.',
|
|
161
|
+
parameters: {
|
|
162
|
+
type: 'object',
|
|
163
|
+
properties: {
|
|
164
|
+
provider: {type: 'string', description: 'Calendar provider: google or microsoft'},
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
async execute(_toolCallId, params) {
|
|
168
|
+
try {
|
|
169
|
+
return toToolResult(await handleListCalendars(cfg, params))
|
|
170
|
+
} catch (err) {
|
|
171
|
+
return {
|
|
172
|
+
content: [
|
|
173
|
+
{
|
|
174
|
+
type: 'text',
|
|
175
|
+
text: JSON.stringify({error: err instanceof Error ? err.message : String(err)}),
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
api.registerTool({
|
|
184
|
+
name: 'calendar_list_events',
|
|
185
|
+
description: 'List calendar events in a time range.',
|
|
186
|
+
parameters: {
|
|
187
|
+
type: 'object',
|
|
188
|
+
required: ['calendarId', 'start', 'end'],
|
|
189
|
+
properties: {
|
|
190
|
+
calendarId: {type: 'string', description: 'Calendar ID (from calendar_list_calendars)'},
|
|
191
|
+
start: {type: 'number', description: 'Start time as Unix timestamp (seconds)'},
|
|
192
|
+
end: {type: 'number', description: 'End time as Unix timestamp (seconds)'},
|
|
193
|
+
provider: {type: 'string', description: 'Calendar provider: google or microsoft'},
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
async execute(_toolCallId, params) {
|
|
197
|
+
try {
|
|
198
|
+
return toToolResult(await handleListEvents(cfg, params))
|
|
199
|
+
} catch (err) {
|
|
200
|
+
return {
|
|
201
|
+
content: [
|
|
202
|
+
{
|
|
203
|
+
type: 'text',
|
|
204
|
+
text: JSON.stringify({error: err instanceof Error ? err.message : String(err)}),
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
api.registerTool({
|
|
213
|
+
name: 'calendar_create_event',
|
|
214
|
+
description: 'Create a new calendar event.',
|
|
215
|
+
parameters: {
|
|
216
|
+
type: 'object',
|
|
217
|
+
required: ['calendarId', 'title', 'startTime', 'endTime'],
|
|
218
|
+
properties: {
|
|
219
|
+
calendarId: {type: 'string', description: 'Calendar ID (from calendar_list_calendars)'},
|
|
220
|
+
title: {type: 'string', description: 'Event title'},
|
|
221
|
+
startTime: {type: 'number', description: 'Start time as Unix timestamp (seconds)'},
|
|
222
|
+
endTime: {type: 'number', description: 'End time as Unix timestamp (seconds)'},
|
|
223
|
+
description: {type: 'string', description: 'Event description'},
|
|
224
|
+
location: {type: 'string', description: 'Event location'},
|
|
225
|
+
participants: {
|
|
226
|
+
type: 'array',
|
|
227
|
+
items: {
|
|
228
|
+
type: 'object',
|
|
229
|
+
properties: {name: {type: 'string'}, email: {type: 'string'}},
|
|
230
|
+
required: ['email'],
|
|
231
|
+
},
|
|
232
|
+
description: 'Event participants',
|
|
233
|
+
},
|
|
234
|
+
provider: {type: 'string', description: 'Calendar provider: google or microsoft'},
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
async execute(_toolCallId, params) {
|
|
238
|
+
try {
|
|
239
|
+
return toToolResult(await handleCreateEvent(cfg, params))
|
|
240
|
+
} catch (err) {
|
|
241
|
+
return {
|
|
242
|
+
content: [
|
|
243
|
+
{
|
|
244
|
+
type: 'text',
|
|
245
|
+
text: JSON.stringify({error: err instanceof Error ? err.message : String(err)}),
|
|
246
|
+
},
|
|
247
|
+
],
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
api.logger.info(
|
|
254
|
+
'calendar: registered gateway methods (calendar.listCalendars, calendar.listEvents, calendar.createEvent) ' +
|
|
255
|
+
'and agent tools (calendar_list_calendars, calendar_list_events, calendar_create_event)',
|
|
256
|
+
)
|
|
257
|
+
}
|
package/email.ts
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email integration via Clawly backend.
|
|
3
|
+
*
|
|
4
|
+
* Gateway methods (external RPC):
|
|
5
|
+
* - email.search — search/list email messages
|
|
6
|
+
* - email.read — read a single email message
|
|
7
|
+
* - email.send — send an email
|
|
8
|
+
* - email.getConnectUrl — get OAuth URL to connect an email account
|
|
9
|
+
*
|
|
10
|
+
* Agent tools (LLM-callable):
|
|
11
|
+
* - email_search — search/list email messages
|
|
12
|
+
* - email_read — read a single email message by ID
|
|
13
|
+
* - email_send — send an email
|
|
14
|
+
* - email_connect — get OAuth URL to connect an email account
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type {PluginApi} from './index'
|
|
18
|
+
import type {GatewayCfg, HandlerResult} from './gateway-fetch'
|
|
19
|
+
import {gatewayFetch, toToolResult} from './gateway-fetch'
|
|
20
|
+
|
|
21
|
+
// ── Handlers ──
|
|
22
|
+
|
|
23
|
+
async function handleSearch(
|
|
24
|
+
cfg: GatewayCfg,
|
|
25
|
+
params: Record<string, unknown>,
|
|
26
|
+
): Promise<HandlerResult> {
|
|
27
|
+
const qs = new URLSearchParams()
|
|
28
|
+
if (typeof params.query === 'string') qs.set('query', params.query)
|
|
29
|
+
if (typeof params.from === 'string') qs.set('from', params.from)
|
|
30
|
+
if (typeof params.to === 'string') qs.set('to', params.to)
|
|
31
|
+
if (typeof params.folder === 'string') qs.set('in', params.folder)
|
|
32
|
+
if (typeof params.limit === 'number') qs.set('limit', String(params.limit))
|
|
33
|
+
if (typeof params.provider === 'string') qs.set('provider', params.provider)
|
|
34
|
+
|
|
35
|
+
const res = await gatewayFetch(cfg, `/email/messages?${qs.toString()}`)
|
|
36
|
+
const data = await res.json()
|
|
37
|
+
if (!res.ok)
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
error: {code: data.error ?? 'error', message: data.message ?? res.statusText},
|
|
41
|
+
}
|
|
42
|
+
return {ok: true, data}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function handleRead(
|
|
46
|
+
cfg: GatewayCfg,
|
|
47
|
+
params: Record<string, unknown>,
|
|
48
|
+
): Promise<HandlerResult> {
|
|
49
|
+
const id = typeof params.id === 'string' ? params.id : ''
|
|
50
|
+
if (!id) return {ok: false, error: {code: 'invalid_params', message: 'id is required'}}
|
|
51
|
+
|
|
52
|
+
const qs = typeof params.provider === 'string' ? `?provider=${params.provider}` : ''
|
|
53
|
+
const res = await gatewayFetch(cfg, `/email/messages/${encodeURIComponent(id)}${qs}`)
|
|
54
|
+
const data = await res.json()
|
|
55
|
+
if (!res.ok)
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
error: {code: data.error ?? 'error', message: data.message ?? res.statusText},
|
|
59
|
+
}
|
|
60
|
+
return {ok: true, data}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function handleSend(
|
|
64
|
+
cfg: GatewayCfg,
|
|
65
|
+
params: Record<string, unknown>,
|
|
66
|
+
): Promise<HandlerResult> {
|
|
67
|
+
const to = Array.isArray(params.to)
|
|
68
|
+
? params.to
|
|
69
|
+
: typeof params.to === 'string'
|
|
70
|
+
? [{email: params.to}]
|
|
71
|
+
: []
|
|
72
|
+
const subject = typeof params.subject === 'string' ? params.subject : ''
|
|
73
|
+
const body = typeof params.body === 'string' ? params.body : ''
|
|
74
|
+
|
|
75
|
+
if (!to.length || !subject || !body)
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
error: {code: 'invalid_params', message: 'to, subject, and body are required'},
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const res = await gatewayFetch(cfg, '/email/messages/send', {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
body: JSON.stringify({
|
|
84
|
+
to,
|
|
85
|
+
subject,
|
|
86
|
+
body,
|
|
87
|
+
replyToMessageId:
|
|
88
|
+
typeof params.replyToMessageId === 'string' ? params.replyToMessageId : undefined,
|
|
89
|
+
provider: typeof params.provider === 'string' ? params.provider : undefined,
|
|
90
|
+
}),
|
|
91
|
+
})
|
|
92
|
+
const data = await res.json()
|
|
93
|
+
if (!res.ok)
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
error: {code: data.error ?? 'error', message: data.message ?? res.statusText},
|
|
97
|
+
}
|
|
98
|
+
return {ok: true, data}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function handleGetConnectUrl(
|
|
102
|
+
cfg: GatewayCfg,
|
|
103
|
+
params: Record<string, unknown>,
|
|
104
|
+
): Promise<HandlerResult> {
|
|
105
|
+
const provider = typeof params.provider === 'string' ? params.provider : undefined
|
|
106
|
+
const res = await gatewayFetch(cfg, '/connections/start', {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
body: JSON.stringify({provider}),
|
|
109
|
+
})
|
|
110
|
+
const data = await res.json()
|
|
111
|
+
if (!res.ok)
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
error: {code: data.error ?? 'error', message: data.message ?? res.statusText},
|
|
115
|
+
}
|
|
116
|
+
return {ok: true, data}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Registration ──
|
|
120
|
+
|
|
121
|
+
export function registerEmail(api: PluginApi, cfg: GatewayCfg) {
|
|
122
|
+
// Gateway methods (external RPC)
|
|
123
|
+
|
|
124
|
+
api.registerGatewayMethod('email.search', async ({params, respond}) => {
|
|
125
|
+
try {
|
|
126
|
+
const r = await handleSearch(cfg, params)
|
|
127
|
+
r.ok ? respond(true, r.data) : respond(false, undefined, r.error)
|
|
128
|
+
} catch (err) {
|
|
129
|
+
respond(false, undefined, {
|
|
130
|
+
code: 'error',
|
|
131
|
+
message: err instanceof Error ? err.message : String(err),
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
api.registerGatewayMethod('email.read', async ({params, respond}) => {
|
|
137
|
+
try {
|
|
138
|
+
const r = await handleRead(cfg, params)
|
|
139
|
+
r.ok ? respond(true, r.data) : respond(false, undefined, r.error)
|
|
140
|
+
} catch (err) {
|
|
141
|
+
respond(false, undefined, {
|
|
142
|
+
code: 'error',
|
|
143
|
+
message: err instanceof Error ? err.message : String(err),
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
api.registerGatewayMethod('email.send', async ({params, respond}) => {
|
|
149
|
+
try {
|
|
150
|
+
const r = await handleSend(cfg, params)
|
|
151
|
+
r.ok ? respond(true, r.data) : respond(false, undefined, r.error)
|
|
152
|
+
} catch (err) {
|
|
153
|
+
respond(false, undefined, {
|
|
154
|
+
code: 'error',
|
|
155
|
+
message: err instanceof Error ? err.message : String(err),
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
api.registerGatewayMethod('email.getConnectUrl', async ({params, respond}) => {
|
|
161
|
+
try {
|
|
162
|
+
const r = await handleGetConnectUrl(cfg, params)
|
|
163
|
+
r.ok ? respond(true, r.data) : respond(false, undefined, r.error)
|
|
164
|
+
} catch (err) {
|
|
165
|
+
respond(false, undefined, {
|
|
166
|
+
code: 'error',
|
|
167
|
+
message: err instanceof Error ? err.message : String(err),
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// Agent tools (LLM-callable)
|
|
173
|
+
|
|
174
|
+
api.registerTool({
|
|
175
|
+
name: 'email_search',
|
|
176
|
+
description:
|
|
177
|
+
'Search or list email messages. Returns a list of email messages matching the query. ' +
|
|
178
|
+
'If no email account is connected, the result will contain a CONNECTION_REQUIRED error ' +
|
|
179
|
+
'with available providers — use email_connect to get an OAuth URL.',
|
|
180
|
+
parameters: {
|
|
181
|
+
type: 'object',
|
|
182
|
+
properties: {
|
|
183
|
+
query: {type: 'string', description: 'Search query (e.g. "from:alice subject:meeting")'},
|
|
184
|
+
from: {type: 'string', description: 'Filter by sender email'},
|
|
185
|
+
to: {type: 'string', description: 'Filter by recipient email'},
|
|
186
|
+
folder: {type: 'string', description: 'Folder name (e.g. INBOX, SENT)'},
|
|
187
|
+
limit: {type: 'number', description: 'Max results (default 10)'},
|
|
188
|
+
provider: {type: 'string', description: 'Email provider: google or microsoft'},
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
async execute(_toolCallId, params) {
|
|
192
|
+
try {
|
|
193
|
+
return toToolResult(await handleSearch(cfg, params))
|
|
194
|
+
} catch (err) {
|
|
195
|
+
return {
|
|
196
|
+
content: [
|
|
197
|
+
{
|
|
198
|
+
type: 'text',
|
|
199
|
+
text: JSON.stringify({error: err instanceof Error ? err.message : String(err)}),
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
api.registerTool({
|
|
208
|
+
name: 'email_read',
|
|
209
|
+
description: 'Read a single email message by ID. Returns full message content including body.',
|
|
210
|
+
parameters: {
|
|
211
|
+
type: 'object',
|
|
212
|
+
required: ['id'],
|
|
213
|
+
properties: {
|
|
214
|
+
id: {type: 'string', description: 'Message ID'},
|
|
215
|
+
provider: {type: 'string', description: 'Email provider: google or microsoft'},
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
async execute(_toolCallId, params) {
|
|
219
|
+
try {
|
|
220
|
+
return toToolResult(await handleRead(cfg, params))
|
|
221
|
+
} catch (err) {
|
|
222
|
+
return {
|
|
223
|
+
content: [
|
|
224
|
+
{
|
|
225
|
+
type: 'text',
|
|
226
|
+
text: JSON.stringify({error: err instanceof Error ? err.message : String(err)}),
|
|
227
|
+
},
|
|
228
|
+
],
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
api.registerTool({
|
|
235
|
+
name: 'email_send',
|
|
236
|
+
description: 'Send an email. Requires a connected email account.',
|
|
237
|
+
parameters: {
|
|
238
|
+
type: 'object',
|
|
239
|
+
required: ['to', 'subject', 'body'],
|
|
240
|
+
properties: {
|
|
241
|
+
to: {
|
|
242
|
+
oneOf: [
|
|
243
|
+
{type: 'string', description: 'Recipient email address'},
|
|
244
|
+
{
|
|
245
|
+
type: 'array',
|
|
246
|
+
items: {
|
|
247
|
+
type: 'object',
|
|
248
|
+
properties: {name: {type: 'string'}, email: {type: 'string'}},
|
|
249
|
+
required: ['email'],
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
description: 'Recipient(s) — single email string or array of {name?, email}',
|
|
254
|
+
},
|
|
255
|
+
subject: {type: 'string', description: 'Email subject'},
|
|
256
|
+
body: {type: 'string', description: 'Email body (plain text or HTML)'},
|
|
257
|
+
replyToMessageId: {type: 'string', description: 'Message ID to reply to'},
|
|
258
|
+
provider: {type: 'string', description: 'Email provider: google or microsoft'},
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
async execute(_toolCallId, params) {
|
|
262
|
+
try {
|
|
263
|
+
return toToolResult(await handleSend(cfg, params))
|
|
264
|
+
} catch (err) {
|
|
265
|
+
return {
|
|
266
|
+
content: [
|
|
267
|
+
{
|
|
268
|
+
type: 'text',
|
|
269
|
+
text: JSON.stringify({error: err instanceof Error ? err.message : String(err)}),
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
api.registerTool({
|
|
278
|
+
name: 'email_connect',
|
|
279
|
+
description:
|
|
280
|
+
'Get an OAuth URL to connect an email account (Gmail or Outlook). ' +
|
|
281
|
+
'Returns an authorizeUrl that the user should open in a browser to authorize access. ' +
|
|
282
|
+
'Use this when email operations return CONNECTION_REQUIRED.',
|
|
283
|
+
parameters: {
|
|
284
|
+
type: 'object',
|
|
285
|
+
properties: {
|
|
286
|
+
provider: {
|
|
287
|
+
type: 'string',
|
|
288
|
+
enum: ['google', 'microsoft'],
|
|
289
|
+
description: 'Email provider to connect (google for Gmail, microsoft for Outlook)',
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
async execute(_toolCallId, params) {
|
|
294
|
+
try {
|
|
295
|
+
return toToolResult(await handleGetConnectUrl(cfg, params))
|
|
296
|
+
} catch (err) {
|
|
297
|
+
return {
|
|
298
|
+
content: [
|
|
299
|
+
{
|
|
300
|
+
type: 'text',
|
|
301
|
+
text: JSON.stringify({error: err instanceof Error ? err.message : String(err)}),
|
|
302
|
+
},
|
|
303
|
+
],
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
api.logger.info(
|
|
310
|
+
'email: registered gateway methods (email.search, email.read, email.send, email.getConnectUrl) ' +
|
|
311
|
+
'and agent tools (email_search, email_read, email_send, email_connect)',
|
|
312
|
+
)
|
|
313
|
+
}
|
package/gateway-fetch.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for plugins that proxy to the Clawly model-gateway backend.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {PluginApi} from './index'
|
|
6
|
+
|
|
7
|
+
export type GatewayCfg = {baseUrl: string; token: string}
|
|
8
|
+
|
|
9
|
+
export type HandlerResult = {ok: boolean; data?: unknown; error?: {code: string; message: string}}
|
|
10
|
+
|
|
11
|
+
export function getGatewayConfig(api: PluginApi): GatewayCfg {
|
|
12
|
+
const cfg = api.pluginConfig && typeof api.pluginConfig === 'object' ? api.pluginConfig : {}
|
|
13
|
+
const baseUrl =
|
|
14
|
+
typeof (cfg as Record<string, unknown>).gatewayBaseUrl === 'string'
|
|
15
|
+
? ((cfg as Record<string, unknown>).gatewayBaseUrl as string).replace(/\/$/, '')
|
|
16
|
+
: ''
|
|
17
|
+
const token =
|
|
18
|
+
typeof (cfg as Record<string, unknown>).gatewayToken === 'string'
|
|
19
|
+
? ((cfg as Record<string, unknown>).gatewayToken as string)
|
|
20
|
+
: ''
|
|
21
|
+
return {baseUrl, token}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function gatewayFetch(
|
|
25
|
+
cfg: GatewayCfg,
|
|
26
|
+
path: string,
|
|
27
|
+
init?: RequestInit,
|
|
28
|
+
): Promise<Response> {
|
|
29
|
+
return fetch(`${cfg.baseUrl}${path}`, {
|
|
30
|
+
...init,
|
|
31
|
+
headers: {
|
|
32
|
+
...(init?.headers ?? {}),
|
|
33
|
+
Authorization: `Bearer ${cfg.token}`,
|
|
34
|
+
'Content-Type': 'application/json',
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function toToolResult(result: HandlerResult) {
|
|
40
|
+
return {content: [{type: 'text', text: JSON.stringify(result.ok ? result.data : result.error)}]}
|
|
41
|
+
}
|
package/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OpenClaw plugin: Clawly utility RPC methods (clawly.*)
|
|
2
|
+
* OpenClaw plugin: Clawly utility RPC methods (clawly.*).
|
|
3
3
|
*
|
|
4
4
|
* Gateway methods:
|
|
5
5
|
* - clawly.file.getOutbound — read a persisted outbound file by original-path hash
|
|
@@ -8,9 +8,6 @@
|
|
|
8
8
|
* - clawly.notification.send — send a push notification directly
|
|
9
9
|
* - clawly.agent.send — send a message to the agent (+ optional push)
|
|
10
10
|
* - clawly.agent.echo — echo-wrapped agent message (bypasses LLM)
|
|
11
|
-
* - memory-browser.list — list all .md files in the memory directory
|
|
12
|
-
* - memory-browser.get — return content of a single .md file
|
|
13
|
-
* - clawhub2gateway.* — ClawHub CLI RPC bridge (search/install/update/list/explore/inspect/star/unstar)
|
|
14
11
|
*
|
|
15
12
|
* Agent tools:
|
|
16
13
|
* - clawly_is_user_online — check if user's device is connected
|
|
@@ -25,29 +22,18 @@
|
|
|
25
22
|
*/
|
|
26
23
|
|
|
27
24
|
import {registerAgentSend} from './agent-send'
|
|
25
|
+
import {registerCalendar} from './calendar'
|
|
28
26
|
import {registerIsUserOnlineTool, registerSendAppPushTool} from './tools'
|
|
29
27
|
import {registerClawlyCronChannel} from './channel'
|
|
30
|
-
import {registerClawhub2gateway} from './clawhub2gateway'
|
|
31
28
|
import {registerCronHook} from './cron-hook'
|
|
32
29
|
import {registerEchoCommand} from './echo'
|
|
33
|
-
import {
|
|
30
|
+
import {registerEmail} from './email'
|
|
31
|
+
import {getGatewayConfig} from './gateway-fetch'
|
|
34
32
|
import {registerNotification} from './notification'
|
|
35
33
|
import {registerOutboundHook, registerOutboundMethods} from './outbound'
|
|
36
34
|
import {registerPresence} from './presence'
|
|
37
35
|
|
|
38
36
|
type PluginRuntime = {
|
|
39
|
-
system?: {
|
|
40
|
-
runCommandWithTimeout?: (
|
|
41
|
-
argv: string[],
|
|
42
|
-
opts: {timeoutMs: number; cwd?: string; env?: Record<string, string | undefined>},
|
|
43
|
-
) => Promise<{
|
|
44
|
-
stdout: string
|
|
45
|
-
stderr: string
|
|
46
|
-
code: number | null
|
|
47
|
-
signal: string | null
|
|
48
|
-
killed: boolean
|
|
49
|
-
}>
|
|
50
|
-
}
|
|
51
37
|
state?: {
|
|
52
38
|
resolveStateDir?: (env?: NodeJS.ProcessEnv) => string
|
|
53
39
|
}
|
|
@@ -108,8 +94,14 @@ export default {
|
|
|
108
94
|
registerSendAppPushTool(api)
|
|
109
95
|
registerClawlyCronChannel(api)
|
|
110
96
|
registerCronHook(api)
|
|
111
|
-
|
|
112
|
-
|
|
97
|
+
|
|
98
|
+
// Email & calendar (optional — requires gatewayBaseUrl + gatewayToken in config)
|
|
99
|
+
const gw = getGatewayConfig(api)
|
|
100
|
+
if (gw.baseUrl && gw.token) {
|
|
101
|
+
registerEmail(api, gw)
|
|
102
|
+
registerCalendar(api, gw)
|
|
103
|
+
}
|
|
104
|
+
|
|
113
105
|
api.logger.info(`Loaded ${api.id} plugin.`)
|
|
114
106
|
},
|
|
115
107
|
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,51 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "clawly-plugins",
|
|
3
3
|
"name": "Clawly Plugins",
|
|
4
|
-
"description": "Clawly utility RPC methods (clawly.*): file access, presence, push notifications, agent messaging
|
|
4
|
+
"description": "Clawly utility RPC methods (clawly.*): file access, presence, push notifications, and agent messaging.",
|
|
5
5
|
"version": "0.2.0",
|
|
6
|
-
"uiHints": {
|
|
7
|
-
"memoryDir": {
|
|
8
|
-
"label": "Memory directory",
|
|
9
|
-
"help": "Absolute path to the memory directory containing .md files. Defaults to <OPENCLAW_WORKSPACE>/memory.",
|
|
10
|
-
"placeholder": "/data/memory"
|
|
11
|
-
},
|
|
12
|
-
"bin": {
|
|
13
|
-
"label": "clawhub binary",
|
|
14
|
-
"help": "Command to run (must be in PATH on the gateway host).",
|
|
15
|
-
"placeholder": "clawhub"
|
|
16
|
-
},
|
|
17
|
-
"defaultDir": {
|
|
18
|
-
"label": "Default skills dir",
|
|
19
|
-
"help": "Maps to `clawhub --dir` when not provided per-call.",
|
|
20
|
-
"placeholder": "skills"
|
|
21
|
-
},
|
|
22
|
-
"defaultNoInput": {
|
|
23
|
-
"label": "Default no-input",
|
|
24
|
-
"help": "If true, adds `--no-input` unless the tool call overrides it.",
|
|
25
|
-
"advanced": true
|
|
26
|
-
},
|
|
27
|
-
"defaultTimeoutMs": {
|
|
28
|
-
"label": "Default timeout (ms)",
|
|
29
|
-
"help": "Default command timeout for most tool calls.",
|
|
30
|
-
"advanced": true
|
|
31
|
-
},
|
|
32
|
-
"configPath": {
|
|
33
|
-
"label": "CLAWHUB_CONFIG_PATH",
|
|
34
|
-
"help": "Where the ClawHub CLI stores auth/config. Defaults to `<OPENCLAW_STATE_DIR>/clawhub/config.json`.",
|
|
35
|
-
"advanced": true,
|
|
36
|
-
"placeholder": "~/.openclaw/clawhub/config.json"
|
|
37
|
-
}
|
|
38
|
-
},
|
|
39
6
|
"configSchema": {
|
|
40
7
|
"type": "object",
|
|
41
8
|
"additionalProperties": false,
|
|
42
9
|
"properties": {
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"defaultDir": { "type": "string", "minLength": 1 },
|
|
46
|
-
"defaultNoInput": { "type": "boolean" },
|
|
47
|
-
"defaultTimeoutMs": { "type": "number", "minimum": 1000 },
|
|
48
|
-
"configPath": { "type": "string" }
|
|
10
|
+
"gatewayBaseUrl": { "type": "string" },
|
|
11
|
+
"gatewayToken": { "type": "string" }
|
|
49
12
|
},
|
|
50
13
|
"required": []
|
|
51
14
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@2en/clawly-plugins",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"module": "index.ts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -14,10 +14,11 @@
|
|
|
14
14
|
"files": [
|
|
15
15
|
"tools",
|
|
16
16
|
"index.ts",
|
|
17
|
+
"calendar.ts",
|
|
17
18
|
"channel.ts",
|
|
18
|
-
"clawhub2gateway.ts",
|
|
19
19
|
"cron-hook.ts",
|
|
20
|
-
"
|
|
20
|
+
"email.ts",
|
|
21
|
+
"gateway-fetch.ts",
|
|
21
22
|
"outbound.ts",
|
|
22
23
|
"echo.ts",
|
|
23
24
|
"presence.ts",
|
package/clawhub2gateway.ts
DELETED
|
@@ -1,405 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ClawHub CLI RPC bridge — expose ClawHub CLI commands as Gateway RPC methods.
|
|
3
|
-
*
|
|
4
|
-
* Gateway methods:
|
|
5
|
-
* - clawhub2gateway.search
|
|
6
|
-
* - clawhub2gateway.install
|
|
7
|
-
* - clawhub2gateway.update
|
|
8
|
-
* - clawhub2gateway.list
|
|
9
|
-
* - clawhub2gateway.explore
|
|
10
|
-
* - clawhub2gateway.inspect
|
|
11
|
-
* - clawhub2gateway.star
|
|
12
|
-
* - clawhub2gateway.unstar
|
|
13
|
-
*
|
|
14
|
-
* Notes:
|
|
15
|
-
* - This plugin shells out to the `clawhub` CLI. Install it on the gateway host:
|
|
16
|
-
* `npm i -g clawhub` (or ensure it is in PATH).
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import fs from 'node:fs/promises'
|
|
20
|
-
import path from 'node:path'
|
|
21
|
-
|
|
22
|
-
import type {PluginApi} from './index'
|
|
23
|
-
|
|
24
|
-
type JsonSchema = Record<string, unknown>
|
|
25
|
-
|
|
26
|
-
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
27
|
-
return Boolean(v && typeof v === 'object' && !Array.isArray(v))
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function readBool(params: Record<string, unknown>, key: string): boolean | undefined {
|
|
31
|
-
return typeof params[key] === 'boolean' ? (params[key] as boolean) : undefined
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function readString(params: Record<string, unknown>, key: string): string | undefined {
|
|
35
|
-
const v = params[key]
|
|
36
|
-
if (typeof v !== 'string') return undefined
|
|
37
|
-
const t = v.trim()
|
|
38
|
-
return t ? t : undefined
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function readNumber(params: Record<string, unknown>, key: string): number | undefined {
|
|
42
|
-
const v = params[key]
|
|
43
|
-
if (typeof v === 'number' && Number.isFinite(v)) return v
|
|
44
|
-
if (typeof v === 'string' && v.trim()) {
|
|
45
|
-
const n = Number(v.trim())
|
|
46
|
-
if (Number.isFinite(n)) return n
|
|
47
|
-
}
|
|
48
|
-
return undefined
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function coercePluginConfig(api: PluginApi): Record<string, unknown> {
|
|
52
|
-
return isRecord(api.pluginConfig) ? api.pluginConfig : {}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function configString(
|
|
56
|
-
cfg: Record<string, unknown>,
|
|
57
|
-
key: string,
|
|
58
|
-
fallback?: string,
|
|
59
|
-
): string | undefined {
|
|
60
|
-
const v = cfg[key]
|
|
61
|
-
if (typeof v === 'string' && v.trim()) return v.trim()
|
|
62
|
-
return fallback
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function configBool(cfg: Record<string, unknown>, key: string, fallback: boolean): boolean {
|
|
66
|
-
const v = cfg[key]
|
|
67
|
-
if (typeof v === 'boolean') return v
|
|
68
|
-
return fallback
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function configNumber(cfg: Record<string, unknown>, key: string, fallback: number): number {
|
|
72
|
-
const v = cfg[key]
|
|
73
|
-
if (typeof v === 'number' && Number.isFinite(v)) return v
|
|
74
|
-
return fallback
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
async function ensureDir(dirPath: string): Promise<void> {
|
|
78
|
-
await fs.mkdir(dirPath, {recursive: true})
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function buildGlobalCliArgs(
|
|
82
|
-
params: Record<string, unknown>,
|
|
83
|
-
defaults: {dir: string; noInput: boolean},
|
|
84
|
-
) {
|
|
85
|
-
const out: string[] = []
|
|
86
|
-
|
|
87
|
-
const workdir = readString(params, 'workdir')
|
|
88
|
-
const dir = readString(params, 'dir') ?? defaults.dir
|
|
89
|
-
const site = readString(params, 'site')
|
|
90
|
-
const registry = readString(params, 'registry')
|
|
91
|
-
const noInput = readBool(params, 'noInput') ?? defaults.noInput
|
|
92
|
-
|
|
93
|
-
if (workdir) out.push('--workdir', workdir)
|
|
94
|
-
if (dir) out.push('--dir', dir)
|
|
95
|
-
if (site) out.push('--site', site)
|
|
96
|
-
if (registry) out.push('--registry', registry)
|
|
97
|
-
if (noInput) out.push('--no-input')
|
|
98
|
-
|
|
99
|
-
return out
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
async function runClawhubCommand(opts: {
|
|
103
|
-
api: PluginApi
|
|
104
|
-
argv: string[]
|
|
105
|
-
cwd?: string
|
|
106
|
-
timeoutMs: number
|
|
107
|
-
env?: Record<string, string | undefined>
|
|
108
|
-
}) {
|
|
109
|
-
const runner = opts.api.runtime.system?.runCommandWithTimeout
|
|
110
|
-
if (!runner) {
|
|
111
|
-
throw new Error('Plugin runtime missing system.runCommandWithTimeout')
|
|
112
|
-
}
|
|
113
|
-
const result = await runner(opts.argv, {timeoutMs: opts.timeoutMs, cwd: opts.cwd, env: opts.env})
|
|
114
|
-
return result
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function joinOutput(stdout: string, stderr: string): string {
|
|
118
|
-
const s1 = (stdout ?? '').trimEnd()
|
|
119
|
-
const s2 = (stderr ?? '').trimEnd()
|
|
120
|
-
if (s1 && s2) return `${s1}\n\n${s2}`
|
|
121
|
-
return s1 || s2 || ''
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function baseToolParamsSchema(extra?: JsonSchema): JsonSchema {
|
|
125
|
-
return {
|
|
126
|
-
type: 'object',
|
|
127
|
-
additionalProperties: false,
|
|
128
|
-
properties: {
|
|
129
|
-
workdir: {type: 'string', description: 'Working directory (ClawHub --workdir)'},
|
|
130
|
-
dir: {type: 'string', description: 'Skills directory relative to workdir (ClawHub --dir)'},
|
|
131
|
-
site: {type: 'string', description: 'ClawHub site URL (ClawHub --site)'},
|
|
132
|
-
registry: {type: 'string', description: 'ClawHub registry API URL (ClawHub --registry)'},
|
|
133
|
-
noInput: {type: 'boolean', description: 'Disable prompts (ClawHub --no-input)'},
|
|
134
|
-
timeoutMs: {type: 'number', description: 'Command timeout in milliseconds'},
|
|
135
|
-
...(extra ?? {}),
|
|
136
|
-
},
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export function registerClawhub2gateway(api: PluginApi) {
|
|
141
|
-
const cfg = coercePluginConfig(api)
|
|
142
|
-
const bin = configString(cfg, 'bin', 'clawhub') ?? 'clawhub'
|
|
143
|
-
const defaultDir = configString(cfg, 'defaultDir', 'skills') ?? 'skills'
|
|
144
|
-
const defaultNoInput = configBool(cfg, 'defaultNoInput', true)
|
|
145
|
-
const defaultTimeoutMs = configNumber(cfg, 'defaultTimeoutMs', 60_000)
|
|
146
|
-
const configPath = configString(cfg, 'configPath')
|
|
147
|
-
|
|
148
|
-
const stateDir =
|
|
149
|
-
api.runtime.state?.resolveStateDir?.(process.env) ??
|
|
150
|
-
process.env.OPENCLAW_STATE_DIR ??
|
|
151
|
-
process.env.CLAWDBOT_STATE_DIR ??
|
|
152
|
-
''
|
|
153
|
-
const defaultConfigPath = stateDir ? path.join(stateDir, 'clawhub', 'config.json') : ''
|
|
154
|
-
|
|
155
|
-
const resolveEnv = async (): Promise<Record<string, string | undefined>> => {
|
|
156
|
-
const env: Record<string, string | undefined> = {}
|
|
157
|
-
const resolved = configPath ?? defaultConfigPath
|
|
158
|
-
if (resolved) {
|
|
159
|
-
await ensureDir(path.dirname(resolved))
|
|
160
|
-
env.CLAWHUB_CONFIG_PATH = resolved
|
|
161
|
-
}
|
|
162
|
-
return env
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const registerRpc = (rpc: {
|
|
166
|
-
method: string
|
|
167
|
-
description: string
|
|
168
|
-
parameters: JsonSchema
|
|
169
|
-
buildCli: (
|
|
170
|
-
params: Record<string, unknown>,
|
|
171
|
-
) => Promise<{argv: string[]; cwd?: string; timeoutMs?: number}>
|
|
172
|
-
}) => {
|
|
173
|
-
api.registerGatewayMethod(rpc.method, async ({params, respond}) => {
|
|
174
|
-
try {
|
|
175
|
-
const timeoutMs = Math.max(
|
|
176
|
-
1_000,
|
|
177
|
-
Math.floor(readNumber(params, 'timeoutMs') ?? defaultTimeoutMs),
|
|
178
|
-
)
|
|
179
|
-
const built = await rpc.buildCli(params)
|
|
180
|
-
const env = await resolveEnv()
|
|
181
|
-
const res = await runClawhubCommand({
|
|
182
|
-
api,
|
|
183
|
-
argv: built.argv,
|
|
184
|
-
cwd: built.cwd,
|
|
185
|
-
timeoutMs: built.timeoutMs ?? timeoutMs,
|
|
186
|
-
env,
|
|
187
|
-
})
|
|
188
|
-
const output = joinOutput(res.stdout, res.stderr)
|
|
189
|
-
if (res.code && res.code !== 0) {
|
|
190
|
-
respond(false, undefined, {
|
|
191
|
-
code: 'command_failed',
|
|
192
|
-
message: output || `clawhub exited with code ${res.code}`,
|
|
193
|
-
})
|
|
194
|
-
return
|
|
195
|
-
}
|
|
196
|
-
respond(true, {
|
|
197
|
-
ok: true,
|
|
198
|
-
output,
|
|
199
|
-
stdout: res.stdout,
|
|
200
|
-
stderr: res.stderr,
|
|
201
|
-
code: res.code,
|
|
202
|
-
signal: res.signal,
|
|
203
|
-
killed: res.killed,
|
|
204
|
-
argv: built.argv,
|
|
205
|
-
cwd: built.cwd,
|
|
206
|
-
})
|
|
207
|
-
} catch (err) {
|
|
208
|
-
respond(false, undefined, {
|
|
209
|
-
code: 'error',
|
|
210
|
-
message: err instanceof Error ? err.message : String(err),
|
|
211
|
-
})
|
|
212
|
-
}
|
|
213
|
-
})
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// -----------------------------
|
|
217
|
-
// Search
|
|
218
|
-
// -----------------------------
|
|
219
|
-
|
|
220
|
-
registerRpc({
|
|
221
|
-
method: 'clawhub2gateway.search',
|
|
222
|
-
description: 'ClawHub CLI: search skills.',
|
|
223
|
-
parameters: baseToolParamsSchema({
|
|
224
|
-
query: {type: 'string', description: 'Search query'},
|
|
225
|
-
limit: {type: 'number', description: 'Max results (clawhub search --limit)'},
|
|
226
|
-
}),
|
|
227
|
-
async buildCli(params) {
|
|
228
|
-
const query = readString(params, 'query')
|
|
229
|
-
if (!query) throw new Error('query required')
|
|
230
|
-
const limit = readNumber(params, 'limit')
|
|
231
|
-
const globalArgs = buildGlobalCliArgs(params, {dir: defaultDir, noInput: defaultNoInput})
|
|
232
|
-
const argv = [bin, 'search', query, ...globalArgs]
|
|
233
|
-
if (limit !== undefined) argv.push('--limit', String(Math.max(1, Math.floor(limit))))
|
|
234
|
-
return {argv}
|
|
235
|
-
},
|
|
236
|
-
})
|
|
237
|
-
|
|
238
|
-
registerRpc({
|
|
239
|
-
method: 'clawhub2gateway.explore',
|
|
240
|
-
description: 'ClawHub CLI: explore skills.!',
|
|
241
|
-
parameters: baseToolParamsSchema({
|
|
242
|
-
limit: {type: 'number', description: 'Max results (clawhub explore --limit, if supported)'},
|
|
243
|
-
sort: {
|
|
244
|
-
type: 'string',
|
|
245
|
-
enum: ['newest', 'downloads', 'rating', 'installs', 'installsAllTime', 'trending'],
|
|
246
|
-
description:
|
|
247
|
-
'Sort order (clawhub explore --sort). One of newest, downloads, rating, installs, installsAllTime, trending.',
|
|
248
|
-
},
|
|
249
|
-
json: {type: 'boolean', description: 'Output JSON'},
|
|
250
|
-
}),
|
|
251
|
-
async buildCli(params) {
|
|
252
|
-
const limit = readNumber(params, 'limit')
|
|
253
|
-
const sort = readString(params, 'sort')
|
|
254
|
-
const json = readBool(params, 'json') ?? false
|
|
255
|
-
const globalArgs = buildGlobalCliArgs(params, {dir: defaultDir, noInput: defaultNoInput})
|
|
256
|
-
const argv = [bin, 'explore', ...globalArgs]
|
|
257
|
-
if (limit !== undefined) argv.push('--limit', String(Math.max(1, Math.floor(limit))))
|
|
258
|
-
if (sort) argv.push('--sort', sort)
|
|
259
|
-
if (json) argv.push('--json')
|
|
260
|
-
return {argv}
|
|
261
|
-
},
|
|
262
|
-
})
|
|
263
|
-
|
|
264
|
-
// -----------------------------
|
|
265
|
-
// Install / Update / List
|
|
266
|
-
// -----------------------------
|
|
267
|
-
|
|
268
|
-
registerRpc({
|
|
269
|
-
method: 'clawhub2gateway.install',
|
|
270
|
-
description: 'ClawHub CLI: install a skill into the workspace skills dir.',
|
|
271
|
-
parameters: baseToolParamsSchema({
|
|
272
|
-
slug: {type: 'string', description: 'Skill slug to install'},
|
|
273
|
-
version: {type: 'string', description: 'Specific version to install'},
|
|
274
|
-
force: {
|
|
275
|
-
type: 'boolean',
|
|
276
|
-
description: 'Overwrite if folder exists (clawhub install --force)',
|
|
277
|
-
},
|
|
278
|
-
}),
|
|
279
|
-
async buildCli(params) {
|
|
280
|
-
const slug = readString(params, 'slug')
|
|
281
|
-
if (!slug) throw new Error('slug required')
|
|
282
|
-
const version = readString(params, 'version')
|
|
283
|
-
const force = readBool(params, 'force') ?? false
|
|
284
|
-
const globalArgs = buildGlobalCliArgs(params, {dir: defaultDir, noInput: defaultNoInput})
|
|
285
|
-
const argv = [bin, 'install', slug, ...globalArgs]
|
|
286
|
-
if (version) argv.push('--version', version)
|
|
287
|
-
if (force) argv.push('--force')
|
|
288
|
-
return {argv}
|
|
289
|
-
},
|
|
290
|
-
})
|
|
291
|
-
|
|
292
|
-
registerRpc({
|
|
293
|
-
method: 'clawhub2gateway.update',
|
|
294
|
-
description: 'ClawHub CLI: update one skill or all installed skills.',
|
|
295
|
-
parameters: baseToolParamsSchema({
|
|
296
|
-
slug: {type: 'string', description: 'Skill slug to update (omit when all=true)'},
|
|
297
|
-
all: {type: 'boolean', description: 'Update all skills (clawhub update --all)'},
|
|
298
|
-
version: {type: 'string', description: 'Update to a specific version (single slug only)'},
|
|
299
|
-
force: {
|
|
300
|
-
type: 'boolean',
|
|
301
|
-
description: 'Overwrite when local files do not match any published version',
|
|
302
|
-
},
|
|
303
|
-
}),
|
|
304
|
-
async buildCli(params) {
|
|
305
|
-
const all = readBool(params, 'all') ?? false
|
|
306
|
-
const slug = readString(params, 'slug')
|
|
307
|
-
const version = readString(params, 'version')
|
|
308
|
-
const force = readBool(params, 'force') ?? false
|
|
309
|
-
const globalArgs = buildGlobalCliArgs(params, {dir: defaultDir, noInput: defaultNoInput})
|
|
310
|
-
|
|
311
|
-
const argv = [bin, 'update', ...globalArgs]
|
|
312
|
-
if (all) {
|
|
313
|
-
argv.push('--all')
|
|
314
|
-
} else {
|
|
315
|
-
if (!slug) throw new Error('slug required when all=false')
|
|
316
|
-
argv.push(slug)
|
|
317
|
-
if (version) argv.push('--version', version)
|
|
318
|
-
}
|
|
319
|
-
if (force) argv.push('--force')
|
|
320
|
-
return {argv}
|
|
321
|
-
},
|
|
322
|
-
})
|
|
323
|
-
|
|
324
|
-
registerRpc({
|
|
325
|
-
method: 'clawhub2gateway.list',
|
|
326
|
-
description: 'ClawHub CLI: list installed skills from .clawhub/lock.json.',
|
|
327
|
-
parameters: baseToolParamsSchema(),
|
|
328
|
-
async buildCli(params) {
|
|
329
|
-
const argv = [
|
|
330
|
-
bin,
|
|
331
|
-
'list',
|
|
332
|
-
...buildGlobalCliArgs(params, {dir: defaultDir, noInput: defaultNoInput}),
|
|
333
|
-
]
|
|
334
|
-
return {argv}
|
|
335
|
-
},
|
|
336
|
-
})
|
|
337
|
-
|
|
338
|
-
// -----------------------------
|
|
339
|
-
// Inspect / Star
|
|
340
|
-
// -----------------------------
|
|
341
|
-
|
|
342
|
-
registerRpc({
|
|
343
|
-
method: 'clawhub2gateway.inspect',
|
|
344
|
-
description: 'ClawHub CLI: inspect a skill without installing.',
|
|
345
|
-
parameters: baseToolParamsSchema({
|
|
346
|
-
slug: {type: 'string', description: 'Skill slug'},
|
|
347
|
-
}),
|
|
348
|
-
async buildCli(params) {
|
|
349
|
-
const slug = readString(params, 'slug')
|
|
350
|
-
if (!slug) throw new Error('slug required')
|
|
351
|
-
const globalArgs = buildGlobalCliArgs(params, {dir: defaultDir, noInput: defaultNoInput})
|
|
352
|
-
|
|
353
|
-
const argv = [bin, 'inspect', slug, ...globalArgs]
|
|
354
|
-
return {argv}
|
|
355
|
-
},
|
|
356
|
-
})
|
|
357
|
-
|
|
358
|
-
registerRpc({
|
|
359
|
-
method: 'clawhub2gateway.star',
|
|
360
|
-
description: 'ClawHub CLI: star a skill.',
|
|
361
|
-
parameters: baseToolParamsSchema({
|
|
362
|
-
slug: {type: 'string', description: 'Skill slug'},
|
|
363
|
-
}),
|
|
364
|
-
async buildCli(params) {
|
|
365
|
-
const slug = readString(params, 'slug')
|
|
366
|
-
if (!slug) throw new Error('slug required')
|
|
367
|
-
const argv = [
|
|
368
|
-
bin,
|
|
369
|
-
'star',
|
|
370
|
-
slug,
|
|
371
|
-
...buildGlobalCliArgs(params, {dir: defaultDir, noInput: defaultNoInput}),
|
|
372
|
-
]
|
|
373
|
-
return {argv}
|
|
374
|
-
},
|
|
375
|
-
})
|
|
376
|
-
|
|
377
|
-
registerRpc({
|
|
378
|
-
method: 'clawhub2gateway.unstar',
|
|
379
|
-
description: 'ClawHub CLI: unstar a skill.',
|
|
380
|
-
parameters: baseToolParamsSchema({
|
|
381
|
-
slug: {type: 'string', description: 'Skill slug'},
|
|
382
|
-
}),
|
|
383
|
-
async buildCli(params) {
|
|
384
|
-
const slug = readString(params, 'slug')
|
|
385
|
-
if (!slug) throw new Error('slug required')
|
|
386
|
-
const argv = [
|
|
387
|
-
bin,
|
|
388
|
-
'unstar',
|
|
389
|
-
slug,
|
|
390
|
-
...buildGlobalCliArgs(params, {dir: defaultDir, noInput: defaultNoInput}),
|
|
391
|
-
]
|
|
392
|
-
return {argv}
|
|
393
|
-
},
|
|
394
|
-
})
|
|
395
|
-
|
|
396
|
-
api.logger.info(
|
|
397
|
-
[
|
|
398
|
-
`clawhub2gateway: registered.`,
|
|
399
|
-
`Using clawhub bin: ${bin}`,
|
|
400
|
-
defaultConfigPath
|
|
401
|
-
? `CLI config: ${configPath ?? defaultConfigPath}`
|
|
402
|
-
: 'CLI config: (not set)',
|
|
403
|
-
].join(' '),
|
|
404
|
-
)
|
|
405
|
-
}
|
package/memory.ts
DELETED
|
@@ -1,187 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Memory directory browser — expose memory directory .md files as Gateway RPC methods.
|
|
3
|
-
*
|
|
4
|
-
* Gateway methods:
|
|
5
|
-
* - memory-browser.list — list all .md files in the OpenClaw memory directory
|
|
6
|
-
* - memory-browser.get — return the content of a single .md file by path
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import fs from 'node:fs/promises'
|
|
10
|
-
import os from 'node:os'
|
|
11
|
-
import path from 'node:path'
|
|
12
|
-
|
|
13
|
-
import type {PluginApi} from './index'
|
|
14
|
-
|
|
15
|
-
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
16
|
-
return Boolean(v && typeof v === 'object' && !Array.isArray(v))
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function configString(
|
|
20
|
-
cfg: Record<string, unknown>,
|
|
21
|
-
key: string,
|
|
22
|
-
fallback?: string,
|
|
23
|
-
): string | undefined {
|
|
24
|
-
const v = cfg[key]
|
|
25
|
-
if (typeof v === 'string' && v.trim()) return v.trim()
|
|
26
|
-
return fallback
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function readString(params: Record<string, unknown>, key: string): string | undefined {
|
|
30
|
-
const v = params[key]
|
|
31
|
-
if (typeof v !== 'string') return undefined
|
|
32
|
-
const t = v.trim()
|
|
33
|
-
return t ? t : undefined
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function coercePluginConfig(api: PluginApi): Record<string, unknown> {
|
|
37
|
-
return isRecord(api.pluginConfig) ? api.pluginConfig : {}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Resolve the memory directory path. Memory lives under workspace (agents.defaults.workspace). */
|
|
41
|
-
function resolveMemoryDir(api: PluginApi, profile?: string): string {
|
|
42
|
-
const cfg = coercePluginConfig(api)
|
|
43
|
-
const configPath = configString(cfg, 'memoryDir')
|
|
44
|
-
if (configPath) return configPath
|
|
45
|
-
|
|
46
|
-
const baseDir =
|
|
47
|
-
process.env.OPENCLAW_WORKSPACE ?? path.join(os.homedir(), '.openclaw', 'workspace')
|
|
48
|
-
// Profile-aware: "main" or empty → default workspace, otherwise workspace-<profile>
|
|
49
|
-
if (profile && profile !== 'main') {
|
|
50
|
-
const parentDir = path.dirname(baseDir)
|
|
51
|
-
const baseName = path.basename(baseDir)
|
|
52
|
-
return path.join(parentDir, `${baseName}-${profile}`, 'memory')
|
|
53
|
-
}
|
|
54
|
-
return path.join(baseDir, 'memory')
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Check that a relative path is safe (no directory traversal). */
|
|
58
|
-
function isSafeRelativePath(relativePath: string): boolean {
|
|
59
|
-
if (!relativePath || relativePath.startsWith('/') || relativePath.includes('\\')) {
|
|
60
|
-
return false
|
|
61
|
-
}
|
|
62
|
-
const normalized = path.normalize(relativePath)
|
|
63
|
-
if (normalized.startsWith('..') || normalized.includes('..')) {
|
|
64
|
-
return false
|
|
65
|
-
}
|
|
66
|
-
return true
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Recursively collect all .md file paths under dir, relative to baseDir. */
|
|
70
|
-
async function collectMdFiles(dir: string, baseDir: string, acc: string[] = []): Promise<string[]> {
|
|
71
|
-
let entries: fs.Dirent[]
|
|
72
|
-
try {
|
|
73
|
-
entries = await fs.readdir(dir, {withFileTypes: true})
|
|
74
|
-
} catch (err) {
|
|
75
|
-
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return acc
|
|
76
|
-
throw err
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
for (const e of entries) {
|
|
80
|
-
const full = path.join(dir, e.name)
|
|
81
|
-
const rel = path.relative(baseDir, full)
|
|
82
|
-
|
|
83
|
-
if (e.isDirectory()) {
|
|
84
|
-
await collectMdFiles(full, baseDir, acc)
|
|
85
|
-
} else if (e.isFile() && e.name.toLowerCase().endsWith('.md')) {
|
|
86
|
-
acc.push(rel)
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
return acc
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export function registerMemoryBrowser(api: PluginApi) {
|
|
93
|
-
const memoryDir = resolveMemoryDir(api)
|
|
94
|
-
api.logger.info(`memory-browser: memory directory: ${memoryDir}`)
|
|
95
|
-
|
|
96
|
-
// -----------------------------
|
|
97
|
-
// memory-browser.list
|
|
98
|
-
// -----------------------------
|
|
99
|
-
api.registerGatewayMethod('memory-browser.list', async ({params, respond}) => {
|
|
100
|
-
try {
|
|
101
|
-
const profile = readString(params, 'profile')
|
|
102
|
-
const dir = profile ? resolveMemoryDir(api, profile) : memoryDir
|
|
103
|
-
const files = await collectMdFiles(dir, dir)
|
|
104
|
-
api.logger.info(`memory-browser.list: ${files.length} files found`)
|
|
105
|
-
files.sort()
|
|
106
|
-
respond(true, {files})
|
|
107
|
-
} catch (err) {
|
|
108
|
-
api.logger.error(
|
|
109
|
-
`memory-browser.list failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
110
|
-
)
|
|
111
|
-
respond(false, undefined, {
|
|
112
|
-
code: 'error',
|
|
113
|
-
message: err instanceof Error ? err.message : String(err),
|
|
114
|
-
})
|
|
115
|
-
}
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
// -----------------------------
|
|
119
|
-
// memory-browser.get
|
|
120
|
-
// -----------------------------
|
|
121
|
-
api.registerGatewayMethod('memory-browser.get', async ({params, respond}) => {
|
|
122
|
-
const relativePath = readString(params, 'path')
|
|
123
|
-
const profile = readString(params, 'profile')
|
|
124
|
-
const dir = profile ? resolveMemoryDir(api, profile) : memoryDir
|
|
125
|
-
if (!relativePath) {
|
|
126
|
-
respond(false, undefined, {
|
|
127
|
-
code: 'invalid_params',
|
|
128
|
-
message: 'path (relative path to .md file) is required',
|
|
129
|
-
})
|
|
130
|
-
return
|
|
131
|
-
}
|
|
132
|
-
if (!isSafeRelativePath(relativePath)) {
|
|
133
|
-
respond(false, undefined, {
|
|
134
|
-
code: 'invalid_params',
|
|
135
|
-
message: 'path must be a safe relative path (no directory traversal)',
|
|
136
|
-
})
|
|
137
|
-
return
|
|
138
|
-
}
|
|
139
|
-
if (!relativePath.toLowerCase().endsWith('.md')) {
|
|
140
|
-
respond(false, undefined, {
|
|
141
|
-
code: 'invalid_params',
|
|
142
|
-
message: 'path must point to a .md file',
|
|
143
|
-
})
|
|
144
|
-
return
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const fullPath = path.join(dir, path.normalize(relativePath))
|
|
148
|
-
const realMemory = await fs.realpath(dir).catch(() => dir)
|
|
149
|
-
const realResolved = await fs.realpath(fullPath).catch(() => null)
|
|
150
|
-
|
|
151
|
-
if (!realResolved) {
|
|
152
|
-
respond(false, undefined, {
|
|
153
|
-
code: 'not_found',
|
|
154
|
-
message: 'file not found',
|
|
155
|
-
})
|
|
156
|
-
return
|
|
157
|
-
}
|
|
158
|
-
const rel = path.relative(realMemory, realResolved)
|
|
159
|
-
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
160
|
-
respond(false, undefined, {
|
|
161
|
-
code: 'not_found',
|
|
162
|
-
message: 'file not found or outside memory directory',
|
|
163
|
-
})
|
|
164
|
-
return
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
try {
|
|
168
|
-
const content = await fs.readFile(fullPath, 'utf-8')
|
|
169
|
-
respond(true, {path: relativePath, content})
|
|
170
|
-
} catch (err) {
|
|
171
|
-
const code = (err as NodeJS.ErrnoException).code
|
|
172
|
-
if (code === 'ENOENT') {
|
|
173
|
-
respond(false, undefined, {code: 'not_found', message: 'file not found'})
|
|
174
|
-
return
|
|
175
|
-
}
|
|
176
|
-
api.logger.error(
|
|
177
|
-
`memory-browser.get failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
178
|
-
)
|
|
179
|
-
respond(false, undefined, {
|
|
180
|
-
code: 'error',
|
|
181
|
-
message: err instanceof Error ? err.message : String(err),
|
|
182
|
-
})
|
|
183
|
-
}
|
|
184
|
-
})
|
|
185
|
-
|
|
186
|
-
api.logger.info(`memory-browser: registered (dir: ${memoryDir})`)
|
|
187
|
-
}
|