@2en/clawly-plugins 1.28.1 → 1.29.0-beta.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.
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Manual plugin install that bypasses OpenClaw's hardened extraction pipeline.
3
+ *
4
+ * OpenClaw ≥3.12 uses `copyFileWithinRoot` → `runPinnedWriteHelper` (python3)
5
+ * during tar extraction, which fails on sprite overlay/JuiceFS filesystems
6
+ * with `SafeOpenError: path is not a regular file under root`.
7
+ *
8
+ * Supports both npm spec and local absolute path. Install uses rsync:
9
+ * `rsync -a --delete` syncs source into targetDir — overwrites changed files,
10
+ * adds new files, and removes stale files not in source. The target directory
11
+ * is never moved or deleted, so the plugin stays functional during the sync.
12
+ * Staging artifacts (tgz, pkg) are cleaned up in finally.
13
+ */
14
+
15
+ import assert from 'node:assert'
16
+ import fs from 'node:fs'
17
+ import path from 'node:path'
18
+ import {$} from 'zx'
19
+
20
+ const PLUGIN_SENTINEL = 'openclaw.plugin.json'
21
+
22
+ interface Logger {
23
+ info?: (msg: string) => void
24
+ warn?: (msg: string) => void
25
+ error?: (msg: string) => void
26
+ }
27
+
28
+ async function withRetry<T>(fn: () => Promise<T>, maxAttempts: number): Promise<T> {
29
+ let lastErr: unknown
30
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
31
+ try {
32
+ return await fn()
33
+ } catch (err) {
34
+ lastErr = err
35
+ if (attempt < maxAttempts) {
36
+ await new Promise((r) => setTimeout(r, 500 * attempt))
37
+ }
38
+ }
39
+ }
40
+ throw lastErr
41
+ }
42
+
43
+ export async function manualPluginInstall(params: {
44
+ spec: string
45
+ targetDir: string
46
+ stagingDir: string
47
+ logger?: Logger
48
+ }): Promise<string> {
49
+ const {spec, targetDir, stagingDir, logger} = params
50
+ const isNpm = !path.isAbsolute(spec)
51
+ const pkgDir = path.join(stagingDir, 'pkg')
52
+
53
+ // 1. Ensure targetDir exists
54
+ fs.mkdirSync(targetDir, {recursive: true})
55
+
56
+ // 2. Create stagingDir
57
+ fs.mkdirSync(stagingDir, {recursive: true})
58
+
59
+ try {
60
+ let sourceDir: string
61
+ if (isNpm) {
62
+ // 3a. npm pack → stagingDir (retry 3x)
63
+ logger?.info?.(`downloading ${spec}...`)
64
+ const packResult = await withRetry(async () => {
65
+ const result = await $`npm pack ${spec} --pack-destination ${stagingDir}`
66
+ return result
67
+ }, 3)
68
+ const lastLine = packResult.stdout.trim().split('\n').pop() ?? ''
69
+ const tgzName = path.basename(lastLine)
70
+ if (!tgzName.endsWith('.tgz')) throw new Error(`unexpected npm pack output: ${lastLine}`)
71
+ const tgzPath = path.join(stagingDir, tgzName)
72
+
73
+ try {
74
+ // 3b. Extract to stagingDir/pkg
75
+ fs.mkdirSync(pkgDir, {recursive: true})
76
+ logger?.info?.(`extracting to ${pkgDir}...`)
77
+ await $`tar xzf ${tgzPath} --strip-components=1 -C ${pkgDir}`
78
+ } finally {
79
+ try {
80
+ fs.unlinkSync(tgzPath)
81
+ } catch {}
82
+ }
83
+
84
+ // 3c. npm install in pkg (retry 3x)
85
+ const pkgPath = path.join(pkgDir, 'package.json')
86
+ if (fs.existsSync(pkgPath)) {
87
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
88
+ if (pkg.dependencies && Object.keys(pkg.dependencies).length > 0) {
89
+ logger?.info?.('installing dependencies...')
90
+ await withRetry(
91
+ () => $({cwd: pkgDir})`npm install --omit=dev --omit=peer --ignore-scripts`,
92
+ 3,
93
+ )
94
+ }
95
+ }
96
+ sourceDir = pkgDir
97
+ } else {
98
+ sourceDir = spec
99
+ if (!fs.existsSync(sourceDir)) {
100
+ throw new Error(`local spec path does not exist: ${sourceDir}`)
101
+ }
102
+ }
103
+
104
+ assert(
105
+ fs.existsSync(path.join(sourceDir, 'package.json')),
106
+ `plugin source missing package.json: ${sourceDir}`,
107
+ )
108
+ assert(
109
+ fs.existsSync(path.join(sourceDir, PLUGIN_SENTINEL)),
110
+ `plugin source missing openclaw.plugin.json: ${sourceDir}`,
111
+ )
112
+
113
+ // 4. rsync source → targetDir (overlay + delete stale files)
114
+ // npm pack output is already clean; local path needs dev file exclusions
115
+ logger?.info?.(`syncing to ${targetDir}...`)
116
+ if (isNpm) {
117
+ await $`rsync -a --delete --checksum ${sourceDir}/ ${targetDir}/`
118
+ } else {
119
+ await $`rsync -a --delete --checksum --exclude=node_modules --exclude=.git ${sourceDir}/ ${targetDir}/`
120
+ }
121
+
122
+ // 5. Verify sentinel
123
+ if (!fs.existsSync(path.join(targetDir, PLUGIN_SENTINEL))) {
124
+ throw new Error(`plugin files missing after install (${PLUGIN_SENTINEL} not found)`)
125
+ }
126
+
127
+ // 6. Local path: install production deps (rsync excludes node_modules)
128
+ if (!isNpm) {
129
+ const pkg = JSON.parse(fs.readFileSync(path.join(targetDir, 'package.json'), 'utf-8'))
130
+ if (pkg.dependencies && Object.keys(pkg.dependencies).length > 0) {
131
+ logger?.info?.('installing dependencies...')
132
+ await withRetry(
133
+ () => $({cwd: targetDir})`npm install --omit=dev --omit=peer --ignore-scripts`,
134
+ 3,
135
+ )
136
+ }
137
+ }
138
+
139
+ return `Installed ${spec} to ${targetDir}`
140
+ } finally {
141
+ // 6. Remove stagingDir (tgz + pkg artifacts)
142
+ try {
143
+ if (fs.existsSync(stagingDir)) {
144
+ fs.rmSync(stagingDir, {recursive: true, force: true})
145
+ }
146
+ } catch {}
147
+ }
148
+ }
@@ -3,6 +3,7 @@
3
3
  "name": "Clawly Plugins",
4
4
  "description": "Clawly utility RPC methods (clawly.*): file access, presence, push notifications, agent messaging, memory browser, and ClawHub CLI bridge.",
5
5
  "version": "0.3.0",
6
+ "skills": ["skills/read-office-file"],
6
7
  "uiHints": {
7
8
  "memoryDir": {
8
9
  "label": "Memory directory",
package/outbound.ts CHANGED
@@ -190,6 +190,18 @@ export function registerOutboundHttpRoute(api: PluginApi) {
190
190
  path: '/clawly/file/outbound',
191
191
  auth: 'plugin',
192
192
  handler: async (_req: IncomingMessage, res: ServerResponse) => {
193
+ res.setHeader('Access-Control-Allow-Origin', '*')
194
+
195
+ // Handle CORS preflight
196
+ if (_req.method === 'OPTIONS') {
197
+ res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS')
198
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
199
+ res.setHeader('Access-Control-Max-Age', '86400')
200
+ res.statusCode = 204
201
+ res.end()
202
+ return
203
+ }
204
+
193
205
  const url = new URL(_req.url ?? '/', 'http://localhost')
194
206
  if (!guardHttpAuth(api, _req, res, url)) return
195
207
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2en/clawly-plugins",
3
- "version": "1.28.1",
3
+ "version": "1.29.0-beta.1",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "repository": {
@@ -36,7 +36,8 @@
36
36
  "resolve-gateway-credentials.ts",
37
37
  "skill-command-restore.ts",
38
38
  "openclaw.plugin.json",
39
- "internal"
39
+ "internal",
40
+ "skills"
40
41
  ],
41
42
  "publishConfig": {
42
43
  "access": "public"
@@ -0,0 +1,41 @@
1
+ ---
2
+ name: read-office-file
3
+ description: Convert Word, Excel, or PowerPoint files to Markdown using MarkItDown.
4
+ metadata: { "openclaw": { "emoji": "📄" } }
5
+ ---
6
+
7
+ # Read Office File
8
+
9
+ Convert Word (.docx), Excel (.xlsx), or PowerPoint (.pptx) files to Markdown using [MarkItDown](https://github.com/microsoft/markitdown).
10
+
11
+ ## When to use
12
+
13
+ - User asks to read, summarize, or extract content from a `.docx`, `.xlsx`, or `.pptx` file.
14
+ - User shares or references an Office file path.
15
+
16
+ ## Steps
17
+
18
+ 1. Convert the file to Markdown (use the extra matching the file type):
19
+
20
+ ```bash
21
+ # .docx
22
+ uvx 'markitdown[docx]' <file_path>
23
+
24
+ # .xlsx
25
+ uvx 'markitdown[xlsx]' <file_path>
26
+
27
+ # .pptx
28
+ uvx 'markitdown[pptx]' <file_path>
29
+ ```
30
+
31
+ 2. Read the Markdown output and respond to the user's request.
32
+
33
+ ## Supported formats
34
+
35
+ | Format | Extensions |
36
+ |---|---|
37
+ | Word | `.docx` |
38
+ | Excel | `.xlsx` |
39
+ | PowerPoint | `.pptx` |
40
+
41
+ Legacy formats (`.doc`, `.xls`, `.ppt`) are **not supported**. Ask the user to convert to the modern format first.
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Agent tool: clawly_msg_break — split the current response into multiple messages.
3
+ *
4
+ * When called, this tool acts as a structural boundary: the text before the
5
+ * tool call becomes one assistant message, and the text after becomes a new one.
6
+ * The client relies on the existing "text reset" mechanism (post-tool-call text
7
+ * detection) to create the split — the tool itself is a no-op.
8
+ *
9
+ * This is preferred over in-band separator tokens (e.g. [MSG_BREAK]) because
10
+ * tool calls are structured and never leak to other channels (Telegram, etc.).
11
+ */
12
+
13
+ import type {PluginApi} from '../types'
14
+
15
+ const TOOL_NAME = 'clawly_msg_break'
16
+
17
+ export function registerMsgBreakTool(api: PluginApi) {
18
+ api.registerTool({
19
+ name: TOOL_NAME,
20
+ description:
21
+ 'Split the current response into multiple messages. Call this between distinct thoughts or topics to send them as separate message bubbles. The text you wrote before this call becomes one message; continue writing after the call to start a new message.',
22
+ parameters: {
23
+ type: 'object',
24
+ properties: {},
25
+ },
26
+ async execute() {
27
+ return {content: [{type: 'text', text: JSON.stringify({ok: true})}]}
28
+ },
29
+ })
30
+
31
+ api.logger.info(`tool: registered ${TOOL_NAME} agent tool`)
32
+ }
package/tools/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type {PluginApi} from '../types'
2
2
  import {registerCalendarTools} from './clawly-calendar'
3
3
  import {registerIsUserOnlineTool} from './clawly-is-user-online'
4
+ import {registerMsgBreakTool} from './clawly-msg-break'
4
5
  import {registerDeepSearchTool, registerGrokSearchTool, registerSearchTool} from './clawly-search'
5
6
  import {registerSendAppPushTool} from './clawly-send-app-push'
6
7
  import {registerSendImageTool} from './clawly-send-image'
@@ -9,6 +10,7 @@ import {registerSendMessageTool} from './clawly-send-message'
9
10
  export function registerTools(api: PluginApi) {
10
11
  registerCalendarTools(api)
11
12
  registerIsUserOnlineTool(api)
13
+ registerMsgBreakTool(api)
12
14
  registerSearchTool(api)
13
15
  registerDeepSearchTool(api)
14
16
  registerGrokSearchTool(api)
@@ -1,62 +0,0 @@
1
- /**
2
- * Ensures browser.* node commands are in gateway.nodes.allowCommands
3
- * so the AI agent can invoke them on connected Clawly Mac nodes.
4
- *
5
- * Runs on gateway_start. Writes directly to openclaw.json via
6
- * writeOpenclawConfig (no restart triggered — gateway.* changes are
7
- * classified as "none" by the config watcher).
8
- */
9
- import path from 'node:path'
10
-
11
- import type {PluginApi} from '../types'
12
- import {readOpenclawConfig, writeOpenclawConfig} from '../model-gateway-setup'
13
-
14
- const BROWSER_COMMANDS = [
15
- 'browser.proxy',
16
- 'browser.navigate',
17
- 'browser.click',
18
- 'browser.type',
19
- 'browser.screenshot',
20
- 'browser.read',
21
- 'browser.tabs',
22
- 'browser.back',
23
- 'browser.scroll',
24
- 'browser.evaluate',
25
- ]
26
-
27
- export function registerNodeBrowserAllowlist(api: PluginApi) {
28
- api.on('gateway_start', async () => {
29
- let stateDir: string
30
- try {
31
- stateDir = api.runtime.state.resolveStateDir()
32
- } catch {
33
- api.logger.warn('node-browser-allowlist: cannot resolve state dir, skipping')
34
- return
35
- }
36
-
37
- const configPath = path.join(stateDir, 'openclaw.json')
38
- const config = readOpenclawConfig(configPath)
39
-
40
- // Ensure gateway.nodes.allowCommands includes browser.* commands
41
- const gateway = (config.gateway as Record<string, unknown>) ?? {}
42
- const nodes = (gateway.nodes as Record<string, unknown>) ?? {}
43
- const existing: string[] = Array.isArray(nodes.allowCommands)
44
- ? (nodes.allowCommands as string[])
45
- : []
46
-
47
- const existingSet = new Set(existing)
48
- const toAdd = BROWSER_COMMANDS.filter((cmd) => !existingSet.has(cmd))
49
-
50
- if (toAdd.length === 0) {
51
- api.logger.info('node-browser-allowlist: browser commands already allowlisted')
52
- return
53
- }
54
-
55
- nodes.allowCommands = [...existing, ...toAdd]
56
- gateway.nodes = nodes
57
- config.gateway = gateway
58
-
59
- writeOpenclawConfig(configPath, config)
60
- api.logger.info(`node-browser-allowlist: added ${toAdd.length} browser commands to allowlist`)
61
- })
62
- }