@tinyclaw/plugins 2.0.0-dev.a9dfb14 → 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.
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinyclaw/plugins",
3
- "version": "2.0.0-dev.a9dfb14",
3
+ "version": "2.0.0-dev.b27966d",
4
4
  "description": "Config-driven plugin loader and validator for Tiny Claw",
5
5
  "license": "GPL-3.0",
6
6
  "author": "Waren Gonzaga",