@2en/clawly-plugins 1.4.1 → 1.5.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/channel.ts CHANGED
@@ -9,8 +9,8 @@
9
9
 
10
10
  import {$} from 'zx'
11
11
  import type {PluginApi} from './index'
12
- import {sendPushNotification} from './notification'
13
- import {isClientOnline} from './presence'
12
+ import {sendPushNotification} from './gateway/notification'
13
+ import {isClientOnline} from './gateway/presence'
14
14
 
15
15
  $.verbose = false
16
16
 
@@ -3,7 +3,7 @@
3
3
  * invoking the LLM.
4
4
  */
5
5
 
6
- import type {PluginApi} from './index'
6
+ import type {PluginApi} from '../index'
7
7
 
8
8
  export function registerEchoCommand(api: PluginApi) {
9
9
  api.registerCommand({
@@ -0,0 +1,6 @@
1
+ import type {PluginApi} from '../index'
2
+ import {registerEchoCommand} from './clawly_echo'
3
+
4
+ export function registerCommands(api: PluginApi) {
5
+ registerEchoCommand(api)
6
+ }
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import {$} from 'zx'
12
- import type {PluginApi} from './index'
12
+ import type {PluginApi} from '../index'
13
13
  import {sendPushNotification} from './notification'
14
14
  import {isClientOnline} from './presence'
15
15
 
@@ -0,0 +1,68 @@
1
+ # clawhub2gateway
2
+
3
+ OpenClaw plugin that wraps the **ClawHub CLI** as **Gateway WebSocket RPC methods**.
4
+
5
+ ## What you get
6
+
7
+ Gateway methods (call via WS RPC):
8
+
9
+ - `clawhub2gateway.search`
10
+ - `clawhub2gateway.explore`
11
+ - `clawhub2gateway.install`
12
+ - `clawhub2gateway.update`
13
+ - `clawhub2gateway.list`
14
+ - `clawhub2gateway.inspect`
15
+ - `clawhub2gateway.star`
16
+ - `clawhub2gateway.unstar`
17
+
18
+ ## Install on a gateway host
19
+
20
+ 1) Install the ClawHub CLI (must be on PATH for the OpenClaw process):
21
+
22
+ ```bash
23
+ npm i -g clawhub
24
+ ```
25
+
26
+ 2) Put this plugin on the gateway host and make it discoverable. Common options:
27
+
28
+ - **Workspace plugin**: copy this folder to `<workspace>/.openclaw/extensions/clawhub2gateway/`
29
+ with `index.ts` + `openclaw.plugin.json`
30
+ - **Config path**: set `plugins.load.paths` to the plugin folder path
31
+
32
+ 3) Enable the plugin in your `openclaw.json`:
33
+
34
+ ```json5
35
+ {
36
+ "plugins": {
37
+ "enabled": true,
38
+ "entries": {
39
+ "clawhub2gateway": { "enabled": true, "config": {} }
40
+ }
41
+ }
42
+ }
43
+ ```
44
+
45
+ ## Plugin config
46
+
47
+ Configured under `plugins.entries.clawhub2gateway.config`:
48
+
49
+ - `bin` (string, default: `"clawhub"`): command to execute
50
+ - `defaultDir` (string, default: `"skills"`): default `--dir`
51
+ - `defaultNoInput` (boolean, default: `true`): default `--no-input`
52
+ - `defaultTimeoutMs` (number, default: `60000`)
53
+ - `configPath` (string, optional): sets `CLAWHUB_CONFIG_PATH`. Defaults to
54
+ `<OPENCLAW_STATE_DIR>/clawhub/config.json` when state dir is known.
55
+
56
+ ## Call examples (RPC params)
57
+
58
+ Install:
59
+
60
+ ```json
61
+ { "method": "clawhub2gateway.install", "params": { "slug": "my-skill-pack" } }
62
+ ```
63
+
64
+ Explore:
65
+
66
+ ```json
67
+ { "method": "clawhub2gateway.explore", "params": { "limit": 50, "sort": "trending", "json": true } }
68
+ ```
@@ -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
+ }
@@ -0,0 +1,14 @@
1
+ import type {PluginApi} from '../index'
2
+ import {registerAgentSend} from './agent'
3
+ import {registerClawhub2gateway} from './clawhub2gateway'
4
+ import {registerMemoryBrowser} from './memory'
5
+ import {registerNotification} from './notification'
6
+ import {registerPresence} from './presence'
7
+
8
+ export function registerGateway(api: PluginApi) {
9
+ registerPresence(api)
10
+ registerNotification(api)
11
+ registerAgentSend(api)
12
+ registerMemoryBrowser(api)
13
+ registerClawhub2gateway(api)
14
+ }
@@ -0,0 +1,55 @@
1
+ # memory-browser
2
+
3
+ OpenClaw plugin that exposes the **memory directory** (all `.md` files) as **Gateway WebSocket RPC methods**.
4
+
5
+ ## What you get
6
+
7
+ Gateway methods (call via WS RPC):
8
+
9
+ - **`memory-browser.list`** — List all `.md` files in the memory directory (recursive). Returns `{ files: string[] }` (relative paths).
10
+ - **`memory-browser.get`** — Return the content of a single `.md` file. Params: `{ path: string }` (relative path, e.g. `"notes/foo.md"`). Returns `{ path: string, content: string }`.
11
+
12
+ ## Memory directory
13
+
14
+ The memory directory is resolved in this order:
15
+
16
+ 1. Plugin config `memoryDir` (absolute path)
17
+ 2. `<OPENCLAW_STATE_DIR>/memory` (or `<CLAWDBOT_STATE_DIR>/memory`)
18
+ 3. `<process.cwd()>/memory`
19
+
20
+ ## Install on a gateway host
21
+
22
+ 1. Put this plugin on the gateway host and make it discoverable, e.g.:
23
+ - Workspace: `<workspace>/.openclaw/extensions/memory-browser/` with `index.ts` + `openclaw.plugin.json`
24
+ - Or set `plugins.load.paths` to the plugin folder
25
+
26
+ 2. Enable the plugin in `openclaw.json`:
27
+
28
+ ```json5
29
+ {
30
+ "plugins": {
31
+ "enabled": true,
32
+ "entries": {
33
+ "memory-browser": { "enabled": true, "config": {} }
34
+ }
35
+ }
36
+ }
37
+ ```
38
+
39
+ Optional config: `config.memoryDir` to override the memory directory path.
40
+
41
+ ## Call examples (RPC params)
42
+
43
+ List all `.md` files:
44
+
45
+ ```json
46
+ { "method": "memory-browser.list", "params": {} }
47
+ ```
48
+
49
+ Get one file:
50
+
51
+ ```json
52
+ { "method": "memory-browser.get", "params": { "path": "notes/meeting-2024.md" } }
53
+ ```
54
+
55
+ `path` must be a relative path to a `.md` file inside the memory directory; directory traversal (`..`) is rejected.
@@ -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
+ }
@@ -13,7 +13,7 @@ import fs from 'node:fs'
13
13
  import os from 'node:os'
14
14
  import path from 'node:path'
15
15
 
16
- import type {PluginApi} from './index'
16
+ import type {PluginApi} from '../index'
17
17
 
18
18
  const TOKEN_DIR = path.join(os.homedir(), '.openclaw', 'clawly')
19
19
  const TOKEN_FILE = path.join(TOKEN_DIR, 'expo-push-token.json')
@@ -7,7 +7,7 @@
7
7
 
8
8
  import {$} from 'zx'
9
9
 
10
- import type {PluginApi} from './index'
10
+ import type {PluginApi} from '../index'
11
11
 
12
12
  $.verbose = false
13
13
 
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
@@ -21,19 +24,29 @@
21
24
  * - before_tool_call — enforces delivery fields on cron.create
22
25
  */
23
26
 
24
- import {registerAgentSend} from './agent-send'
25
27
  import {registerCalendar} from './calendar'
26
- import {registerIsUserOnlineTool, registerSendAppPushTool} from './tools'
27
28
  import {registerClawlyCronChannel} from './channel'
29
+ import {registerCommands} from './command'
28
30
  import {registerCronHook} from './cron-hook'
29
- import {registerEchoCommand} from './echo'
30
31
  import {registerEmail} from './email'
32
+ import {registerGateway} from './gateway'
31
33
  import {getGatewayConfig} from './gateway-fetch'
32
- import {registerNotification} from './notification'
33
34
  import {registerOutboundHook, registerOutboundMethods} from './outbound'
34
- import {registerPresence} from './presence'
35
+ import {registerTools} from './tools'
35
36
 
36
37
  type PluginRuntime = {
38
+ system?: {
39
+ runCommandWithTimeout?: (
40
+ argv: string[],
41
+ opts: {timeoutMs: number; cwd?: string; env?: Record<string, string | undefined>},
42
+ ) => Promise<{
43
+ stdout: string
44
+ stderr: string
45
+ code: number | null
46
+ signal: string | null
47
+ killed: boolean
48
+ }>
49
+ }
37
50
  state?: {
38
51
  resolveStateDir?: (env?: NodeJS.ProcessEnv) => string
39
52
  }
@@ -86,14 +99,11 @@ export default {
86
99
  register(api: PluginApi) {
87
100
  registerOutboundHook(api)
88
101
  registerOutboundMethods(api)
89
- registerEchoCommand(api)
90
- registerPresence(api)
91
- registerNotification(api)
92
- registerAgentSend(api)
93
- registerIsUserOnlineTool(api)
94
- registerSendAppPushTool(api)
102
+ registerCommands(api)
103
+ registerTools(api)
95
104
  registerClawlyCronChannel(api)
96
105
  registerCronHook(api)
106
+ registerGateway(api)
97
107
 
98
108
  // Email & calendar (optional — requires gatewayBaseUrl + gatewayToken in config)
99
109
  const gw = getGatewayConfig(api)
@@ -1,12 +1,51 @@
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 agent messaging.",
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
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" },
10
49
  "gatewayBaseUrl": { "type": "string" },
11
50
  "gatewayToken": { "type": "string" }
12
51
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2en/clawly-plugins",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "repository": {
@@ -12,6 +12,8 @@
12
12
  "zx": "npm:zx@8.8.5-lite"
13
13
  },
14
14
  "files": [
15
+ "command",
16
+ "gateway",
15
17
  "tools",
16
18
  "index.ts",
17
19
  "calendar.ts",
@@ -20,11 +22,6 @@
20
22
  "email.ts",
21
23
  "gateway-fetch.ts",
22
24
  "outbound.ts",
23
- "echo.ts",
24
- "presence.ts",
25
- "notification.ts",
26
- "agent-send.ts",
27
- "tools.ts",
28
25
  "openclaw.plugin.json"
29
26
  ],
30
27
  "publishConfig": {
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import type {PluginApi} from '../index'
8
- import {isClientOnline} from '../presence'
8
+ import {isClientOnline} from '../gateway/presence'
9
9
 
10
10
  const TOOL_NAME = 'clawly_is_user_online'
11
11
 
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import type {PluginApi} from '../index'
10
- import {sendPushNotification} from '../notification'
10
+ import {sendPushNotification} from '../gateway/notification'
11
11
 
12
12
  const TOOL_NAME = 'clawly_send_app_push'
13
13
 
package/tools/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ import type {PluginApi} from '../index'
2
+ import {registerIsUserOnlineTool} from './clawly-is-user-online'
3
+ import {registerSendAppPushTool} from './clawly-send-app-push'
4
+
5
+ export function registerTools(api: PluginApi) {
6
+ registerIsUserOnlineTool(api)
7
+ registerSendAppPushTool(api)
8
+ }
package/tools.ts DELETED
@@ -1,2 +0,0 @@
1
- export {registerIsUserOnlineTool} from './tools/clawly-is-user-online'
2
- export {registerSendAppPushTool} from './tools/clawly-send-app-push'