@adversity/coding-tool-x 2.6.0 → 3.0.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.
@@ -0,0 +1,229 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { getPlugin } = require('./registry');
4
+ const { INSTALLED_DIR, CONFIG_DIR } = require('./constants');
5
+ const { createPluginContext } = require('./plugin-api');
6
+
7
+ // Track loaded plugins
8
+ const loadedPlugins = new Map();
9
+
10
+ /**
11
+ * Load and activate a plugin
12
+ * @param {string} name - Plugin name
13
+ * @returns {Object} Result object with success status and optional error
14
+ */
15
+ function loadPlugin(name) {
16
+ // ERROR ISOLATION: Wrap entire function to prevent CTX crash
17
+ try {
18
+ // Check if already loaded
19
+ if (loadedPlugins.has(name)) {
20
+ return {
21
+ success: true,
22
+ message: `Plugin '${name}' already loaded`,
23
+ };
24
+ }
25
+
26
+ // Get plugin info from registry
27
+ const pluginInfo = getPlugin(name);
28
+ if (!pluginInfo) {
29
+ return {
30
+ success: false,
31
+ error: `Plugin '${name}' not found in registry`,
32
+ };
33
+ }
34
+
35
+ // Check if enabled
36
+ if (!pluginInfo.enabled) {
37
+ return {
38
+ success: false,
39
+ error: `Plugin '${name}' is disabled`,
40
+ };
41
+ }
42
+
43
+ // Construct path to installed plugin
44
+ const pluginDir = path.join(INSTALLED_DIR, name);
45
+ if (!fs.existsSync(pluginDir)) {
46
+ return {
47
+ success: false,
48
+ error: `Plugin directory not found: ${pluginDir}`,
49
+ };
50
+ }
51
+
52
+ // Read and validate plugin.json
53
+ const pluginJsonPath = path.join(pluginDir, 'plugin.json');
54
+ if (!fs.existsSync(pluginJsonPath)) {
55
+ return {
56
+ success: false,
57
+ error: `plugin.json not found in ${pluginDir}`,
58
+ };
59
+ }
60
+
61
+ let pluginJson;
62
+ try {
63
+ const content = fs.readFileSync(pluginJsonPath, 'utf8');
64
+ pluginJson = JSON.parse(content);
65
+ } catch (error) {
66
+ return {
67
+ success: false,
68
+ error: `Failed to parse plugin.json: ${error.message}`,
69
+ };
70
+ }
71
+
72
+ // Validate plugin.json structure
73
+ if (!pluginJson.name || !pluginJson.version || !pluginJson.main) {
74
+ return {
75
+ success: false,
76
+ error: 'Invalid plugin.json: missing name, version, or main',
77
+ };
78
+ }
79
+
80
+ // Require plugin's main file
81
+ const mainPath = path.join(pluginDir, pluginJson.main);
82
+
83
+ // SECURITY: Verify resolved path is within pluginDir (prevent path traversal)
84
+ const resolvedMainPath = path.resolve(mainPath);
85
+ const resolvedPluginDir = path.resolve(pluginDir);
86
+ if (!resolvedMainPath.startsWith(resolvedPluginDir + path.sep)) {
87
+ return {
88
+ success: false,
89
+ error: `Security violation: Main file path traversal detected: ${pluginJson.main}`,
90
+ };
91
+ }
92
+
93
+ if (!fs.existsSync(mainPath)) {
94
+ return {
95
+ success: false,
96
+ error: `Main file not found: ${mainPath}`,
97
+ };
98
+ }
99
+
100
+ let pluginModule;
101
+ try {
102
+ pluginModule = require(mainPath);
103
+ } catch (error) {
104
+ return {
105
+ success: false,
106
+ error: `Failed to require plugin: ${error.message}`,
107
+ };
108
+ }
109
+
110
+ // Verify exports: { activate, deactivate }
111
+ if (typeof pluginModule.activate !== 'function') {
112
+ return {
113
+ success: false,
114
+ error: 'Plugin must export an activate() function',
115
+ };
116
+ }
117
+
118
+ // Load plugin-specific configuration
119
+ const pluginConfigPath = path.join(CONFIG_DIR, `${name}.json`);
120
+ let pluginConfig = {};
121
+ if (fs.existsSync(pluginConfigPath)) {
122
+ try {
123
+ const configContent = fs.readFileSync(pluginConfigPath, 'utf8');
124
+ pluginConfig = JSON.parse(configContent);
125
+ } catch (error) {
126
+ // Non-fatal: log but continue with empty config
127
+ console.warn(`[plugin-loader] Failed to load config for ${name}:`, error.message);
128
+ }
129
+ }
130
+
131
+ // Create plugin context
132
+ const ctx = createPluginContext(name, pluginConfig, pluginDir);
133
+
134
+ // Call activate() wrapped in try/catch
135
+ try {
136
+ pluginModule.activate(ctx);
137
+ } catch (error) {
138
+ return {
139
+ success: false,
140
+ error: `Plugin activation failed: ${error.message}`,
141
+ };
142
+ }
143
+
144
+ // Track loaded plugin
145
+ loadedPlugins.set(name, {
146
+ module: pluginModule,
147
+ context: ctx,
148
+ info: pluginJson,
149
+ loadedAt: new Date().toISOString(),
150
+ });
151
+
152
+ return {
153
+ success: true,
154
+ message: `Plugin '${name}' loaded successfully`,
155
+ };
156
+ } catch (error) {
157
+ // ERROR ISOLATION: Catch any unexpected errors
158
+ return {
159
+ success: false,
160
+ error: `Unexpected error loading plugin '${name}': ${error.message}`,
161
+ };
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Unload and deactivate a plugin
167
+ * @param {string} name - Plugin name
168
+ * @returns {Object} Result object with success status
169
+ */
170
+ function unloadPlugin(name) {
171
+ try {
172
+ // Check if loaded
173
+ if (!loadedPlugins.has(name)) {
174
+ return {
175
+ success: false,
176
+ error: `Plugin '${name}' is not loaded`,
177
+ };
178
+ }
179
+
180
+ const plugin = loadedPlugins.get(name);
181
+
182
+ // Call deactivate() if exists
183
+ if (typeof plugin.module.deactivate === 'function') {
184
+ try {
185
+ plugin.module.deactivate();
186
+ } catch (error) {
187
+ // Log but don't fail unload
188
+ console.error(`[plugin-loader] Error during deactivation of '${name}':`, error.message);
189
+ }
190
+ }
191
+
192
+ // Remove from tracking
193
+ loadedPlugins.delete(name);
194
+
195
+ return {
196
+ success: true,
197
+ message: `Plugin '${name}' unloaded successfully`,
198
+ };
199
+ } catch (error) {
200
+ return {
201
+ success: false,
202
+ error: `Unexpected error unloading plugin '${name}': ${error.message}`,
203
+ };
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Get list of currently loaded plugin names
209
+ * @returns {Array<string>} Array of loaded plugin names
210
+ */
211
+ function getLoadedPlugins() {
212
+ return Array.from(loadedPlugins.keys());
213
+ }
214
+
215
+ /**
216
+ * Get loaded plugin details (internal use)
217
+ * @param {string} name - Plugin name
218
+ * @returns {Object|undefined} Plugin details or undefined
219
+ */
220
+ function getLoadedPluginDetails(name) {
221
+ return loadedPlugins.get(name);
222
+ }
223
+
224
+ module.exports = {
225
+ loadPlugin,
226
+ unloadPlugin,
227
+ getLoadedPlugins,
228
+ getLoadedPluginDetails,
229
+ };
@@ -0,0 +1,170 @@
1
+ const { loadRegistry, listPlugins } = require('./registry');
2
+ const { loadPlugin, unloadPlugin, getLoadedPlugins, getLoadedPluginDetails } = require('./plugin-loader');
3
+ const eventBus = require('./event-bus');
4
+
5
+ // Module state
6
+ let initialized = false;
7
+ const registeredCommands = new Map(); // { commandName: { pluginName, handler } }
8
+
9
+ /**
10
+ * Initialize all enabled plugins
11
+ * @param {Object} [options] - Initialization options
12
+ * @param {Object} [options.config] - Application configuration
13
+ * @param {Array} [options.args] - CLI arguments
14
+ * @returns {Object} Result with loaded count and failed plugins
15
+ */
16
+ function initializePlugins(options = {}) {
17
+ if (initialized) {
18
+ return {
19
+ loaded: getLoadedPlugins().length,
20
+ failed: [],
21
+ message: 'Plugins already initialized',
22
+ };
23
+ }
24
+
25
+ const { config = {}, args = [] } = options;
26
+ const failed = [];
27
+ let loadedCount = 0;
28
+
29
+ // Load registry
30
+ const registry = loadRegistry();
31
+ const plugins = Object.entries(registry.plugins || {});
32
+
33
+ // Sort plugins by loadOrder (lower = earlier)
34
+ const sortedPlugins = plugins
35
+ .filter(([, info]) => info.enabled)
36
+ .sort((a, b) => (a[1].loadOrder || 10) - (b[1].loadOrder || 10));
37
+
38
+ // Load each enabled plugin
39
+ for (const [name] of sortedPlugins) {
40
+ const result = loadPlugin(name);
41
+
42
+ if (result.success) {
43
+ loadedCount++;
44
+
45
+ // Collect registered commands from this plugin
46
+ const pluginDetails = getLoadedPluginDetails(name);
47
+ if (pluginDetails && pluginDetails.context) {
48
+ const commands = pluginDetails.context._getCommands();
49
+ commands.forEach((handler, cmdName) => {
50
+ registeredCommands.set(cmdName, { pluginName: name, handler });
51
+ });
52
+ }
53
+ } else {
54
+ failed.push({ name, error: result.error });
55
+ }
56
+ }
57
+
58
+ // Emit cli:init event
59
+ eventBus.emitSync('cli:init', { config, args });
60
+
61
+ initialized = true;
62
+
63
+ return {
64
+ loaded: loadedCount,
65
+ failed,
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Shutdown all loaded plugins
71
+ * @returns {Object} Confirmation of shutdown
72
+ */
73
+ function shutdownPlugins() {
74
+ if (!initialized) {
75
+ return {
76
+ success: true,
77
+ message: 'Plugins not initialized, nothing to shutdown',
78
+ };
79
+ }
80
+
81
+ // Emit cli:shutdown event
82
+ eventBus.emitSync('cli:shutdown', {});
83
+
84
+ // Unload each loaded plugin in reverse order
85
+ const loadedNames = getLoadedPlugins();
86
+ const errors = [];
87
+
88
+ for (const name of loadedNames.reverse()) {
89
+ const result = unloadPlugin(name);
90
+ if (!result.success) {
91
+ errors.push({ name, error: result.error });
92
+ }
93
+ }
94
+
95
+ // Clear command registry
96
+ registeredCommands.clear();
97
+ initialized = false;
98
+
99
+ return {
100
+ success: errors.length === 0,
101
+ unloaded: loadedNames.length - errors.length,
102
+ errors,
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Get all registered commands from plugins
108
+ * @returns {Map} Map of { commandName: { pluginName, handler } }
109
+ */
110
+ function getRegisteredCommands() {
111
+ return new Map(registeredCommands);
112
+ }
113
+
114
+ /**
115
+ * Check if a command is registered by a plugin
116
+ * @param {string} name - Command name
117
+ * @returns {boolean} True if command exists
118
+ */
119
+ function isPluginCommand(name) {
120
+ return registeredCommands.has(name);
121
+ }
122
+
123
+ /**
124
+ * Execute a plugin-registered command
125
+ * @param {string} name - Command name
126
+ * @param {Array} args - Command arguments
127
+ * @returns {Object} Result with success status and result/error
128
+ */
129
+ async function executePluginCommand(name, args = []) {
130
+ if (!registeredCommands.has(name)) {
131
+ return {
132
+ success: false,
133
+ error: `Command '${name}' not found`,
134
+ };
135
+ }
136
+
137
+ const { pluginName, handler } = registeredCommands.get(name);
138
+
139
+ try {
140
+ const result = await handler(args);
141
+ return {
142
+ success: true,
143
+ result,
144
+ pluginName,
145
+ };
146
+ } catch (error) {
147
+ return {
148
+ success: false,
149
+ error: `Error executing command '${name}' from plugin '${pluginName}': ${error.message}`,
150
+ pluginName,
151
+ };
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Check if plugins are initialized
157
+ * @returns {boolean} Initialization status
158
+ */
159
+ function isInitialized() {
160
+ return initialized;
161
+ }
162
+
163
+ module.exports = {
164
+ initializePlugins,
165
+ shutdownPlugins,
166
+ getRegisteredCommands,
167
+ isPluginCommand,
168
+ executePluginCommand,
169
+ isInitialized,
170
+ };
@@ -0,0 +1,152 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { PLUGINS_DIR, REGISTRY_FILE } = require('./constants');
4
+
5
+ /**
6
+ * Ensure plugins directory exists
7
+ */
8
+ function ensurePluginsDir() {
9
+ if (!fs.existsSync(PLUGINS_DIR)) {
10
+ fs.mkdirSync(PLUGINS_DIR, { recursive: true });
11
+ }
12
+ }
13
+
14
+ /**
15
+ * Load registry.json, create with empty structure if missing
16
+ * @returns {Object} Registry data
17
+ */
18
+ function loadRegistry() {
19
+ ensurePluginsDir();
20
+
21
+ if (!fs.existsSync(REGISTRY_FILE)) {
22
+ const emptyRegistry = { plugins: {} };
23
+ fs.writeFileSync(REGISTRY_FILE, JSON.stringify(emptyRegistry, null, 2), 'utf8');
24
+ return emptyRegistry;
25
+ }
26
+
27
+ try {
28
+ const content = fs.readFileSync(REGISTRY_FILE, 'utf8');
29
+ const data = JSON.parse(content);
30
+
31
+ // Ensure plugins key exists
32
+ if (!data.plugins) {
33
+ data.plugins = {};
34
+ }
35
+
36
+ return data;
37
+ } catch (error) {
38
+ console.error('Failed to load registry:', error.message);
39
+ return { plugins: {} };
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Save registry data to registry.json
45
+ * @param {Object} data - Registry data
46
+ */
47
+ function saveRegistry(data) {
48
+ ensurePluginsDir();
49
+
50
+ try {
51
+ fs.writeFileSync(REGISTRY_FILE, JSON.stringify(data, null, 2), 'utf8');
52
+ } catch (error) {
53
+ throw new Error(`Failed to save registry: ${error.message}`);
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Add plugin to registry
59
+ * @param {string} name - Plugin name
60
+ * @param {Object} info - Plugin information
61
+ * @param {string} info.version - Plugin version
62
+ * @param {boolean} [info.enabled=true] - Whether plugin is enabled
63
+ * @param {string} [info.installedAt] - Installation timestamp
64
+ * @param {string} [info.source] - Git URL or source
65
+ * @param {number} [info.loadOrder=10] - Load order priority
66
+ * @returns {Object} Updated registry
67
+ */
68
+ function addPlugin(name, info) {
69
+ const registry = loadRegistry();
70
+
71
+ registry.plugins[name] = {
72
+ version: info.version,
73
+ enabled: info.enabled !== undefined ? info.enabled : true,
74
+ installedAt: info.installedAt || new Date().toISOString(),
75
+ source: info.source || '',
76
+ loadOrder: info.loadOrder !== undefined ? info.loadOrder : 10,
77
+ };
78
+
79
+ saveRegistry(registry);
80
+ return registry;
81
+ }
82
+
83
+ /**
84
+ * Remove plugin from registry
85
+ * @param {string} name - Plugin name
86
+ * @returns {Object} Updated registry
87
+ */
88
+ function removePlugin(name) {
89
+ const registry = loadRegistry();
90
+
91
+ if (registry.plugins[name]) {
92
+ delete registry.plugins[name];
93
+ saveRegistry(registry);
94
+ }
95
+
96
+ return registry;
97
+ }
98
+
99
+ /**
100
+ * Get plugin info
101
+ * @param {string} name - Plugin name
102
+ * @returns {Object|null} Plugin info or null if not found
103
+ */
104
+ function getPlugin(name) {
105
+ const registry = loadRegistry();
106
+ return registry.plugins[name] || null;
107
+ }
108
+
109
+ /**
110
+ * List all plugins
111
+ * @returns {Array} Array of plugin objects with name included
112
+ */
113
+ function listPlugins() {
114
+ const registry = loadRegistry();
115
+
116
+ return Object.entries(registry.plugins).map(([name, info]) => ({
117
+ name,
118
+ ...info,
119
+ }));
120
+ }
121
+
122
+ /**
123
+ * Update plugin info (partial update)
124
+ * @param {string} name - Plugin name
125
+ * @param {Object} updates - Partial updates to apply
126
+ * @returns {Object} Updated plugin info
127
+ */
128
+ function updatePlugin(name, updates) {
129
+ const registry = loadRegistry();
130
+
131
+ if (!registry.plugins[name]) {
132
+ throw new Error(`Plugin '${name}' not found in registry`);
133
+ }
134
+
135
+ registry.plugins[name] = {
136
+ ...registry.plugins[name],
137
+ ...updates,
138
+ };
139
+
140
+ saveRegistry(registry);
141
+ return registry.plugins[name];
142
+ }
143
+
144
+ module.exports = {
145
+ loadRegistry,
146
+ saveRegistry,
147
+ addPlugin,
148
+ removePlugin,
149
+ getPlugin,
150
+ listPlugins,
151
+ updatePlugin,
152
+ };
@@ -0,0 +1,115 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://github.com/zhangzeao/coding-tool/blob/main/src/plugins/schema/plugin-manifest.json",
4
+ "title": "CTX Plugin Manifest",
5
+ "description": "Schema for Coding-Tool-X (ctx) plugin manifest file",
6
+ "type": "object",
7
+ "required": ["name", "version", "description", "main", "ctx"],
8
+ "properties": {
9
+ "name": {
10
+ "type": "string",
11
+ "description": "Plugin name in kebab-case, must start with 'ctx-plugin-'",
12
+ "pattern": "^ctx-plugin-[a-z0-9-]+$"
13
+ },
14
+ "version": {
15
+ "type": "string",
16
+ "description": "Semantic version string",
17
+ "pattern": "^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.-]+)?(\\+[a-zA-Z0-9.-]+)?$"
18
+ },
19
+ "description": {
20
+ "type": "string",
21
+ "description": "Brief description of what the plugin does",
22
+ "minLength": 1,
23
+ "maxLength": 200
24
+ },
25
+ "main": {
26
+ "type": "string",
27
+ "description": "Entry point JavaScript file (relative to plugin root)",
28
+ "pattern": "^(?!.*\\.\\.)(?!.*\\.\\/)[a-zA-Z0-9_-]+(?:\\/[a-zA-Z0-9_-]+)*\\.js$"
29
+ },
30
+ "ctx": {
31
+ "type": "object",
32
+ "description": "CTX-specific metadata",
33
+ "required": ["minVersion", "hooks"],
34
+ "properties": {
35
+ "minVersion": {
36
+ "type": "string",
37
+ "description": "Minimum CTX version required",
38
+ "pattern": "^\\d+\\.\\d+\\.\\d+$"
39
+ },
40
+ "hooks": {
41
+ "type": "array",
42
+ "description": "Lifecycle hooks this plugin subscribes to",
43
+ "items": {
44
+ "type": "string",
45
+ "enum": [
46
+ "cli:init",
47
+ "cli:shutdown",
48
+ "cli:command:before",
49
+ "cli:command:after",
50
+ "config:loaded",
51
+ "config:saved",
52
+ "proxy:start",
53
+ "proxy:stop"
54
+ ]
55
+ },
56
+ "uniqueItems": true,
57
+ "minItems": 1
58
+ }
59
+ },
60
+ "additionalProperties": false
61
+ },
62
+ "author": {
63
+ "type": "string",
64
+ "description": "Author name and optional email"
65
+ },
66
+ "license": {
67
+ "type": "string",
68
+ "description": "SPDX license identifier"
69
+ },
70
+ "repository": {
71
+ "type": "string",
72
+ "description": "Git repository URL",
73
+ "format": "uri"
74
+ },
75
+ "dependencies": {
76
+ "type": "object",
77
+ "description": "npm dependencies (will be installed automatically)",
78
+ "patternProperties": {
79
+ "^[a-z0-9-_@/]+$": {
80
+ "type": "string"
81
+ }
82
+ },
83
+ "additionalProperties": false
84
+ },
85
+ "config": {
86
+ "type": "object",
87
+ "description": "Default configuration values for this plugin"
88
+ },
89
+ "commands": {
90
+ "type": "array",
91
+ "description": "CLI commands registered by this plugin",
92
+ "items": {
93
+ "type": "object",
94
+ "required": ["name", "description"],
95
+ "properties": {
96
+ "name": {
97
+ "type": "string",
98
+ "pattern": "^[a-z0-9-]+$",
99
+ "description": "Command name (kebab-case)"
100
+ },
101
+ "description": {
102
+ "type": "string",
103
+ "description": "Command description shown in help"
104
+ },
105
+ "usage": {
106
+ "type": "string",
107
+ "description": "Usage example"
108
+ }
109
+ },
110
+ "additionalProperties": false
111
+ }
112
+ }
113
+ },
114
+ "additionalProperties": false
115
+ }
@@ -341,7 +341,7 @@ router.get('/servers/:id/tools', async (req, res) => {
341
341
  try {
342
342
  const { id } = req.params;
343
343
  const result = await mcpService.getServerTools(id);
344
- res.json(result);
344
+ res.json({ success: true, ...result });
345
345
  } catch (err) {
346
346
  res.status(404).json({ success: false, error: err.message });
347
347
  }