@2en/clawly-plugins 1.5.0 → 1.7.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/gateway/index.ts CHANGED
@@ -3,6 +3,7 @@ import {registerAgentSend} from './agent'
3
3
  import {registerClawhub2gateway} from './clawhub2gateway'
4
4
  import {registerMemoryBrowser} from './memory'
5
5
  import {registerNotification} from './notification'
6
+ import {registerPlugins} from './plugins'
6
7
  import {registerPresence} from './presence'
7
8
 
8
9
  export function registerGateway(api: PluginApi) {
@@ -11,4 +12,5 @@ export function registerGateway(api: PluginApi) {
11
12
  registerAgentSend(api)
12
13
  registerMemoryBrowser(api)
13
14
  registerClawhub2gateway(api)
15
+ registerPlugins(api)
14
16
  }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Plugin version check and update management.
3
+ *
4
+ * Methods:
5
+ * - clawly.plugins.version({ pluginId, npmPkgName }) → VersionResult
6
+ * - clawly.plugins.update({ pluginId, npmPkgName, strategy, targetVersion?, restart? }) → UpdateResult
7
+ */
8
+
9
+ import fs from 'node:fs'
10
+ import path from 'node:path'
11
+ import {$} from 'zx'
12
+
13
+ import type {PluginApi} from '../index'
14
+ import {LruCache} from '../lib/lruCache'
15
+ import {isUpdateAvailable} from '../lib/semver'
16
+ import {stripCliLogs} from '../lib/stripCliLogs'
17
+
18
+ $.verbose = false
19
+
20
+ interface NpmViewCache {
21
+ version: string
22
+ versions: string[]
23
+ }
24
+
25
+ const npmCache = new LruCache<NpmViewCache>({maxSize: 5, ttlMs: 5 * 60 * 1000})
26
+
27
+ function resolveStateDir(api: PluginApi): string {
28
+ return api.runtime.state?.resolveStateDir?.(process.env) ?? process.env.OPENCLAW_STATE_DIR ?? ''
29
+ }
30
+
31
+ function readExtensionVersion(stateDir: string, pluginId: string): string | null {
32
+ try {
33
+ const pkgPath = path.join(stateDir, 'extensions', pluginId, 'package.json')
34
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
35
+ return typeof pkg.version === 'string' ? pkg.version : null
36
+ } catch {
37
+ return null
38
+ }
39
+ }
40
+
41
+ async function fetchNpmView(npmPkgName: string): Promise<NpmViewCache | null> {
42
+ const cached = npmCache.get(npmPkgName)
43
+ if (cached) return cached
44
+
45
+ try {
46
+ const result = await $`npm view ${npmPkgName} --json`
47
+ const parsed = JSON.parse(stripCliLogs(result.stdout))
48
+ const entry: NpmViewCache = {
49
+ version: typeof parsed.version === 'string' ? parsed.version : '',
50
+ versions: Array.isArray(parsed.versions) ? parsed.versions : [],
51
+ }
52
+ npmCache.set(npmPkgName, entry)
53
+ return entry
54
+ } catch {
55
+ return null
56
+ }
57
+ }
58
+
59
+ export function registerPlugins(api: PluginApi) {
60
+ // ── clawly.plugins.version ──────────────────────────────────────
61
+
62
+ api.registerGatewayMethod('clawly.plugins.version', async ({params, respond}) => {
63
+ const pluginId = typeof params.pluginId === 'string' ? params.pluginId.trim() : ''
64
+ const npmPkgName = typeof params.npmPkgName === 'string' ? params.npmPkgName.trim() : ''
65
+
66
+ if (!pluginId || !npmPkgName) {
67
+ respond(false, undefined, {
68
+ code: 'invalid_params',
69
+ message: 'pluginId and npmPkgName are required',
70
+ })
71
+ return
72
+ }
73
+
74
+ const stateDir = resolveStateDir(api)
75
+ api.logger.info(`plugins.version: checking ${pluginId} (${npmPkgName})`)
76
+ let latestNpmVersion: string | null = null
77
+ let allNpmVersions: string[] = []
78
+ let updateAvailable = false
79
+ const errors: string[] = []
80
+
81
+ // Step 1: Read installed version from extensions/<id>/package.json
82
+ const npmPackageVersion = stateDir ? readExtensionVersion(stateDir, pluginId) : null
83
+ api.logger.info(`plugins.version: installed version = ${npmPackageVersion ?? 'not found'}`)
84
+
85
+ // Step 2: Fetch npm registry info
86
+ try {
87
+ const npm = await fetchNpmView(npmPkgName)
88
+ if (npm) {
89
+ latestNpmVersion = npm.version
90
+ allNpmVersions = npm.versions
91
+ api.logger.info(
92
+ `plugins.version: npm registry latest=${latestNpmVersion}, ${allNpmVersions.length} versions available`,
93
+ )
94
+ } else {
95
+ api.logger.warn(`plugins.version: npm view returned null for ${npmPkgName}`)
96
+ }
97
+ } catch (err) {
98
+ errors.push(`npm view: ${err instanceof Error ? err.message : String(err)}`)
99
+ api.logger.error(`plugins.version: npm view failed — ${errors[errors.length - 1]}`)
100
+ }
101
+
102
+ // Step 3: Compute update availability
103
+ if (npmPackageVersion && latestNpmVersion) {
104
+ updateAvailable = isUpdateAvailable(npmPackageVersion, latestNpmVersion)
105
+ }
106
+
107
+ api.logger.info(
108
+ `plugins.version: result current=${npmPackageVersion} latest=${latestNpmVersion} updateAvailable=${updateAvailable}`,
109
+ )
110
+
111
+ respond(true, {
112
+ pluginVersion: npmPackageVersion,
113
+ npmPackageVersion,
114
+ latestNpmVersion,
115
+ allNpmVersions,
116
+ updateAvailable,
117
+ ...(errors.length ? {error: errors.join('; ')} : {}),
118
+ })
119
+ })
120
+
121
+ // ── clawly.plugins.update ──────────────────────────────────────
122
+
123
+ api.registerGatewayMethod('clawly.plugins.update', async ({params, respond}) => {
124
+ const pluginId = typeof params.pluginId === 'string' ? params.pluginId.trim() : ''
125
+ const npmPkgName = typeof params.npmPkgName === 'string' ? params.npmPkgName.trim() : ''
126
+ const strategy = typeof params.strategy === 'string' ? params.strategy.trim() : ''
127
+ const targetVersion =
128
+ typeof params.targetVersion === 'string' ? params.targetVersion.trim() : undefined
129
+ const restart = params.restart === true
130
+
131
+ if (!pluginId || !npmPkgName || !strategy) {
132
+ respond(false, undefined, {
133
+ code: 'invalid_params',
134
+ message: 'pluginId, npmPkgName, and strategy are required',
135
+ })
136
+ return
137
+ }
138
+
139
+ if (!['force', 'update', 'install'].includes(strategy)) {
140
+ respond(false, undefined, {
141
+ code: 'invalid_params',
142
+ message: `invalid strategy: ${strategy}`,
143
+ })
144
+ return
145
+ }
146
+
147
+ const installTarget = targetVersion ? `${npmPkgName}@${targetVersion}` : npmPkgName
148
+
149
+ try {
150
+ let output = ''
151
+
152
+ if (strategy === 'force') {
153
+ const stateDir = resolveStateDir(api)
154
+ if (!stateDir) throw new Error('cannot resolve openclaw state dir')
155
+
156
+ const configPath = path.join(stateDir, 'openclaw.json')
157
+
158
+ // 1. Save current plugins.entries.<id> config (preserve user settings)
159
+ let savedEntry: Record<string, unknown> = {enabled: true}
160
+ try {
161
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
162
+ const entry = config?.plugins?.entries?.[pluginId]
163
+ if (entry && typeof entry === 'object') {
164
+ savedEntry = entry as Record<string, unknown>
165
+ }
166
+ } catch {
167
+ // config unreadable — use default
168
+ }
169
+ api.logger.info(`plugins: force — saved entry config for ${pluginId}`)
170
+
171
+ // 2. Delete plugins.entries.<id> from config so install starts clean
172
+ try {
173
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
174
+ if (config?.plugins?.entries?.[pluginId]) {
175
+ delete config.plugins.entries[pluginId]
176
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n')
177
+ }
178
+ } catch {
179
+ // ignore
180
+ }
181
+
182
+ // 3. Remove extension dir to force clean install
183
+ const extensionDir = path.join(stateDir, 'extensions', pluginId)
184
+ if (fs.existsSync(extensionDir)) {
185
+ api.logger.info(`plugins: force — removing ${extensionDir}`)
186
+ await $`rm -rf ${extensionDir}`
187
+ }
188
+
189
+ // 4. Install fresh
190
+ api.logger.info(`plugins: force — installing ${installTarget}`)
191
+ const result = await $`openclaw plugins install ${installTarget}`
192
+ output = result.stdout.trim()
193
+
194
+ // 5. Merge saved config back into plugins.entries.<id>
195
+ try {
196
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
197
+ if (!config.plugins) config.plugins = {}
198
+ if (!config.plugins.entries) config.plugins.entries = {}
199
+ config.plugins.entries[pluginId] = {
200
+ ...config.plugins.entries[pluginId],
201
+ ...savedEntry,
202
+ }
203
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n')
204
+ api.logger.info(`plugins: force — restored entry config for ${pluginId}`)
205
+ } catch (err) {
206
+ api.logger.warn(
207
+ `plugins: force — failed to restore entry config: ${err instanceof Error ? err.message : String(err)}`,
208
+ )
209
+ }
210
+ } else if (strategy === 'update') {
211
+ const result = await $`openclaw plugins update ${pluginId}`
212
+ output = result.stdout.trim()
213
+ } else {
214
+ // install
215
+ const result = await $`openclaw plugins install ${installTarget}`
216
+ output = result.stdout.trim()
217
+ }
218
+
219
+ // Invalidate npm cache for this package
220
+ npmCache.delete(npmPkgName)
221
+
222
+ let restarted = false
223
+ if (restart) {
224
+ try {
225
+ await $`openclaw gateway restart`
226
+ restarted = true
227
+ } catch (err) {
228
+ api.logger.warn(
229
+ `plugins: gateway restart failed — ${err instanceof Error ? err.message : String(err)}`,
230
+ )
231
+ }
232
+ }
233
+
234
+ respond(true, {ok: true, strategy, output, restarted})
235
+ } catch (err) {
236
+ const msg = err instanceof Error ? err.message : String(err)
237
+ api.logger.error(`plugins: update failed — ${msg}`)
238
+ respond(false, {ok: false, strategy, error: msg})
239
+ }
240
+ })
241
+
242
+ api.logger.info('plugins: registered clawly.plugins.version + clawly.plugins.update')
243
+ }
@@ -8,6 +8,7 @@
8
8
  import {$} from 'zx'
9
9
 
10
10
  import type {PluginApi} from '../index'
11
+ import {stripCliLogs} from '../lib/stripCliLogs'
11
12
 
12
13
  $.verbose = false
13
14
 
@@ -25,12 +26,7 @@ interface PresenceEntry {
25
26
  export async function isClientOnline(host = DEFAULT_HOST): Promise<boolean> {
26
27
  try {
27
28
  const result = await $`openclaw gateway call system-presence --json`
28
- const output = result.stdout.trim()
29
- let jsonStr = output
30
- if (!output.startsWith('[')) {
31
- jsonStr = output.slice(output.indexOf('\n['))
32
- }
33
- console.log({jsonStr})
29
+ const jsonStr = stripCliLogs(result.stdout)
34
30
  const entries: PresenceEntry[] = JSON.parse(jsonStr)
35
31
  const entry = entries.find((e) => e.host === host)
36
32
  if (!entry) return false
package/gateway-fetch.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Shared helpers for plugins that proxy to the Clawly model-gateway backend.
2
+ * Shared helpers for plugins that proxy to the Clawly skill-gateway backend.
3
3
  */
4
4
 
5
5
  import type {PluginApi} from './index'
@@ -10,14 +10,12 @@ export type HandlerResult = {ok: boolean; data?: unknown; error?: {code: string;
10
10
 
11
11
  export function getGatewayConfig(api: PluginApi): GatewayCfg {
12
12
  const cfg = api.pluginConfig && typeof api.pluginConfig === 'object' ? api.pluginConfig : {}
13
+ const c = cfg as Record<string, unknown>
13
14
  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)
15
+ typeof c.skillGatewayBaseUrl === 'string'
16
+ ? (c.skillGatewayBaseUrl as string).replace(/\/$/, '')
20
17
  : ''
18
+ const token = typeof c.skillGatewayToken === 'string' ? (c.skillGatewayToken as string) : ''
21
19
  return {baseUrl, token}
22
20
  }
23
21
 
package/index.ts CHANGED
@@ -105,7 +105,7 @@ export default {
105
105
  registerCronHook(api)
106
106
  registerGateway(api)
107
107
 
108
- // Email & calendar (optional — requires gatewayBaseUrl + gatewayToken in config)
108
+ // Email & calendar (optional — requires skillGatewayBaseUrl + skillGatewayToken in config)
109
109
  const gw = getGatewayConfig(api)
110
110
  if (gw.baseUrl && gw.token) {
111
111
  registerEmail(api, gw)
@@ -0,0 +1,85 @@
1
+ import {afterEach, beforeEach, describe, expect, jest, test} from 'bun:test'
2
+ import {LruCache} from './lruCache'
3
+
4
+ describe('LruCache', () => {
5
+ test('get returns undefined for missing key', () => {
6
+ const cache = new LruCache<string>({maxSize: 3, ttlMs: 60_000})
7
+ expect(cache.get('missing')).toBeUndefined()
8
+ })
9
+
10
+ test('set and get', () => {
11
+ const cache = new LruCache<number>({maxSize: 3, ttlMs: 60_000})
12
+ cache.set('a', 1)
13
+ expect(cache.get('a')).toBe(1)
14
+ expect(cache.size).toBe(1)
15
+ })
16
+
17
+ test('evicts oldest when over maxSize', () => {
18
+ const cache = new LruCache<number>({maxSize: 2, ttlMs: 60_000})
19
+ cache.set('a', 1)
20
+ cache.set('b', 2)
21
+ cache.set('c', 3)
22
+ expect(cache.get('a')).toBeUndefined()
23
+ expect(cache.get('b')).toBe(2)
24
+ expect(cache.get('c')).toBe(3)
25
+ expect(cache.size).toBe(2)
26
+ })
27
+
28
+ test('get refreshes position (prevents eviction)', () => {
29
+ const cache = new LruCache<number>({maxSize: 2, ttlMs: 60_000})
30
+ cache.set('a', 1)
31
+ cache.set('b', 2)
32
+ // Access 'a' to refresh its position
33
+ cache.get('a')
34
+ // Insert 'c' — should evict 'b' (now oldest) instead of 'a'
35
+ cache.set('c', 3)
36
+ expect(cache.get('a')).toBe(1)
37
+ expect(cache.get('b')).toBeUndefined()
38
+ expect(cache.get('c')).toBe(3)
39
+ })
40
+
41
+ test('expired entries return undefined', () => {
42
+ const now = Date.now()
43
+ jest.setSystemTime(new Date(now))
44
+
45
+ const cache = new LruCache<string>({maxSize: 5, ttlMs: 1000})
46
+ cache.set('x', 'val')
47
+ expect(cache.get('x')).toBe('val')
48
+
49
+ // Advance past TTL
50
+ jest.setSystemTime(new Date(now + 1001))
51
+ expect(cache.get('x')).toBeUndefined()
52
+ expect(cache.size).toBe(0)
53
+
54
+ jest.restoreAllMocks()
55
+ })
56
+
57
+ test('delete removes entry', () => {
58
+ const cache = new LruCache<number>({maxSize: 5, ttlMs: 60_000})
59
+ cache.set('a', 1)
60
+ expect(cache.delete('a')).toBe(true)
61
+ expect(cache.get('a')).toBeUndefined()
62
+ expect(cache.delete('a')).toBe(false)
63
+ })
64
+
65
+ test('clear removes all entries', () => {
66
+ const cache = new LruCache<number>({maxSize: 5, ttlMs: 60_000})
67
+ cache.set('a', 1)
68
+ cache.set('b', 2)
69
+ cache.clear()
70
+ expect(cache.size).toBe(0)
71
+ expect(cache.get('a')).toBeUndefined()
72
+ })
73
+
74
+ test('overwriting a key updates value and refreshes position', () => {
75
+ const cache = new LruCache<number>({maxSize: 2, ttlMs: 60_000})
76
+ cache.set('a', 1)
77
+ cache.set('b', 2)
78
+ cache.set('a', 10)
79
+ // 'a' is refreshed, 'b' is oldest
80
+ cache.set('c', 3)
81
+ expect(cache.get('a')).toBe(10)
82
+ expect(cache.get('b')).toBeUndefined()
83
+ expect(cache.get('c')).toBe(3)
84
+ })
85
+ })
@@ -0,0 +1,50 @@
1
+ interface CacheEntry<V> {
2
+ value: V
3
+ expiresAt: number
4
+ }
5
+
6
+ export class LruCache<V> {
7
+ private map = new Map<string, CacheEntry<V>>()
8
+ private maxSize: number
9
+ private ttlMs: number
10
+
11
+ constructor(opts: {maxSize: number; ttlMs: number}) {
12
+ this.maxSize = opts.maxSize
13
+ this.ttlMs = opts.ttlMs
14
+ }
15
+
16
+ get(key: string): V | undefined {
17
+ const entry = this.map.get(key)
18
+ if (!entry) return undefined
19
+ if (Date.now() > entry.expiresAt) {
20
+ this.map.delete(key)
21
+ return undefined
22
+ }
23
+ // Refresh position: delete and re-insert (Map preserves insertion order)
24
+ this.map.delete(key)
25
+ this.map.set(key, entry)
26
+ return entry.value
27
+ }
28
+
29
+ set(key: string, value: V): void {
30
+ this.map.delete(key)
31
+ if (this.map.size >= this.maxSize) {
32
+ // Evict oldest (first entry)
33
+ const oldest = this.map.keys().next().value
34
+ if (oldest !== undefined) this.map.delete(oldest)
35
+ }
36
+ this.map.set(key, {value, expiresAt: Date.now() + this.ttlMs})
37
+ }
38
+
39
+ delete(key: string): boolean {
40
+ return this.map.delete(key)
41
+ }
42
+
43
+ clear(): void {
44
+ this.map.clear()
45
+ }
46
+
47
+ get size(): number {
48
+ return this.map.size
49
+ }
50
+ }
@@ -0,0 +1,88 @@
1
+ import {describe, expect, test} from 'bun:test'
2
+ import {compareSemVer, isUpdateAvailable, parseSemVer} from './semver'
3
+
4
+ describe('parseSemVer', () => {
5
+ test('parses simple version', () => {
6
+ const v = parseSemVer('1.2.3')
7
+ expect(v).toEqual({major: 1, minor: 2, patch: 3, prerelease: undefined, raw: '1.2.3'})
8
+ })
9
+
10
+ test('parses version with v prefix', () => {
11
+ const v = parseSemVer('v1.2.3')
12
+ expect(v).toEqual({major: 1, minor: 2, patch: 3, prerelease: undefined, raw: 'v1.2.3'})
13
+ })
14
+
15
+ test('parses version with prerelease', () => {
16
+ const v = parseSemVer('1.2.3-beta.1')
17
+ expect(v).toEqual({major: 1, minor: 2, patch: 3, prerelease: 'beta.1', raw: '1.2.3-beta.1'})
18
+ })
19
+
20
+ test('returns null for invalid input', () => {
21
+ expect(parseSemVer('')).toBeNull()
22
+ expect(parseSemVer('abc')).toBeNull()
23
+ expect(parseSemVer('1.2')).toBeNull()
24
+ expect(parseSemVer('1.2.3.4')).toBeNull()
25
+ })
26
+
27
+ test('trims whitespace', () => {
28
+ const v = parseSemVer(' 1.0.0 ')
29
+ expect(v?.major).toBe(1)
30
+ })
31
+ })
32
+
33
+ describe('compareSemVer', () => {
34
+ test('equal versions return 0', () => {
35
+ expect(compareSemVer('1.0.0', '1.0.0')).toBe(0)
36
+ })
37
+
38
+ test('major difference', () => {
39
+ expect(compareSemVer('2.0.0', '1.0.0')).toBe(1)
40
+ expect(compareSemVer('1.0.0', '2.0.0')).toBe(-1)
41
+ })
42
+
43
+ test('minor difference', () => {
44
+ expect(compareSemVer('1.1.0', '1.0.0')).toBe(1)
45
+ expect(compareSemVer('1.0.0', '1.1.0')).toBe(-1)
46
+ })
47
+
48
+ test('patch difference', () => {
49
+ expect(compareSemVer('1.0.1', '1.0.0')).toBe(1)
50
+ expect(compareSemVer('1.0.0', '1.0.1')).toBe(-1)
51
+ })
52
+
53
+ test('release > prerelease at same version', () => {
54
+ expect(compareSemVer('1.0.0', '1.0.0-beta.1')).toBe(1)
55
+ expect(compareSemVer('1.0.0-beta.1', '1.0.0')).toBe(-1)
56
+ })
57
+
58
+ test('two prereleases at same version return 0', () => {
59
+ expect(compareSemVer('1.0.0-alpha', '1.0.0-beta')).toBe(0)
60
+ })
61
+
62
+ test('returns null for invalid input', () => {
63
+ expect(compareSemVer('abc', '1.0.0')).toBeNull()
64
+ expect(compareSemVer('1.0.0', 'abc')).toBeNull()
65
+ })
66
+ })
67
+
68
+ describe('isUpdateAvailable', () => {
69
+ test('returns true when latest > current', () => {
70
+ expect(isUpdateAvailable('1.0.0', '1.1.0')).toBe(true)
71
+ expect(isUpdateAvailable('1.0.0', '2.0.0')).toBe(true)
72
+ expect(isUpdateAvailable('1.5.0', '1.5.1')).toBe(true)
73
+ })
74
+
75
+ test('returns false when latest <= current', () => {
76
+ expect(isUpdateAvailable('1.0.0', '1.0.0')).toBe(false)
77
+ expect(isUpdateAvailable('2.0.0', '1.0.0')).toBe(false)
78
+ })
79
+
80
+ test('returns false when latest is prerelease', () => {
81
+ expect(isUpdateAvailable('1.0.0', '2.0.0-beta.1')).toBe(false)
82
+ })
83
+
84
+ test('returns false for invalid input', () => {
85
+ expect(isUpdateAvailable('abc', '1.0.0')).toBe(false)
86
+ expect(isUpdateAvailable('1.0.0', 'abc')).toBe(false)
87
+ })
88
+ })
package/lib/semver.ts ADDED
@@ -0,0 +1,46 @@
1
+ export interface SemVer {
2
+ major: number
3
+ minor: number
4
+ patch: number
5
+ prerelease: string | undefined
6
+ raw: string
7
+ }
8
+
9
+ const SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/
10
+
11
+ export function parseSemVer(version: string): SemVer | null {
12
+ const m = version.trim().match(SEMVER_RE)
13
+ if (!m) return null
14
+ return {
15
+ major: Number(m[1]),
16
+ minor: Number(m[2]),
17
+ patch: Number(m[3]),
18
+ prerelease: m[4] ?? undefined,
19
+ raw: version.trim(),
20
+ }
21
+ }
22
+
23
+ export function compareSemVer(a: string, b: string): -1 | 0 | 1 | null {
24
+ const pa = parseSemVer(a)
25
+ const pb = parseSemVer(b)
26
+ if (!pa || !pb) return null
27
+
28
+ for (const key of ['major', 'minor', 'patch'] as const) {
29
+ if (pa[key] > pb[key]) return 1
30
+ if (pa[key] < pb[key]) return -1
31
+ }
32
+
33
+ // Both equal in major.minor.patch — compare prerelease
34
+ // A release (no prerelease) is greater than a prerelease
35
+ if (!pa.prerelease && pb.prerelease) return 1
36
+ if (pa.prerelease && !pb.prerelease) return -1
37
+
38
+ return 0
39
+ }
40
+
41
+ export function isUpdateAvailable(current: string, latest: string): boolean {
42
+ const pl = parseSemVer(latest)
43
+ if (!pl || pl.prerelease) return false // latest must be stable
44
+ const cmp = compareSemVer(latest, current)
45
+ return cmp === 1
46
+ }
@@ -0,0 +1,71 @@
1
+ import {describe, expect, test} from 'bun:test'
2
+ import {stripCliLogs} from './stripCliLogs'
3
+
4
+ describe('stripCliLogs', () => {
5
+ test('strips leading log lines', () => {
6
+ const input = [
7
+ '11:40:09 [plugins] echo: registered /clawly_echo command',
8
+ '11:40:09 [plugins] tool: registered clawly_is_user_online agent tool',
9
+ '11:40:09 [plugins] tool: registered clawly_send_app_push agent tool',
10
+ '[{"id":"clawly-plugins","version":"1.5.0"}]',
11
+ ].join('\n')
12
+ expect(stripCliLogs(input)).toBe('[{"id":"clawly-plugins","version":"1.5.0"}]')
13
+ })
14
+
15
+ test('returns unchanged when no log lines', () => {
16
+ const input = '{"ok":true}'
17
+ expect(stripCliLogs(input)).toBe('{"ok":true}')
18
+ })
19
+
20
+ test('handles single-digit hour', () => {
21
+ const input = '9:05:01 [core] init\n{"data":1}'
22
+ expect(stripCliLogs(input)).toBe('{"data":1}')
23
+ })
24
+
25
+ test('returns empty string when all lines are logs', () => {
26
+ const input = '11:00:00 [a] foo\n12:00:00 [b] bar'
27
+ expect(stripCliLogs(input)).toBe('')
28
+ })
29
+
30
+ test('preserves multiline JSON after logs', () => {
31
+ const input = ['11:40:09 [plugins] loading', '{', ' "version": "1.0.0"', '}'].join('\n')
32
+ expect(stripCliLogs(input)).toBe('{\n "version": "1.0.0"\n}')
33
+ })
34
+
35
+ test('handles empty input', () => {
36
+ expect(stripCliLogs('')).toBe('')
37
+ })
38
+
39
+ test('does not strip lines that merely contain a timestamp mid-line', () => {
40
+ const input = 'some prefix 11:40:09 [x] y\n{"ok":true}'
41
+ expect(stripCliLogs(input)).toBe('some prefix 11:40:09 [x] y\n{"ok":true}')
42
+ })
43
+
44
+ test('strips ANSI color codes from log lines', () => {
45
+ const input = [
46
+ '\x1b[35m12:46:24 [plugins] presence: registered clawly.isOnline method',
47
+ '\x1b[35m12:46:24 [plugins] registered clawly.plugins.version',
48
+ '[{"id":"clawly-plugins","version":"1.5.0"}]',
49
+ ].join('\n')
50
+ expect(stripCliLogs(input)).toBe('[{"id":"clawly-plugins","version":"1.5.0"}]')
51
+ })
52
+
53
+ test('strips log lines without timestamps (after ANSI stripping)', () => {
54
+ const input = [
55
+ '\x1b[35m[plugins] echo: registered /clawly_echo command',
56
+ '\x1b[35m[plugins] tool: registered clawly_send_app_push agent tool',
57
+ '[{"id":"clawly-plugins"}]',
58
+ ].join('\n')
59
+ expect(stripCliLogs(input)).toBe('[{"id":"clawly-plugins"}]')
60
+ })
61
+
62
+ test('strips plain log lines without timestamps', () => {
63
+ const input = '[plugins] Loaded clawly-plugins plugin.\n{"ok":true}'
64
+ expect(stripCliLogs(input)).toBe('{"ok":true}')
65
+ })
66
+
67
+ test('preserves JSON arrays (not confused with log prefixes)', () => {
68
+ const input = '[{"id":"test"}]'
69
+ expect(stripCliLogs(input)).toBe('[{"id":"test"}]')
70
+ })
71
+ })
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Strip leading log lines from `openclaw ... --json` output.
3
+ *
4
+ * OpenClaw CLI may emit log lines (with or without ANSI colors / timestamps)
5
+ * before the actual JSON payload:
6
+ *
7
+ * 11:40:09 [plugins] echo: registered /clawly_echo command
8
+ * \x1b[35m[plugins] tool: registered clawly_send_app_push agent tool
9
+ * [{"id":"clawly-plugins", ...}]
10
+ *
11
+ * This function strips ANSI escape sequences, drops every line that looks
12
+ * like a log prefix (`HH:MM:SS [tag] ...` or `[tag] ...`), and returns the
13
+ * remaining text (the JSON body).
14
+ */
15
+
16
+ const ANSI_RE = /\x1b\[[0-9;]*m/g
17
+ const LOG_LINE_RE = /^(\d{1,2}:\d{2}:\d{2} )?\[\w[\w-]*\] /
18
+
19
+ export function stripCliLogs(output: string): string {
20
+ const cleaned = output.replace(ANSI_RE, '')
21
+ const lines = cleaned.split('\n')
22
+ const firstNonLog = lines.findIndex((line) => !LOG_LINE_RE.test(line))
23
+ if (firstNonLog === -1) return ''
24
+ return lines.slice(firstNonLog).join('\n').trim()
25
+ }
@@ -2,7 +2,7 @@
2
2
  "id": "clawly-plugins",
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
- "version": "0.2.0",
5
+ "version": "0.3.0",
6
6
  "uiHints": {
7
7
  "memoryDir": {
8
8
  "label": "Memory directory",
@@ -46,8 +46,8 @@
46
46
  "defaultNoInput": { "type": "boolean" },
47
47
  "defaultTimeoutMs": { "type": "number", "minimum": 1000 },
48
48
  "configPath": { "type": "string" },
49
- "gatewayBaseUrl": { "type": "string" },
50
- "gatewayToken": { "type": "string" }
49
+ "skillGatewayBaseUrl": { "type": "string" },
50
+ "skillGatewayToken": { "type": "string" }
51
51
  },
52
52
  "required": []
53
53
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2en/clawly-plugins",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "repository": {
@@ -14,6 +14,7 @@
14
14
  "files": [
15
15
  "command",
16
16
  "gateway",
17
+ "lib",
17
18
  "tools",
18
19
  "index.ts",
19
20
  "calendar.ts",