@tinyclaw/plugins 2.0.0-dev.ac5c5a7 → 2.0.0-dev.b27966d
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/dist/community.d.ts +74 -0
- package/dist/community.js +282 -0
- package/dist/index.d.ts +25 -1
- package/dist/index.js +126 -0
- package/dist/update-checker.d.ts +51 -0
- package/dist/update-checker.js +333 -0
- package/package.json +1 -1
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Community Plugin Manager
|
|
3
|
+
*
|
|
4
|
+
* Provides helpers for installing, removing, and listing third-party
|
|
5
|
+
* (community) plugins that live outside the official monorepo.
|
|
6
|
+
*
|
|
7
|
+
* Community plugins are stored in the config key `plugins.community: string[]`
|
|
8
|
+
* — separate from `plugins.enabled` which tracks actively running plugins.
|
|
9
|
+
*
|
|
10
|
+
* Installation flow:
|
|
11
|
+
* 1. Validate the package name (strict npm naming rules)
|
|
12
|
+
* 2. Run `bun add <package>` to install the dependency
|
|
13
|
+
* 3. Dynamically import the package and validate the plugin contract
|
|
14
|
+
* 4. Register it in `plugins.community`
|
|
15
|
+
*
|
|
16
|
+
* Security:
|
|
17
|
+
* - Package names are validated against strict npm naming rules before any
|
|
18
|
+
* shell execution — no metacharacters, no path traversal.
|
|
19
|
+
* - Official `@tinyclaw/plugin-*` packages are rejected from this flow
|
|
20
|
+
* (they are managed via the monorepo, not community install).
|
|
21
|
+
* - The installed module must satisfy the TinyClawPlugin interface before
|
|
22
|
+
* it's registered — random npm packages that aren't plugins are rejected.
|
|
23
|
+
*/
|
|
24
|
+
import type { ConfigManagerInterface } from '@tinyclaw/types';
|
|
25
|
+
/**
|
|
26
|
+
* Validate a package name is safe for shell execution and npm resolution.
|
|
27
|
+
* Returns the cleaned name (without version suffix) or null if invalid.
|
|
28
|
+
*/
|
|
29
|
+
export declare function validatePackageName(input: string): {
|
|
30
|
+
name: string;
|
|
31
|
+
installSpec: string;
|
|
32
|
+
} | null;
|
|
33
|
+
/** Read the community plugin list from config. */
|
|
34
|
+
export declare function getCommunityPlugins(configManager: ConfigManagerInterface): string[];
|
|
35
|
+
export interface InstallResult {
|
|
36
|
+
success: boolean;
|
|
37
|
+
message: string;
|
|
38
|
+
plugin?: {
|
|
39
|
+
id: string;
|
|
40
|
+
name: string;
|
|
41
|
+
type: string;
|
|
42
|
+
version: string;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Install a community plugin from npm.
|
|
47
|
+
*
|
|
48
|
+
* 1. Validates the package name
|
|
49
|
+
* 2. Runs `bun add <package>` to install
|
|
50
|
+
* 3. Imports the module and validates the plugin contract
|
|
51
|
+
* 4. Registers in `plugins.community` config
|
|
52
|
+
*
|
|
53
|
+
* @returns Result with success status and a human-readable message
|
|
54
|
+
*/
|
|
55
|
+
export declare function installCommunityPlugin(packageInput: string, configManager: ConfigManagerInterface): Promise<InstallResult>;
|
|
56
|
+
/**
|
|
57
|
+
* Remove a community plugin.
|
|
58
|
+
*
|
|
59
|
+
* Removes from `plugins.community` and `plugins.enabled`, then runs `bun remove`.
|
|
60
|
+
*/
|
|
61
|
+
export declare function removeCommunityPlugin(packageName: string, configManager: ConfigManagerInterface): Promise<InstallResult>;
|
|
62
|
+
export interface CommunityPluginInfo {
|
|
63
|
+
id: string;
|
|
64
|
+
name: string;
|
|
65
|
+
type: string;
|
|
66
|
+
version: string;
|
|
67
|
+
enabled: boolean;
|
|
68
|
+
source: 'community';
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* List all registered community plugins with their status.
|
|
72
|
+
* Imports are parallelized to avoid serial delays with many plugins.
|
|
73
|
+
*/
|
|
74
|
+
export declare function listCommunityPlugins(configManager: ConfigManagerInterface): Promise<CommunityPluginInfo[]>;
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Community Plugin Manager
|
|
3
|
+
*
|
|
4
|
+
* Provides helpers for installing, removing, and listing third-party
|
|
5
|
+
* (community) plugins that live outside the official monorepo.
|
|
6
|
+
*
|
|
7
|
+
* Community plugins are stored in the config key `plugins.community: string[]`
|
|
8
|
+
* — separate from `plugins.enabled` which tracks actively running plugins.
|
|
9
|
+
*
|
|
10
|
+
* Installation flow:
|
|
11
|
+
* 1. Validate the package name (strict npm naming rules)
|
|
12
|
+
* 2. Run `bun add <package>` to install the dependency
|
|
13
|
+
* 3. Dynamically import the package and validate the plugin contract
|
|
14
|
+
* 4. Register it in `plugins.community`
|
|
15
|
+
*
|
|
16
|
+
* Security:
|
|
17
|
+
* - Package names are validated against strict npm naming rules before any
|
|
18
|
+
* shell execution — no metacharacters, no path traversal.
|
|
19
|
+
* - Official `@tinyclaw/plugin-*` packages are rejected from this flow
|
|
20
|
+
* (they are managed via the monorepo, not community install).
|
|
21
|
+
* - The installed module must satisfy the TinyClawPlugin interface before
|
|
22
|
+
* it's registered — random npm packages that aren't plugins are rejected.
|
|
23
|
+
*/
|
|
24
|
+
import { logger } from '@tinyclaw/logger';
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Validation
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
/**
|
|
29
|
+
* Strict npm package name pattern (lowercase only, no shell metacharacters).
|
|
30
|
+
*
|
|
31
|
+
* Allows:
|
|
32
|
+
* - Scoped packages: `@scope/name` (lowercase alphanumeric, hyphens, dots)
|
|
33
|
+
* - Unscoped packages: `name`
|
|
34
|
+
* - Optional version suffix: `@1.2.3`, `@^1.0.0`, `@latest`
|
|
35
|
+
*
|
|
36
|
+
* Rejects everything else — no uppercase, no underscores in names, no spaces,
|
|
37
|
+
* no shell metacharacters, no path separators.
|
|
38
|
+
*/
|
|
39
|
+
const VALID_PACKAGE_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(@[a-z0-9.^~>=<|-]+)?$/;
|
|
40
|
+
/**
|
|
41
|
+
* Validate a package name is safe for shell execution and npm resolution.
|
|
42
|
+
* Returns the cleaned name (without version suffix) or null if invalid.
|
|
43
|
+
*/
|
|
44
|
+
export function validatePackageName(input) {
|
|
45
|
+
const trimmed = input.trim();
|
|
46
|
+
if (!trimmed || trimmed.length > 214)
|
|
47
|
+
return null;
|
|
48
|
+
if (!VALID_PACKAGE_RE.test(trimmed))
|
|
49
|
+
return null;
|
|
50
|
+
// Split name from version suffix for the name-only checks.
|
|
51
|
+
// Safe: the regex above guarantees scoped packages contain '/' and
|
|
52
|
+
// that the only '@' positions are scope-prefix and version-suffix.
|
|
53
|
+
const atIdx = trimmed.lastIndexOf('@');
|
|
54
|
+
const hasVersionSuffix = atIdx > 0 && !trimmed.startsWith('@', atIdx - 1);
|
|
55
|
+
const name = hasVersionSuffix ? trimmed.slice(0, atIdx) : trimmed;
|
|
56
|
+
// Reject official plugins — they're managed via the monorepo
|
|
57
|
+
if (name.startsWith('@tinyclaw/plugin-'))
|
|
58
|
+
return null;
|
|
59
|
+
return { name, installSpec: trimmed };
|
|
60
|
+
}
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Plugin contract validation
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
/**
|
|
65
|
+
* Dynamically import a package and verify it exports a valid TinyClawPlugin.
|
|
66
|
+
* Returns the plugin on success, null on failure.
|
|
67
|
+
*/
|
|
68
|
+
async function validatePluginModule(packageName) {
|
|
69
|
+
try {
|
|
70
|
+
const mod = await import(packageName);
|
|
71
|
+
const plugin = mod.default;
|
|
72
|
+
if (!plugin || typeof plugin !== 'object')
|
|
73
|
+
return null;
|
|
74
|
+
const hasRequiredFields = 'id' in plugin &&
|
|
75
|
+
'name' in plugin &&
|
|
76
|
+
'type' in plugin &&
|
|
77
|
+
'version' in plugin &&
|
|
78
|
+
typeof plugin.id === 'string' &&
|
|
79
|
+
typeof plugin.name === 'string' &&
|
|
80
|
+
typeof plugin.type === 'string' &&
|
|
81
|
+
typeof plugin.version === 'string' &&
|
|
82
|
+
['channel', 'provider', 'tools'].includes(plugin.type);
|
|
83
|
+
if (!hasRequiredFields)
|
|
84
|
+
return null;
|
|
85
|
+
return plugin;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Config helpers
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
/** Read the community plugin list from config. */
|
|
95
|
+
export function getCommunityPlugins(configManager) {
|
|
96
|
+
return configManager.get('plugins.community') ?? [];
|
|
97
|
+
}
|
|
98
|
+
/** Add a plugin to the community list (idempotent). */
|
|
99
|
+
function addToCommunityList(configManager, packageName) {
|
|
100
|
+
const current = getCommunityPlugins(configManager);
|
|
101
|
+
if (!current.includes(packageName)) {
|
|
102
|
+
configManager.set('plugins.community', [...current, packageName]);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** Remove a plugin from the community list. */
|
|
106
|
+
function removeFromCommunityList(configManager, packageName) {
|
|
107
|
+
const current = getCommunityPlugins(configManager);
|
|
108
|
+
configManager.set('plugins.community', current.filter((id) => id !== packageName));
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Install a community plugin from npm.
|
|
112
|
+
*
|
|
113
|
+
* 1. Validates the package name
|
|
114
|
+
* 2. Runs `bun add <package>` to install
|
|
115
|
+
* 3. Imports the module and validates the plugin contract
|
|
116
|
+
* 4. Registers in `plugins.community` config
|
|
117
|
+
*
|
|
118
|
+
* @returns Result with success status and a human-readable message
|
|
119
|
+
*/
|
|
120
|
+
export async function installCommunityPlugin(packageInput, configManager) {
|
|
121
|
+
// 1. Validate package name
|
|
122
|
+
const validated = validatePackageName(packageInput);
|
|
123
|
+
if (!validated) {
|
|
124
|
+
return {
|
|
125
|
+
success: false,
|
|
126
|
+
message: `Invalid package name "${packageInput}". Must be a valid npm package name. Official @tinyclaw/plugin-* packages are managed separately.`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const { name, installSpec } = validated;
|
|
130
|
+
// 2. Check if already registered
|
|
131
|
+
const community = getCommunityPlugins(configManager);
|
|
132
|
+
if (community.includes(name)) {
|
|
133
|
+
return {
|
|
134
|
+
success: false,
|
|
135
|
+
message: `Plugin "${name}" is already registered as a community plugin.`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
// 3. Install via bun
|
|
139
|
+
logger.info(`Installing community plugin: ${installSpec}`, undefined, { emoji: '📦' });
|
|
140
|
+
try {
|
|
141
|
+
const proc = Bun.spawnSync(['bun', 'add', installSpec], {
|
|
142
|
+
stdout: 'pipe',
|
|
143
|
+
stderr: 'pipe',
|
|
144
|
+
timeout: 60_000,
|
|
145
|
+
});
|
|
146
|
+
if (proc.exitCode !== 0) {
|
|
147
|
+
const stderr = proc.stderr.toString().trim();
|
|
148
|
+
return {
|
|
149
|
+
success: false,
|
|
150
|
+
message: `Failed to install "${installSpec}" from npm. ${stderr ? `Error: ${stderr.slice(0, 200)}` : 'The package may not exist or the registry is unreachable.'}`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
return {
|
|
156
|
+
success: false,
|
|
157
|
+
message: `Installation failed: ${err.message}`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
// 4. Validate the plugin contract
|
|
161
|
+
const plugin = await validatePluginModule(name);
|
|
162
|
+
if (!plugin) {
|
|
163
|
+
// Installed but not a valid plugin — remove it
|
|
164
|
+
try {
|
|
165
|
+
Bun.spawnSync(['bun', 'remove', name], {
|
|
166
|
+
stdout: 'pipe',
|
|
167
|
+
stderr: 'pipe',
|
|
168
|
+
timeout: 30_000,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// Best-effort cleanup
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
success: false,
|
|
176
|
+
message: `Package "${name}" was installed but does not export a valid Tiny Claw plugin (must default-export an object with id, name, type, version). Package was removed.`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
// 5. Register in config
|
|
180
|
+
addToCommunityList(configManager, name);
|
|
181
|
+
logger.info(`Community plugin installed: ${plugin.name} (${plugin.id})`, undefined, {
|
|
182
|
+
emoji: '✅',
|
|
183
|
+
});
|
|
184
|
+
return {
|
|
185
|
+
success: true,
|
|
186
|
+
message: `Community plugin "${plugin.name}" (${plugin.id} v${plugin.version}) installed successfully. Restart Tiny Claw to activate it.`,
|
|
187
|
+
plugin: {
|
|
188
|
+
id: plugin.id,
|
|
189
|
+
name: plugin.name,
|
|
190
|
+
type: plugin.type,
|
|
191
|
+
version: plugin.version,
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Remove a community plugin.
|
|
197
|
+
*
|
|
198
|
+
* Removes from `plugins.community` and `plugins.enabled`, then runs `bun remove`.
|
|
199
|
+
*/
|
|
200
|
+
export async function removeCommunityPlugin(packageName, configManager) {
|
|
201
|
+
const validated = validatePackageName(packageName);
|
|
202
|
+
if (!validated) {
|
|
203
|
+
return { success: false, message: `Invalid package name "${packageName}".` };
|
|
204
|
+
}
|
|
205
|
+
const { name } = validated;
|
|
206
|
+
const community = getCommunityPlugins(configManager);
|
|
207
|
+
if (!community.includes(name)) {
|
|
208
|
+
return {
|
|
209
|
+
success: false,
|
|
210
|
+
message: `Plugin "${name}" is not registered as a community plugin.`,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
// Remove from config lists
|
|
214
|
+
removeFromCommunityList(configManager, name);
|
|
215
|
+
// Also remove from enabled if present
|
|
216
|
+
const enabled = configManager.get('plugins.enabled') ?? [];
|
|
217
|
+
const wasEnabled = enabled.includes(name);
|
|
218
|
+
if (wasEnabled) {
|
|
219
|
+
configManager.set('plugins.enabled', enabled.filter((id) => id !== name));
|
|
220
|
+
}
|
|
221
|
+
// Uninstall the npm package
|
|
222
|
+
try {
|
|
223
|
+
Bun.spawnSync(['bun', 'remove', name], {
|
|
224
|
+
stdout: 'pipe',
|
|
225
|
+
stderr: 'pipe',
|
|
226
|
+
timeout: 30_000,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// Best-effort — config is already cleaned up
|
|
231
|
+
}
|
|
232
|
+
logger.info(`Community plugin removed: ${name}`, undefined, { emoji: '🗑️' });
|
|
233
|
+
return {
|
|
234
|
+
success: true,
|
|
235
|
+
message: wasEnabled
|
|
236
|
+
? `Community plugin "${name}" removed and disabled. Restart Tiny Claw to stop the running instance.`
|
|
237
|
+
: `Community plugin "${name}" removed.`,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* List all registered community plugins with their status.
|
|
242
|
+
* Imports are parallelized to avoid serial delays with many plugins.
|
|
243
|
+
*/
|
|
244
|
+
export async function listCommunityPlugins(configManager) {
|
|
245
|
+
const community = getCommunityPlugins(configManager);
|
|
246
|
+
const enabled = new Set(configManager.get('plugins.enabled') ?? []);
|
|
247
|
+
const results = await Promise.all(community.map(async (id) => {
|
|
248
|
+
try {
|
|
249
|
+
const mod = await import(id);
|
|
250
|
+
const plugin = mod.default;
|
|
251
|
+
if (plugin && typeof plugin === 'object') {
|
|
252
|
+
return {
|
|
253
|
+
id: plugin.id ?? id,
|
|
254
|
+
name: plugin.name ?? id,
|
|
255
|
+
type: plugin.type ?? 'unknown',
|
|
256
|
+
version: plugin.version ?? 'unknown',
|
|
257
|
+
enabled: enabled.has(id),
|
|
258
|
+
source: 'community',
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
id,
|
|
263
|
+
name: id,
|
|
264
|
+
type: 'unknown',
|
|
265
|
+
version: 'unknown',
|
|
266
|
+
enabled: enabled.has(id),
|
|
267
|
+
source: 'community',
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return {
|
|
272
|
+
id,
|
|
273
|
+
name: id,
|
|
274
|
+
type: 'unknown',
|
|
275
|
+
version: 'unresolvable',
|
|
276
|
+
enabled: enabled.has(id),
|
|
277
|
+
source: 'community',
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
}));
|
|
281
|
+
return results;
|
|
282
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
* Discovery is config-driven (not filesystem-based) so plugins explicitly
|
|
9
9
|
* opt in via their pairing flow. Import failures are non-fatal — logged and
|
|
10
10
|
* skipped so the rest of the system boots normally.
|
|
11
|
+
*
|
|
12
|
+
* Pairing-tool discovery scans the monorepo `plugins/` directories at runtime
|
|
13
|
+
* so new plugins are picked up automatically without code changes.
|
|
11
14
|
*/
|
|
12
|
-
import type { ChannelPlugin, ConfigManagerInterface, ProviderPlugin, ToolsPlugin } from '@tinyclaw/types';
|
|
15
|
+
import type { ChannelPlugin, ConfigManagerInterface, ProviderPlugin, SecretsManagerInterface, Tool, ToolsPlugin } from '@tinyclaw/types';
|
|
13
16
|
export interface LoadedPlugins {
|
|
14
17
|
channels: ChannelPlugin[];
|
|
15
18
|
providers: ProviderPlugin[];
|
|
@@ -22,3 +25,24 @@ export interface LoadedPlugins {
|
|
|
22
25
|
* @returns Grouped loaded plugin instances
|
|
23
26
|
*/
|
|
24
27
|
export declare function loadPlugins(configManager: ConfigManagerInterface): Promise<LoadedPlugins>;
|
|
28
|
+
/**
|
|
29
|
+
* Discover pairing tools from all installed plugins — not just enabled ones.
|
|
30
|
+
*
|
|
31
|
+
* This solves the chicken-and-egg problem: pairing tools (e.g. `discord_pair`)
|
|
32
|
+
* must be available to the agent *before* the plugin is enabled, otherwise the
|
|
33
|
+
* agent has no way to activate plugins conversationally.
|
|
34
|
+
*
|
|
35
|
+
* Only pairing tools are extracted here; full plugin lifecycle (start/stop) is
|
|
36
|
+
* still gated by `plugins.enabled` via `loadPlugins()`.
|
|
37
|
+
*
|
|
38
|
+
* @param enabledIds - IDs already in `plugins.enabled` (their tools are loaded
|
|
39
|
+
* separately via loadPlugins — we skip them here to avoid duplicates)
|
|
40
|
+
* @param secrets - SecretsManager for pairing tools that need it
|
|
41
|
+
* @param configManager - ConfigManager for pairing tools that need it
|
|
42
|
+
* @returns Array of pairing tools from not-yet-enabled plugins
|
|
43
|
+
*/
|
|
44
|
+
export declare function discoverPairingTools(enabledIds: string[], secrets: SecretsManagerInterface, configManager: ConfigManagerInterface): Promise<Tool[]>;
|
|
45
|
+
export type { CommunityPluginInfo, InstallResult } from './community.js';
|
|
46
|
+
export { getCommunityPlugins, installCommunityPlugin, listCommunityPlugins, removeCommunityPlugin, validatePackageName, } from './community.js';
|
|
47
|
+
export type { PluginUpdateInfo, PluginVersionInfo } from './update-checker.js';
|
|
48
|
+
export { buildPluginUpdateContext, checkPluginUpdates, } from './update-checker.js';
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,13 @@
|
|
|
8
8
|
* Discovery is config-driven (not filesystem-based) so plugins explicitly
|
|
9
9
|
* opt in via their pairing flow. Import failures are non-fatal — logged and
|
|
10
10
|
* skipped so the rest of the system boots normally.
|
|
11
|
+
*
|
|
12
|
+
* Pairing-tool discovery scans the monorepo `plugins/` directories at runtime
|
|
13
|
+
* so new plugins are picked up automatically without code changes.
|
|
11
14
|
*/
|
|
15
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
16
|
+
import { dirname, join } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
12
18
|
import { logger } from '@tinyclaw/logger';
|
|
13
19
|
/**
|
|
14
20
|
* Load all enabled plugins.
|
|
@@ -59,6 +65,122 @@ export async function loadPlugins(configManager) {
|
|
|
59
65
|
}
|
|
60
66
|
return result;
|
|
61
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Directories inside the monorepo `plugins/` folder that contain plugin
|
|
70
|
+
* packages (each sub-folder must have a `package.json` with a `name` field).
|
|
71
|
+
*/
|
|
72
|
+
const PLUGIN_CATEGORY_DIRS = ['channel', 'provider'];
|
|
73
|
+
/**
|
|
74
|
+
* Scan the workspace `plugins/` directories and return all plugin package IDs.
|
|
75
|
+
*
|
|
76
|
+
* Walks `plugins/channel/*` and `plugins/provider/*`, reads each `package.json`,
|
|
77
|
+
* and collects the `name` field. This way new plugins added to the monorepo
|
|
78
|
+
* are discovered automatically — no hardcoded list to maintain.
|
|
79
|
+
*
|
|
80
|
+
* Works for all deployment types:
|
|
81
|
+
* - **Source / dev**: scans the `plugins/` directory from the workspace root
|
|
82
|
+
* - **Docker**: same — `plugins/` is copied into the image
|
|
83
|
+
* - **npm global install**: `plugins/` won't exist, returns empty array
|
|
84
|
+
* (npm-installed plugins are resolved via dynamic `import()` at pairing
|
|
85
|
+
* time, once they're added to `plugins.enabled`)
|
|
86
|
+
*/
|
|
87
|
+
function scanInstalledPluginIds() {
|
|
88
|
+
try {
|
|
89
|
+
// Resolve monorepo root: this file lives at packages/plugins/src/index.ts
|
|
90
|
+
// (or packages/plugins/dist/index.js after build), so root is 3 levels up.
|
|
91
|
+
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
92
|
+
const workspaceRoot = join(thisDir, '..', '..', '..');
|
|
93
|
+
const pluginsRoot = join(workspaceRoot, 'plugins');
|
|
94
|
+
if (!existsSync(pluginsRoot)) {
|
|
95
|
+
logger.debug('Plugin scan: plugins/ directory not found — no discoverable plugins');
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
const ids = [];
|
|
99
|
+
for (const category of PLUGIN_CATEGORY_DIRS) {
|
|
100
|
+
const categoryDir = join(pluginsRoot, category);
|
|
101
|
+
if (!existsSync(categoryDir))
|
|
102
|
+
continue;
|
|
103
|
+
const entries = readdirSync(categoryDir, { withFileTypes: true });
|
|
104
|
+
for (const entry of entries) {
|
|
105
|
+
if (!entry.isDirectory())
|
|
106
|
+
continue;
|
|
107
|
+
const pkgPath = join(categoryDir, entry.name, 'package.json');
|
|
108
|
+
if (!existsSync(pkgPath))
|
|
109
|
+
continue;
|
|
110
|
+
try {
|
|
111
|
+
const raw = readFileSync(pkgPath, 'utf-8');
|
|
112
|
+
const pkg = JSON.parse(raw);
|
|
113
|
+
if (typeof pkg.name === 'string' && pkg.name.startsWith('@tinyclaw/plugin-')) {
|
|
114
|
+
ids.push(pkg.name);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// Malformed package.json — skip silently
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
logger.debug('Plugin scan: discovered plugins', { ids });
|
|
123
|
+
return ids;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
logger.debug('Plugin scan failed — no discoverable plugins');
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Discover pairing tools from all installed plugins — not just enabled ones.
|
|
132
|
+
*
|
|
133
|
+
* This solves the chicken-and-egg problem: pairing tools (e.g. `discord_pair`)
|
|
134
|
+
* must be available to the agent *before* the plugin is enabled, otherwise the
|
|
135
|
+
* agent has no way to activate plugins conversationally.
|
|
136
|
+
*
|
|
137
|
+
* Only pairing tools are extracted here; full plugin lifecycle (start/stop) is
|
|
138
|
+
* still gated by `plugins.enabled` via `loadPlugins()`.
|
|
139
|
+
*
|
|
140
|
+
* @param enabledIds - IDs already in `plugins.enabled` (their tools are loaded
|
|
141
|
+
* separately via loadPlugins — we skip them here to avoid duplicates)
|
|
142
|
+
* @param secrets - SecretsManager for pairing tools that need it
|
|
143
|
+
* @param configManager - ConfigManager for pairing tools that need it
|
|
144
|
+
* @returns Array of pairing tools from not-yet-enabled plugins
|
|
145
|
+
*/
|
|
146
|
+
export async function discoverPairingTools(enabledIds, secrets, configManager) {
|
|
147
|
+
const tools = [];
|
|
148
|
+
const enabledSet = new Set(enabledIds);
|
|
149
|
+
// Collect IDs from both official (monorepo) and community (config) sources
|
|
150
|
+
const officialIds = scanInstalledPluginIds();
|
|
151
|
+
const communityIds = configManager.get('plugins.community') ?? [];
|
|
152
|
+
const allPluginIds = [...new Set([...officialIds, ...communityIds])];
|
|
153
|
+
for (const id of allPluginIds) {
|
|
154
|
+
// Skip plugins that are already enabled — their pairing tools are loaded
|
|
155
|
+
// through the normal loadPlugins → getPairingTools path.
|
|
156
|
+
if (enabledSet.has(id))
|
|
157
|
+
continue;
|
|
158
|
+
try {
|
|
159
|
+
const mod = await import(id);
|
|
160
|
+
const plugin = mod.default;
|
|
161
|
+
if (!plugin || !isValidPlugin(plugin))
|
|
162
|
+
continue;
|
|
163
|
+
// Extract pairing tools from channel and provider plugins
|
|
164
|
+
if ((plugin.type === 'channel' || plugin.type === 'provider') &&
|
|
165
|
+
'getPairingTools' in plugin &&
|
|
166
|
+
typeof plugin.getPairingTools === 'function') {
|
|
167
|
+
const pairingTools = plugin.getPairingTools(secrets, configManager);
|
|
168
|
+
if (pairingTools.length > 0) {
|
|
169
|
+
tools.push(...pairingTools);
|
|
170
|
+
const source = communityIds.includes(id) ? 'community' : 'official';
|
|
171
|
+
logger.info(`Discovered pairing tools from: ${plugin.name} (${plugin.id}) [${source}]`, {
|
|
172
|
+
toolNames: pairingTools.map((t) => t.name),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
// Non-fatal — plugin may not be installed in this environment
|
|
179
|
+
logger.debug(`Could not discover plugin "${id}": ${err.message}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return tools;
|
|
183
|
+
}
|
|
62
184
|
/** Minimal structural validation for a plugin object. */
|
|
63
185
|
function isValidPlugin(obj) {
|
|
64
186
|
if (!obj || typeof obj !== 'object')
|
|
@@ -69,3 +191,7 @@ function isValidPlugin(obj) {
|
|
|
69
191
|
typeof p.type === 'string' &&
|
|
70
192
|
['channel', 'provider', 'tools'].includes(p.type));
|
|
71
193
|
}
|
|
194
|
+
// Re-export community plugin management
|
|
195
|
+
export { getCommunityPlugins, installCommunityPlugin, listCommunityPlugins, removeCommunityPlugin, validatePackageName, } from './community.js';
|
|
196
|
+
// Re-export plugin update checker
|
|
197
|
+
export { buildPluginUpdateContext, checkPluginUpdates, } from './update-checker.js';
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Update Checker
|
|
3
|
+
*
|
|
4
|
+
* Checks the npm registry for newer versions of installed Tiny Claw plugins.
|
|
5
|
+
* Results are cached locally (24-hour TTL) to avoid repeated network calls.
|
|
6
|
+
*
|
|
7
|
+
* The update info is injected into the agent's system prompt context so the
|
|
8
|
+
* AI can conversationally inform the user about available plugin upgrades.
|
|
9
|
+
*
|
|
10
|
+
* Follows the same pattern as the core update checker in @tinyclaw/core.
|
|
11
|
+
*/
|
|
12
|
+
export interface PluginVersionInfo {
|
|
13
|
+
/** Plugin package name (e.g. "@tinyclaw/plugin-channel-discord"). */
|
|
14
|
+
id: string;
|
|
15
|
+
/** Human-readable name (e.g. "Discord"). */
|
|
16
|
+
name: string;
|
|
17
|
+
/** Currently installed version. */
|
|
18
|
+
current: string;
|
|
19
|
+
/** Latest version published on npm. */
|
|
20
|
+
latest: string;
|
|
21
|
+
/** Whether a newer version is available. */
|
|
22
|
+
updateAvailable: boolean;
|
|
23
|
+
}
|
|
24
|
+
export interface PluginUpdateInfo {
|
|
25
|
+
/** Plugins with version information. */
|
|
26
|
+
plugins: PluginVersionInfo[];
|
|
27
|
+
/** Number of plugins with available updates. */
|
|
28
|
+
updatableCount: number;
|
|
29
|
+
/** Detected runtime environment. */
|
|
30
|
+
runtime: 'npm' | 'docker' | 'source';
|
|
31
|
+
/** Timestamp (ms) of the last check. */
|
|
32
|
+
checkedAt: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Check all installed plugins for available updates.
|
|
36
|
+
*
|
|
37
|
+
* Scans both official (monorepo) and community (config-registered) plugins.
|
|
38
|
+
*
|
|
39
|
+
* - Returns cached result if still fresh (< 24 hours old).
|
|
40
|
+
* - Otherwise fetches the npm registry for each plugin.
|
|
41
|
+
* - Never throws — returns null on any failure.
|
|
42
|
+
*
|
|
43
|
+
* @param dataDir - The tinyclaw data directory (e.g. `~/.tinyclaw`).
|
|
44
|
+
* @param communityPluginIds - Optional list of community plugin IDs to also check.
|
|
45
|
+
*/
|
|
46
|
+
export declare function checkPluginUpdates(dataDir: string, communityPluginIds?: string[]): Promise<PluginUpdateInfo | null>;
|
|
47
|
+
/**
|
|
48
|
+
* Build a system prompt section that informs the agent about available
|
|
49
|
+
* plugin updates. Returns an empty string if no updates are available.
|
|
50
|
+
*/
|
|
51
|
+
export declare function buildPluginUpdateContext(info: PluginUpdateInfo | null): string;
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Update Checker
|
|
3
|
+
*
|
|
4
|
+
* Checks the npm registry for newer versions of installed Tiny Claw plugins.
|
|
5
|
+
* Results are cached locally (24-hour TTL) to avoid repeated network calls.
|
|
6
|
+
*
|
|
7
|
+
* The update info is injected into the agent's system prompt context so the
|
|
8
|
+
* AI can conversationally inform the user about available plugin upgrades.
|
|
9
|
+
*
|
|
10
|
+
* Follows the same pattern as the core update checker in @tinyclaw/core.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { logger } from '@tinyclaw/logger';
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Constants
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
/** Time-to-live for the cache file (24 hours). */
|
|
20
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
21
|
+
/** npm registry base URL. */
|
|
22
|
+
const NPM_REGISTRY_BASE = 'https://registry.npmjs.org';
|
|
23
|
+
/** Maximum time to wait for each registry response (ms). */
|
|
24
|
+
const FETCH_TIMEOUT_MS = 5_000;
|
|
25
|
+
/** Cache file name within the data directory. */
|
|
26
|
+
const CACHE_FILENAME = 'plugin-update-check.json';
|
|
27
|
+
/**
|
|
28
|
+
* Directories inside the monorepo `plugins/` folder that contain plugin
|
|
29
|
+
* packages.
|
|
30
|
+
*/
|
|
31
|
+
const PLUGIN_CATEGORY_DIRS = ['channel', 'provider'];
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Semver comparison (minimal — same as core update-checker)
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
function isNewerVersion(current, latest) {
|
|
36
|
+
const parse = (v) => v
|
|
37
|
+
.replace(/^v/, '')
|
|
38
|
+
.replace(/[-+].*$/, '')
|
|
39
|
+
.split('.')
|
|
40
|
+
.map((s) => {
|
|
41
|
+
const n = Number(s);
|
|
42
|
+
return Number.isNaN(n) ? 0 : n;
|
|
43
|
+
})
|
|
44
|
+
.slice(0, 3);
|
|
45
|
+
const [cMaj = 0, cMin = 0, cPat = 0] = parse(current);
|
|
46
|
+
const [lMaj = 0, lMin = 0, lPat = 0] = parse(latest);
|
|
47
|
+
if (lMaj !== cMaj)
|
|
48
|
+
return lMaj > cMaj;
|
|
49
|
+
if (lMin !== cMin)
|
|
50
|
+
return lMin > cMin;
|
|
51
|
+
return lPat > cPat;
|
|
52
|
+
}
|
|
53
|
+
/** Matches a semver-like version string. */
|
|
54
|
+
const SEMVER_RE = /^v?\d+\.\d+\.\d+/;
|
|
55
|
+
/** Sanitize a version string for safe prompt interpolation. */
|
|
56
|
+
function sanitizeVersion(value) {
|
|
57
|
+
const trimmed = value.trim();
|
|
58
|
+
if (!SEMVER_RE.test(trimmed))
|
|
59
|
+
return 'unknown';
|
|
60
|
+
return trimmed.replace(/^(v?\d+\.\d+\.\d+)[\s\S]*$/, '$1');
|
|
61
|
+
}
|
|
62
|
+
/** Sanitize a package name for safe prompt interpolation. */
|
|
63
|
+
function sanitizePackageName(value) {
|
|
64
|
+
// Only allow scoped npm package names: @scope/name with alphanumeric, hyphens, dots
|
|
65
|
+
return value.replace(/[^a-zA-Z0-9@/_.-]/g, '');
|
|
66
|
+
}
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Runtime detection
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
function detectRuntime() {
|
|
71
|
+
const envRuntime = process.env.TINYCLAW_RUNTIME?.toLowerCase();
|
|
72
|
+
if (envRuntime === 'docker')
|
|
73
|
+
return 'docker';
|
|
74
|
+
if (envRuntime === 'source')
|
|
75
|
+
return 'source';
|
|
76
|
+
try {
|
|
77
|
+
if (existsSync('/.dockerenv'))
|
|
78
|
+
return 'docker';
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// Permission errors — assume npm
|
|
82
|
+
}
|
|
83
|
+
return 'npm';
|
|
84
|
+
}
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Community plugin scanning
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
/**
|
|
89
|
+
* Resolve community (non-monorepo) plugins by dynamically importing them
|
|
90
|
+
* and reading their package metadata. Imports run in parallel.
|
|
91
|
+
*/
|
|
92
|
+
async function scanCommunityPlugins(communityIds) {
|
|
93
|
+
const results = await Promise.all(communityIds
|
|
94
|
+
.filter((id) => !id.startsWith('@tinyclaw/plugin-'))
|
|
95
|
+
.map(async (id) => {
|
|
96
|
+
try {
|
|
97
|
+
const mod = await import(id);
|
|
98
|
+
const plugin = mod.default;
|
|
99
|
+
if (plugin && typeof plugin === 'object' && typeof plugin.version === 'string') {
|
|
100
|
+
return {
|
|
101
|
+
id,
|
|
102
|
+
name: typeof plugin.name === 'string' ? plugin.name : id,
|
|
103
|
+
version: plugin.version,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}));
|
|
112
|
+
return results.filter((p) => p !== null);
|
|
113
|
+
}
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Official plugin scanning
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
/**
|
|
118
|
+
* Scan the workspace for installed plugins and their current versions.
|
|
119
|
+
*/
|
|
120
|
+
function scanInstalledPlugins() {
|
|
121
|
+
try {
|
|
122
|
+
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
123
|
+
const workspaceRoot = join(thisDir, '..', '..', '..');
|
|
124
|
+
const pluginsRoot = join(workspaceRoot, 'plugins');
|
|
125
|
+
if (!existsSync(pluginsRoot))
|
|
126
|
+
return [];
|
|
127
|
+
const plugins = [];
|
|
128
|
+
for (const category of PLUGIN_CATEGORY_DIRS) {
|
|
129
|
+
const categoryDir = join(pluginsRoot, category);
|
|
130
|
+
if (!existsSync(categoryDir))
|
|
131
|
+
continue;
|
|
132
|
+
const entries = readdirSync(categoryDir, { withFileTypes: true });
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
if (!entry.isDirectory())
|
|
135
|
+
continue;
|
|
136
|
+
const pkgPath = join(categoryDir, entry.name, 'package.json');
|
|
137
|
+
if (!existsSync(pkgPath))
|
|
138
|
+
continue;
|
|
139
|
+
try {
|
|
140
|
+
const raw = readFileSync(pkgPath, 'utf-8');
|
|
141
|
+
const pkg = JSON.parse(raw);
|
|
142
|
+
if (typeof pkg.name === 'string' && pkg.name.startsWith('@tinyclaw/plugin-')) {
|
|
143
|
+
plugins.push({
|
|
144
|
+
id: pkg.name,
|
|
145
|
+
name: pkg.description?.replace(/plugin for Tiny Claw/i, '').trim() || entry.name,
|
|
146
|
+
version: pkg.version || '0.0.0',
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// Malformed package.json — skip
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return plugins;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Cache I/O
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
function getCachePath(dataDir) {
|
|
165
|
+
return join(dataDir, 'data', CACHE_FILENAME);
|
|
166
|
+
}
|
|
167
|
+
function readCache(dataDir) {
|
|
168
|
+
try {
|
|
169
|
+
const raw = readFileSync(getCachePath(dataDir), 'utf-8');
|
|
170
|
+
const cached = JSON.parse(raw);
|
|
171
|
+
if (cached &&
|
|
172
|
+
typeof cached.checkedAt === 'number' &&
|
|
173
|
+
Array.isArray(cached.plugins) &&
|
|
174
|
+
typeof cached.updatableCount === 'number') {
|
|
175
|
+
return cached;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
// Missing or corrupt — will re-check
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
function writeCache(dataDir, info) {
|
|
184
|
+
try {
|
|
185
|
+
const dir = join(dataDir, 'data');
|
|
186
|
+
mkdirSync(dir, { recursive: true });
|
|
187
|
+
writeFileSync(getCachePath(dataDir), JSON.stringify(info, null, 2), 'utf-8');
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
logger.debug('Failed to write plugin update cache', err);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Registry fetch
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
async function fetchLatestPluginVersion(packageName) {
|
|
197
|
+
const controller = new AbortController();
|
|
198
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
199
|
+
try {
|
|
200
|
+
const url = `${NPM_REGISTRY_BASE}/${encodeURIComponent(packageName)}/latest`;
|
|
201
|
+
const res = await fetch(url, {
|
|
202
|
+
signal: controller.signal,
|
|
203
|
+
headers: { Accept: 'application/json' },
|
|
204
|
+
});
|
|
205
|
+
if (!res.ok)
|
|
206
|
+
return null;
|
|
207
|
+
const data = (await res.json());
|
|
208
|
+
return data.version ?? null;
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
clearTimeout(timeout);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// Public API
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
/**
|
|
221
|
+
* Check all installed plugins for available updates.
|
|
222
|
+
*
|
|
223
|
+
* Scans both official (monorepo) and community (config-registered) plugins.
|
|
224
|
+
*
|
|
225
|
+
* - Returns cached result if still fresh (< 24 hours old).
|
|
226
|
+
* - Otherwise fetches the npm registry for each plugin.
|
|
227
|
+
* - Never throws — returns null on any failure.
|
|
228
|
+
*
|
|
229
|
+
* @param dataDir - The tinyclaw data directory (e.g. `~/.tinyclaw`).
|
|
230
|
+
* @param communityPluginIds - Optional list of community plugin IDs to also check.
|
|
231
|
+
*/
|
|
232
|
+
export async function checkPluginUpdates(dataDir, communityPluginIds) {
|
|
233
|
+
try {
|
|
234
|
+
const officialPlugins = scanInstalledPlugins();
|
|
235
|
+
const communityPlugins = await scanCommunityPlugins(communityPluginIds ?? []);
|
|
236
|
+
const installed = [...officialPlugins, ...communityPlugins];
|
|
237
|
+
if (installed.length === 0)
|
|
238
|
+
return null;
|
|
239
|
+
// Return cached result if still fresh AND the plugin set hasn't changed.
|
|
240
|
+
// Adding or removing a plugin busts the cache so the new plugin is checked.
|
|
241
|
+
const cached = readCache(dataDir);
|
|
242
|
+
const cachedIds = new Set(cached?.plugins.map((p) => p.id) ?? []);
|
|
243
|
+
const installedIds = new Set(installed.map((p) => p.id));
|
|
244
|
+
const samePluginSet = cachedIds.size === installedIds.size && [...installedIds].every((id) => cachedIds.has(id));
|
|
245
|
+
if (cached && samePluginSet && Date.now() - cached.checkedAt < CACHE_TTL_MS) {
|
|
246
|
+
// Re-evaluate against currently installed versions
|
|
247
|
+
const refreshed = cached.plugins.map((cp) => {
|
|
248
|
+
const local = installed.find((ip) => ip.id === cp.id);
|
|
249
|
+
const current = local?.version ?? cp.current;
|
|
250
|
+
return {
|
|
251
|
+
...cp,
|
|
252
|
+
current,
|
|
253
|
+
updateAvailable: isNewerVersion(current, cp.latest),
|
|
254
|
+
};
|
|
255
|
+
});
|
|
256
|
+
const updatableCount = refreshed.filter((p) => p.updateAvailable).length;
|
|
257
|
+
return { ...cached, plugins: refreshed, updatableCount };
|
|
258
|
+
}
|
|
259
|
+
// Fetch latest versions from npm (parallel, with individual timeouts)
|
|
260
|
+
const results = await Promise.all(installed.map(async (plugin) => {
|
|
261
|
+
const latest = await fetchLatestPluginVersion(plugin.id);
|
|
262
|
+
return {
|
|
263
|
+
id: plugin.id,
|
|
264
|
+
name: plugin.name,
|
|
265
|
+
current: plugin.version,
|
|
266
|
+
latest: latest ?? plugin.version,
|
|
267
|
+
updateAvailable: latest ? isNewerVersion(plugin.version, latest) : false,
|
|
268
|
+
};
|
|
269
|
+
}));
|
|
270
|
+
const runtime = detectRuntime();
|
|
271
|
+
const info = {
|
|
272
|
+
plugins: results,
|
|
273
|
+
updatableCount: results.filter((p) => p.updateAvailable).length,
|
|
274
|
+
runtime,
|
|
275
|
+
checkedAt: Date.now(),
|
|
276
|
+
};
|
|
277
|
+
writeCache(dataDir, info);
|
|
278
|
+
if (info.updatableCount > 0) {
|
|
279
|
+
logger.info('Plugin updates available', {
|
|
280
|
+
count: info.updatableCount,
|
|
281
|
+
plugins: results
|
|
282
|
+
.filter((p) => p.updateAvailable)
|
|
283
|
+
.map((p) => `${p.id}@${p.current} → ${p.latest}`),
|
|
284
|
+
}, { emoji: '🔌' });
|
|
285
|
+
}
|
|
286
|
+
return info;
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
logger.debug('Plugin update check failed', err);
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// System prompt context builder
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
/**
|
|
297
|
+
* Build a system prompt section that informs the agent about available
|
|
298
|
+
* plugin updates. Returns an empty string if no updates are available.
|
|
299
|
+
*/
|
|
300
|
+
export function buildPluginUpdateContext(info) {
|
|
301
|
+
if (!info || info.updatableCount === 0)
|
|
302
|
+
return '';
|
|
303
|
+
const updatable = info.plugins.filter((p) => p.updateAvailable);
|
|
304
|
+
const pluginLines = updatable
|
|
305
|
+
.map((p) => {
|
|
306
|
+
const safeName = sanitizePackageName(p.id);
|
|
307
|
+
const safeCurrent = sanitizeVersion(p.current);
|
|
308
|
+
const safeLatest = sanitizeVersion(p.latest);
|
|
309
|
+
return `- **${safeName}**: ${safeCurrent} → ${safeLatest}`;
|
|
310
|
+
})
|
|
311
|
+
.join('\n');
|
|
312
|
+
const upgradeInstructions = info.runtime === 'npm'
|
|
313
|
+
? `Since you are running as an npm global install, you can upgrade plugins using the shell tool:
|
|
314
|
+
${updatable.map((p) => `\`bun install -g ${sanitizePackageName(p.id)}@latest\``).join('\n')}
|
|
315
|
+
After upgrading, request a restart using the tinyclaw_restart tool.`
|
|
316
|
+
: info.runtime === 'docker'
|
|
317
|
+
? `Since you are running inside a Docker container, plugin updates are included in the new image.
|
|
318
|
+
Instruct the owner to pull the latest image and restart the container.`
|
|
319
|
+
: `Since you are running from source, instruct the owner to update and rebuild:
|
|
320
|
+
\`git pull && bun install && bun run build:plugins\`
|
|
321
|
+
Then restart using the tinyclaw_restart tool.`;
|
|
322
|
+
return `
|
|
323
|
+
|
|
324
|
+
## Plugin Updates Available
|
|
325
|
+
${pluginLines}
|
|
326
|
+
|
|
327
|
+
${upgradeInstructions}
|
|
328
|
+
|
|
329
|
+
**Behavior guidelines:**
|
|
330
|
+
- Mention plugin updates naturally when relevant, but do not interrupt ongoing tasks.
|
|
331
|
+
- Do not repeat the plugin update reminder if the owner has already acknowledged or dismissed it.
|
|
332
|
+
- Plugin updates are separate from core Tiny Claw updates — both should be kept current.`;
|
|
333
|
+
}
|