@2en/clawly-plugins 1.3.0 → 1.4.0
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/clawhub2gateway.ts +405 -0
- package/index.ts +20 -1
- package/memory.ts +187 -0
- package/openclaw.plugin.json +42 -2
- package/package.json +4 -1
- package/tools/clawly-is-user-online.ts +35 -0
- package/tools/clawly-send-app-push.ts +80 -0
|
@@ -0,0 +1,405 @@
|
|
|
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/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.*), memory browser, and ClawHub CLI bridge.
|
|
3
3
|
*
|
|
4
4
|
* Gateway methods:
|
|
5
5
|
* - clawly.file.getOutbound — read a persisted outbound file by original-path hash
|
|
@@ -8,6 +8,9 @@
|
|
|
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)
|
|
11
14
|
*
|
|
12
15
|
* Agent tools:
|
|
13
16
|
* - clawly_is_user_online — check if user's device is connected
|
|
@@ -24,13 +27,27 @@
|
|
|
24
27
|
import {registerAgentSend} from './agent-send'
|
|
25
28
|
import {registerIsUserOnlineTool, registerSendAppPushTool} from './tools'
|
|
26
29
|
import {registerClawlyCronChannel} from './channel'
|
|
30
|
+
import {registerClawhub2gateway} from './clawhub2gateway'
|
|
27
31
|
import {registerCronHook} from './cron-hook'
|
|
28
32
|
import {registerEchoCommand} from './echo'
|
|
33
|
+
import {registerMemoryBrowser} from './memory'
|
|
29
34
|
import {registerNotification} from './notification'
|
|
30
35
|
import {registerOutboundHook, registerOutboundMethods} from './outbound'
|
|
31
36
|
import {registerPresence} from './presence'
|
|
32
37
|
|
|
33
38
|
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
|
+
}
|
|
34
51
|
state?: {
|
|
35
52
|
resolveStateDir?: (env?: NodeJS.ProcessEnv) => string
|
|
36
53
|
}
|
|
@@ -91,6 +108,8 @@ export default {
|
|
|
91
108
|
registerSendAppPushTool(api)
|
|
92
109
|
registerClawlyCronChannel(api)
|
|
93
110
|
registerCronHook(api)
|
|
111
|
+
registerMemoryBrowser(api)
|
|
112
|
+
registerClawhub2gateway(api)
|
|
94
113
|
api.logger.info(`Loaded ${api.id} plugin.`)
|
|
95
114
|
},
|
|
96
115
|
}
|
package/memory.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
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
|
+
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,12 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "clawly-plugins",
|
|
3
3
|
"name": "Clawly Plugins",
|
|
4
|
-
"description": "Clawly utility RPC methods (clawly.*): file access, presence, push notifications, and
|
|
4
|
+
"description": "Clawly utility RPC methods (clawly.*): file access, presence, push notifications, agent messaging, memory browser, and ClawHub CLI bridge.",
|
|
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
|
+
},
|
|
6
39
|
"configSchema": {
|
|
7
40
|
"type": "object",
|
|
8
41
|
"additionalProperties": false,
|
|
9
|
-
"properties": {
|
|
42
|
+
"properties": {
|
|
43
|
+
"memoryDir": { "type": "string", "minLength": 1 },
|
|
44
|
+
"bin": { "type": "string", "minLength": 1 },
|
|
45
|
+
"defaultDir": { "type": "string", "minLength": 1 },
|
|
46
|
+
"defaultNoInput": { "type": "boolean" },
|
|
47
|
+
"defaultTimeoutMs": { "type": "number", "minimum": 1000 },
|
|
48
|
+
"configPath": { "type": "string" }
|
|
49
|
+
},
|
|
10
50
|
"required": []
|
|
11
51
|
}
|
|
12
52
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@2en/clawly-plugins",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"module": "index.ts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -12,9 +12,12 @@
|
|
|
12
12
|
"zx": "npm:zx@8.8.5-lite"
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
|
+
"tools",
|
|
15
16
|
"index.ts",
|
|
16
17
|
"channel.ts",
|
|
18
|
+
"clawhub2gateway.ts",
|
|
17
19
|
"cron-hook.ts",
|
|
20
|
+
"memory.ts",
|
|
18
21
|
"outbound.ts",
|
|
19
22
|
"echo.ts",
|
|
20
23
|
"presence.ts",
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent tool: clawly_is_user_online — check if the user's mobile
|
|
3
|
+
* device is currently connected to the OpenClaw gateway.
|
|
4
|
+
*
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {PluginApi} from '../index'
|
|
8
|
+
import {isClientOnline} from '../presence'
|
|
9
|
+
|
|
10
|
+
const TOOL_NAME = 'clawly_is_user_online'
|
|
11
|
+
|
|
12
|
+
const parameters: Record<string, unknown> = {
|
|
13
|
+
type: 'object',
|
|
14
|
+
properties: {
|
|
15
|
+
host: {
|
|
16
|
+
type: 'string',
|
|
17
|
+
description: 'Presence host identifier (default: "openclaw-ios")',
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function registerIsUserOnlineTool(api: PluginApi) {
|
|
23
|
+
api.registerTool({
|
|
24
|
+
name: TOOL_NAME,
|
|
25
|
+
description: "Check if the user's mobile device is currently online.",
|
|
26
|
+
parameters,
|
|
27
|
+
async execute(_toolCallId, params) {
|
|
28
|
+
const host = typeof params.host === 'string' ? params.host : undefined
|
|
29
|
+
const isOnline = await isClientOnline(host)
|
|
30
|
+
return {content: [{type: 'text', text: JSON.stringify({isOnline})}]}
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
api.logger.info(`tool: registered ${TOOL_NAME} agent tool`)
|
|
35
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent tool: clawly_send_app_push — send a push notification to
|
|
3
|
+
* the user's phone from the LLM agent during a conversation.
|
|
4
|
+
*
|
|
5
|
+
* Expo push API reference:
|
|
6
|
+
* https://docs.expo.dev/push-notifications/sending-notifications/
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {PluginApi} from '../index'
|
|
10
|
+
import {sendPushNotification} from '../notification'
|
|
11
|
+
|
|
12
|
+
const TOOL_NAME = 'clawly_send_app_push'
|
|
13
|
+
|
|
14
|
+
const parameters: Record<string, unknown> = {
|
|
15
|
+
type: 'object',
|
|
16
|
+
required: ['body'],
|
|
17
|
+
properties: {
|
|
18
|
+
body: {type: 'string', description: 'Notification message'},
|
|
19
|
+
title: {type: 'string', description: 'Title (default: "Clawly")'},
|
|
20
|
+
subtitle: {type: 'string', description: 'iOS subtitle'},
|
|
21
|
+
sound: {
|
|
22
|
+
type: ['string', 'null'],
|
|
23
|
+
description: 'Sound name (default: "default")',
|
|
24
|
+
},
|
|
25
|
+
badge: {type: 'number', description: 'iOS badge count'},
|
|
26
|
+
data: {type: 'object', description: 'Custom JSON payload'},
|
|
27
|
+
ttl: {type: 'number', description: 'Seconds to keep for redelivery'},
|
|
28
|
+
expiration: {type: 'number', description: 'Unix timestamp expiry'},
|
|
29
|
+
priority: {
|
|
30
|
+
type: 'string',
|
|
31
|
+
enum: ['default', 'normal', 'high'],
|
|
32
|
+
description: 'Delivery priority',
|
|
33
|
+
},
|
|
34
|
+
channelId: {type: 'string', description: 'Android notification channel'},
|
|
35
|
+
categoryId: {type: 'string', description: 'Notification category'},
|
|
36
|
+
interruptionLevel: {
|
|
37
|
+
type: 'string',
|
|
38
|
+
enum: ['active', 'critical', 'passive', 'time-sensitive'],
|
|
39
|
+
description: 'iOS interruption level',
|
|
40
|
+
},
|
|
41
|
+
mutableContent: {type: 'boolean', description: 'iOS mutable content'},
|
|
42
|
+
richContent: {
|
|
43
|
+
type: 'object',
|
|
44
|
+
description: 'Rich content (e.g. {image: url})',
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function registerSendAppPushTool(api: PluginApi) {
|
|
50
|
+
api.registerTool({
|
|
51
|
+
name: TOOL_NAME,
|
|
52
|
+
description: "Send a push notification to the user's phone.",
|
|
53
|
+
parameters,
|
|
54
|
+
async execute(_toolCallId, params) {
|
|
55
|
+
const body = typeof params.body === 'string' ? params.body.trim() : ''
|
|
56
|
+
if (!body) {
|
|
57
|
+
return {content: [{type: 'text', text: JSON.stringify({error: 'body is required'})}]}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const title = typeof params.title === 'string' ? params.title : undefined
|
|
61
|
+
const data =
|
|
62
|
+
typeof params.data === 'object' && params.data !== null
|
|
63
|
+
? (params.data as Record<string, unknown>)
|
|
64
|
+
: undefined
|
|
65
|
+
|
|
66
|
+
// Collect Expo-specific extras (everything except body/title/data)
|
|
67
|
+
const {body: _body, title: _title, data: _data, ...extras} = params
|
|
68
|
+
const hasExtras = Object.keys(extras).length > 0
|
|
69
|
+
|
|
70
|
+
const sent = await sendPushNotification(
|
|
71
|
+
{body, title, data},
|
|
72
|
+
api,
|
|
73
|
+
hasExtras ? extras : undefined,
|
|
74
|
+
)
|
|
75
|
+
return {content: [{type: 'text', text: JSON.stringify({sent})}]}
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
api.logger.info(`tool: registered ${TOOL_NAME} agent tool`)
|
|
80
|
+
}
|