forge-workflow 0.0.6 → 0.0.8

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.
Files changed (55) hide show
  1. package/.cursorrules +149 -0
  2. package/bin/forge.js +43 -3
  3. package/lib/agents/README.md +46 -1
  4. package/lib/agents/cline.plugin.json +11 -4
  5. package/lib/agents/codex.plugin.json +2 -2
  6. package/lib/agents/copilot.plugin.json +5 -5
  7. package/lib/agents/cursor.plugin.json +1 -1
  8. package/lib/agents/kilocode.plugin.json +1 -1
  9. package/lib/agents/opencode.plugin.json +7 -4
  10. package/lib/agents/roo.plugin.json +10 -3
  11. package/lib/agents-config.js +127 -79
  12. package/lib/codex-skills.js +50 -0
  13. package/lib/commands/_issue.js +172 -0
  14. package/lib/commands/_registry.js +40 -1
  15. package/lib/commands/claim.js +5 -0
  16. package/lib/commands/close.js +5 -0
  17. package/lib/commands/commands-reset.js +147 -0
  18. package/lib/commands/create.js +5 -0
  19. package/lib/commands/dev.js +26 -0
  20. package/lib/commands/issue.js +5 -0
  21. package/lib/commands/list.js +5 -0
  22. package/lib/commands/plan.js +18 -0
  23. package/lib/commands/ready.js +5 -0
  24. package/lib/commands/setup.js +4295 -0
  25. package/lib/commands/ship.js +20 -0
  26. package/lib/commands/show.js +5 -0
  27. package/lib/commands/status.js +210 -44
  28. package/lib/commands/sync.js +19 -1
  29. package/lib/commands/update.js +5 -0
  30. package/lib/commands/validate.js +13 -0
  31. package/lib/detect-agent.js +38 -8
  32. package/lib/detection-utils.js +405 -0
  33. package/lib/file-utils.js +260 -0
  34. package/lib/forge-context.js +42 -0
  35. package/lib/frontmatter.js +79 -0
  36. package/lib/husky-migration.js +113 -12
  37. package/lib/lefthook-check.js +27 -6
  38. package/lib/plugin-manager.js +225 -72
  39. package/lib/project-discovery.js +39 -5
  40. package/lib/runtime-health.js +305 -0
  41. package/lib/shell-utils.js +50 -0
  42. package/lib/ui-utils.js +43 -0
  43. package/lib/validation-utils.js +163 -0
  44. package/lib/workflow/enforce-stage.js +179 -0
  45. package/lib/workflow/stages.js +201 -0
  46. package/lib/workflow/state.js +332 -0
  47. package/opencode.json +67 -0
  48. package/package.json +15 -5
  49. package/scripts/beads-context.sh +12 -4
  50. package/scripts/check-agents.js +103 -0
  51. package/scripts/lib/eval-runner.js +50 -0
  52. package/scripts/pr-coordinator.sh +71 -21
  53. package/scripts/smart-status.sh +21 -11
  54. package/scripts/sync-commands.js +49 -20
  55. package/scripts/test.js +16 -1
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @module frontmatter
5
+ *
6
+ * Utility for parsing and manipulating YAML frontmatter using gray-matter.
7
+ *
8
+ * This is the canonical frontmatter library for runtime code that needs
9
+ * full gray-matter capabilities (e.g., commands-reset, plugin transforms).
10
+ *
11
+ * Note: scripts/sync-commands.js uses its own hand-rolled YAML parser
12
+ * (via the `yaml` package) because sync runs at build time and must not
13
+ * depend on gray-matter being installed in the target project.
14
+ *
15
+ * @example
16
+ * const { parse, stringify, stripAll, keepOnly } = require('./frontmatter');
17
+ * const { data, content } = parse(raw);
18
+ * const rebuilt = stringify(data, content);
19
+ */
20
+
21
+ const matter = require('gray-matter');
22
+
23
+ /**
24
+ * Parse YAML frontmatter from a markdown string.
25
+ *
26
+ * @param {string} content - Raw file content with optional frontmatter
27
+ * @returns {{ data: Record<string, unknown>, content: string }}
28
+ */
29
+ function parse(content) {
30
+ const result = matter(content);
31
+ return { data: result.data, content: result.content };
32
+ }
33
+
34
+ /**
35
+ * Build a file string from frontmatter data and body content.
36
+ *
37
+ * @param {Record<string, unknown>} data - Key-value pairs for the YAML block
38
+ * @param {string} content - The markdown body content
39
+ * @returns {string}
40
+ */
41
+ function stringify(data, content) {
42
+ return matter.stringify(content, data);
43
+ }
44
+
45
+ /**
46
+ * Strip all frontmatter, returning only the body content.
47
+ *
48
+ * @param {string} content - Raw file content with optional frontmatter
49
+ * @returns {string} Body content without frontmatter
50
+ */
51
+ function stripAll(content) {
52
+ const result = matter(content);
53
+ return result.content;
54
+ }
55
+
56
+ /**
57
+ * Keep only specified fields in frontmatter, strip the rest.
58
+ *
59
+ * If none of the specified fields exist, returns body only (no frontmatter).
60
+ *
61
+ * @param {string} content - Raw file content with optional frontmatter
62
+ * @param {string[]} fields - Field names to keep
63
+ * @returns {string} Rebuilt file with filtered frontmatter
64
+ */
65
+ function keepOnly(content, fields) {
66
+ const result = matter(content);
67
+ const filtered = {};
68
+ for (const field of fields) {
69
+ if (Object.hasOwn(result.data, field)) {
70
+ filtered[field] = result.data[field];
71
+ }
72
+ }
73
+ if (Object.keys(filtered).length === 0) {
74
+ return result.content;
75
+ }
76
+ return matter.stringify(result.content, filtered);
77
+ }
78
+
79
+ module.exports = { parse, stringify, stripAll, keepOnly };
@@ -11,6 +11,65 @@ const fs = require('node:fs');
11
11
  const path = require('node:path');
12
12
  const { execFileSync, spawnSync } = require('node:child_process');
13
13
 
14
+ function readGitDirPointer(projectRoot) {
15
+ const gitPath = path.join(projectRoot, '.git');
16
+ if (!fs.existsSync(gitPath)) {
17
+ return null;
18
+ }
19
+
20
+ try {
21
+ const gitStat = fs.statSync(gitPath);
22
+ if (gitStat.isDirectory()) {
23
+ return gitPath;
24
+ }
25
+ if (!gitStat.isFile()) {
26
+ return null;
27
+ }
28
+
29
+ const pointer = fs.readFileSync(gitPath, 'utf8').trim();
30
+ if (!pointer.startsWith('gitdir:')) {
31
+ return null;
32
+ }
33
+
34
+ return path.resolve(projectRoot, pointer.replace(/^gitdir:\s*/, ''));
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ function hasHooksPathConfigured(projectRoot) {
41
+ const gitDir = readGitDirPointer(projectRoot);
42
+ if (!gitDir) {
43
+ return false;
44
+ }
45
+
46
+ const configCandidates = [path.join(gitDir, 'config')];
47
+ const commonDirPath = path.join(gitDir, 'commondir');
48
+ if (fs.existsSync(commonDirPath)) {
49
+ try {
50
+ const commonDir = fs.readFileSync(commonDirPath, 'utf8').trim();
51
+ if (commonDir) {
52
+ configCandidates.push(path.resolve(gitDir, commonDir, 'config'));
53
+ }
54
+ } catch {
55
+ // Ignore unreadable commondir files and fall back to direct config candidates.
56
+ }
57
+ }
58
+
59
+ return configCandidates.some((configPath) => {
60
+ if (!fs.existsSync(configPath)) {
61
+ return false;
62
+ }
63
+
64
+ try {
65
+ const gitConfig = fs.readFileSync(configPath, 'utf8');
66
+ return /hooksPath\s*=/.test(gitConfig);
67
+ } catch {
68
+ return false;
69
+ }
70
+ });
71
+ }
72
+
14
73
  /**
15
74
  * Resolve a command to its full path to avoid relying on inherited PATH.
16
75
  * Falls back to the command name if resolution fails.
@@ -24,7 +83,7 @@ function resolveCommand(command) {
24
83
  if (result.status === 0 && result.stdout) {
25
84
  return result.stdout.trim().split(/\r?\n/)[0].trim();
26
85
  }
27
- } catch (_e) {
86
+ } catch {
28
87
  // Expected: command resolution may fail if 'which'/'where.exe' is unavailable — fall back to bare command name
29
88
  }
30
89
  return command;
@@ -67,13 +126,43 @@ const KNOWN_PATTERNS = [
67
126
  */
68
127
  const HUSKY_INTERNAL = new Set(['_', '.gitignore', 'husky.sh']);
69
128
 
129
+ function resolveGitDir(projectRoot) {
130
+ const gitPath = path.join(projectRoot, '.git');
131
+ if (!fs.existsSync(gitPath)) {
132
+ return null;
133
+ }
134
+
135
+ try {
136
+ const stat = fs.statSync(gitPath);
137
+ if (stat.isDirectory()) {
138
+ return gitPath;
139
+ }
140
+
141
+ if (stat.isFile()) {
142
+ const pointer = fs.readFileSync(gitPath, 'utf8').trim();
143
+ const separatorIndex = pointer.indexOf(':');
144
+ const label = separatorIndex === -1 ? pointer : pointer.slice(0, separatorIndex).trim().toLowerCase();
145
+ if (label === 'gitdir') {
146
+ const gitDir = pointer.slice(separatorIndex + 1).trim();
147
+ if (gitDir) {
148
+ return path.resolve(projectRoot, gitDir);
149
+ }
150
+ }
151
+ }
152
+ } catch {
153
+ return null;
154
+ }
155
+
156
+ return null;
157
+ }
158
+
70
159
  /**
71
160
  * Detect whether Husky is installed in a project.
72
161
  *
73
162
  * @param {string} projectRoot - Absolute path to the project root.
74
163
  * @returns {{ found: boolean, huskyDir: string|null, hasHooksPath: boolean }}
75
164
  */
76
- function detectHusky(projectRoot) {
165
+ function _detectHuskyLegacy(projectRoot) {
77
166
  const huskyDir = path.join(projectRoot, '.husky');
78
167
  const found = fs.existsSync(huskyDir) && fs.statSync(huskyDir).isDirectory();
79
168
 
@@ -83,12 +172,13 @@ function detectHusky(projectRoot) {
83
172
 
84
173
  // Check for core.hooksPath in .git/config
85
174
  let hasHooksPath = false;
86
- const gitConfigPath = path.join(projectRoot, '.git', 'config');
87
- if (fs.existsSync(gitConfigPath)) {
175
+ const gitDir = resolveGitDir(projectRoot);
176
+ const gitConfigPath = gitDir ? path.join(gitDir, 'config') : null;
177
+ if (gitConfigPath && fs.existsSync(gitConfigPath)) {
88
178
  try {
89
179
  const gitConfig = fs.readFileSync(gitConfigPath, 'utf8');
90
180
  hasHooksPath = /hooksPath\s*=/.test(gitConfig);
91
- } catch (_err) {
181
+ } catch {
92
182
  // Expected: .git/config may be unreadable or malformed — assume no hooksPath is configured
93
183
  }
94
184
  }
@@ -96,6 +186,17 @@ function detectHusky(projectRoot) {
96
186
  return { found: true, huskyDir, hasHooksPath };
97
187
  }
98
188
 
189
+ function detectHusky(projectRoot) {
190
+ const huskyDir = path.join(projectRoot, '.husky');
191
+ const found = fs.existsSync(huskyDir) && fs.statSync(huskyDir).isDirectory();
192
+
193
+ if (!found) {
194
+ return { found: false, huskyDir: null, hasHooksPath: false };
195
+ }
196
+
197
+ return { found: true, huskyDir, hasHooksPath: hasHooksPathConfigured(projectRoot) };
198
+ }
199
+
99
200
  /**
100
201
  * Strip Husky boilerplate (shebang, sourcing _/husky.sh, blank lines) from hook content.
101
202
  *
@@ -167,9 +268,9 @@ function parseHookFile(huskyDir, hookName, mapped, unmapped) {
167
268
  let content;
168
269
  try {
169
270
  content = fs.readFileSync(filePath, 'utf8');
170
- } catch (_err) {
271
+ } catch (err) {
171
272
  // Expected: hook file may be unreadable due to permissions — report as unmapped
172
- unmapped.push({ hook: hookName, reason: `Cannot read file: ${_err.message}` });
273
+ unmapped.push({ hook: hookName, reason: `Cannot read file: ${err.message}` });
173
274
  return;
174
275
  }
175
276
 
@@ -211,7 +312,7 @@ function mapHuskyHooks(huskyDir) {
211
312
  let entries;
212
313
  try {
213
314
  entries = fs.readdirSync(huskyDir, { withFileTypes: true });
214
- } catch (_err) {
315
+ } catch {
215
316
  // Expected: .husky/ directory may be unreadable — return empty results
216
317
  return { mapped, unmapped };
217
318
  }
@@ -254,7 +355,7 @@ function validateNoSymlinks(huskyDir) {
254
355
  if (stat.isSymbolicLink()) {
255
356
  symlinkFiles.push(entry.name);
256
357
  }
257
- } catch (_err) {
358
+ } catch {
258
359
  // Expected: file may have been removed between readdir and lstat — skip unstattable entries
259
360
  }
260
361
  }
@@ -285,7 +386,7 @@ function formatHookCommands(hooks) {
285
386
  * @returns {string} Updated YAML content with hooks merged in.
286
387
  */
287
388
  function mergeHookTypeIntoYaml(yaml, hookType, hooks) {
288
- const commandsRegex = new RegExp(String.raw`(` + hookType + String.raw`:\s*\n(?:.*\n)*?\s+commands:\s*\n)`, 'm');
389
+ const commandsRegex = new RegExp(String.raw`(${hookType}:\s*\n(?:.*\n)*?\s+commands:\s*\n)`, 'm');
289
390
  const commandsMatch = commandsRegex.exec(yaml);
290
391
  if (commandsMatch) {
291
392
  const insertion = formatHookCommands(hooks);
@@ -344,7 +445,7 @@ function unsetHooksPath(projectRoot) {
344
445
  stdio: 'ignore',
345
446
  });
346
447
  return true;
347
- } catch (_err) {
448
+ } catch {
348
449
  // Expected: git config --unset fails when core.hooksPath is not set — safe to ignore
349
450
  return false;
350
451
  }
@@ -410,7 +511,7 @@ function migrateHusky(projectRoot, options = {}) {
410
511
  if (fs.existsSync(lefthookPath)) {
411
512
  try {
412
513
  existingContent = fs.readFileSync(lefthookPath, 'utf8');
413
- } catch (_err) {
514
+ } catch {
414
515
  // Expected: existing lefthook.yml may be unreadable — will create a new one instead
415
516
  }
416
517
  }
@@ -11,17 +11,26 @@ const path = require('node:path');
11
11
  * is actually available in node_modules/.bin.
12
12
  *
13
13
  * @param {string} projectRoot - Absolute path to the project root directory.
14
- * @returns {{ installed: boolean, binaryAvailable: boolean, message: string }}
14
+ * @returns {{ installed: boolean, binaryAvailable: boolean, state: string, message: string }}
15
15
  * - installed: true if lefthook appears in dependencies or devDependencies
16
16
  * - binaryAvailable: true if the lefthook binary exists in node_modules/.bin
17
+ * - state: explicit installation state for runtime health checks
17
18
  * - message: actionable guidance when something is missing, empty string when OK
18
19
  */
19
20
  function checkLefthookStatus(projectRoot) {
20
- const pkgPath = path.join(projectRoot, 'package.json');
21
+ const root = typeof projectRoot === 'string' && projectRoot.trim()
22
+ ? projectRoot
23
+ : process.cwd();
24
+ const pkgPath = path.join(root, 'package.json');
21
25
 
22
26
  // No package.json — nothing to report
23
27
  if (!fs.existsSync(pkgPath)) {
24
- return { installed: false, binaryAvailable: false, message: '' };
28
+ return {
29
+ installed: false,
30
+ binaryAvailable: false,
31
+ state: 'missing-package',
32
+ message: ''
33
+ };
25
34
  }
26
35
 
27
36
  let pkg;
@@ -29,7 +38,12 @@ function checkLefthookStatus(projectRoot) {
29
38
  pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
30
39
  } catch (_err) {
31
40
  // Expected: package.json may contain invalid JSON — treat as absent
32
- return { installed: false, binaryAvailable: false, message: '' };
41
+ return {
42
+ installed: false,
43
+ binaryAvailable: false,
44
+ state: 'missing-package',
45
+ message: ''
46
+ };
33
47
  }
34
48
 
35
49
  const installed = Boolean(
@@ -40,12 +54,13 @@ function checkLefthookStatus(projectRoot) {
40
54
  return {
41
55
  installed: false,
42
56
  binaryAvailable: false,
57
+ state: 'missing-dependency',
43
58
  message: 'lefthook not found. Run: bun add -D lefthook && bun install',
44
59
  };
45
60
  }
46
61
 
47
62
  // Check for the binary in node_modules/.bin
48
- const binDir = path.join(projectRoot, 'node_modules', '.bin');
63
+ const binDir = path.join(root, 'node_modules', '.bin');
49
64
  const binaryAvailable =
50
65
  fs.existsSync(path.join(binDir, 'lefthook')) ||
51
66
  fs.existsSync(path.join(binDir, 'lefthook.cmd'));
@@ -54,12 +69,18 @@ function checkLefthookStatus(projectRoot) {
54
69
  return {
55
70
  installed: true,
56
71
  binaryAvailable: false,
72
+ state: 'missing-binary',
57
73
  message:
58
74
  'lefthook is in package.json but not installed. Run: bun install',
59
75
  };
60
76
  }
61
77
 
62
- return { installed: true, binaryAvailable: true, message: '' };
78
+ return {
79
+ installed: true,
80
+ binaryAvailable: true,
81
+ state: 'installed',
82
+ message: ''
83
+ };
63
84
  }
64
85
 
65
86
  module.exports = { checkLefthookStatus };
@@ -8,6 +8,221 @@
8
8
  const fs = require('node:fs');
9
9
  const path = require('node:path');
10
10
 
11
+ const SUPPORT_STATUSES = Object.freeze([
12
+ 'first-class',
13
+ 'supported',
14
+ 'compatibility',
15
+ 'deprecated',
16
+ 'unsupported',
17
+ ]);
18
+
19
+ const NATIVE_SURFACES = Object.freeze([
20
+ 'cli-first',
21
+ 'editor-native',
22
+ 'desktop-app',
23
+ 'web-app',
24
+ 'terminal-native',
25
+ 'hybrid',
26
+ ]);
27
+
28
+ const DEFAULT_SUPPORT_STATUS = 'supported';
29
+
30
+ const SURFACE_BY_AGENT_ID = Object.freeze({
31
+ claude: 'cli-first',
32
+ codex: 'cli-first',
33
+ opencode: 'cli-first',
34
+ cline: 'editor-native',
35
+ cursor: 'editor-native',
36
+ copilot: 'editor-native',
37
+ kilocode: 'editor-native',
38
+ roo: 'editor-native',
39
+ });
40
+
41
+ function isPlainObject(value) {
42
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
43
+ }
44
+
45
+ function validateCapabilityFlags(capabilities, errors) {
46
+ const booleanFields = ['commands', 'rules', 'skills', 'mcp', 'contextMode'];
47
+
48
+ booleanFields.forEach((field) => {
49
+ if (capabilities[field] !== undefined && typeof capabilities[field] !== 'boolean') {
50
+ errors.push(`"capabilities.${field}" must be a boolean`);
51
+ }
52
+ });
53
+
54
+ if (capabilities.hooks !== undefined) {
55
+ if (typeof capabilities.hooks === 'boolean') {
56
+ return;
57
+ }
58
+
59
+ if (!isPlainObject(capabilities.hooks)) {
60
+ errors.push('"capabilities.hooks" must be a boolean or an object');
61
+ return;
62
+ }
63
+
64
+ if (
65
+ capabilities.hooks.blocking !== undefined &&
66
+ typeof capabilities.hooks.blocking !== 'boolean'
67
+ ) {
68
+ errors.push('"capabilities.hooks.blocking" must be a boolean');
69
+ }
70
+ }
71
+ }
72
+
73
+ function validateSupportMetadata(support, errors) {
74
+ if (support === undefined) {
75
+ return;
76
+ }
77
+
78
+ if (!isPlainObject(support)) {
79
+ errors.push('"support" must be an object');
80
+ return;
81
+ }
82
+
83
+ if (support.status !== undefined) {
84
+ if (
85
+ typeof support.status !== 'string' ||
86
+ !SUPPORT_STATUSES.includes(support.status)
87
+ ) {
88
+ errors.push(
89
+ `"support.status" must be one of: ${SUPPORT_STATUSES.join(', ')}`
90
+ );
91
+ }
92
+ }
93
+
94
+ if (support.surface !== undefined) {
95
+ if (
96
+ typeof support.surface !== 'string' ||
97
+ !NATIVE_SURFACES.includes(support.surface)
98
+ ) {
99
+ errors.push(
100
+ `"support.surface" must be one of: ${NATIVE_SURFACES.join(', ')}`
101
+ );
102
+ }
103
+ }
104
+
105
+ if (support.install !== undefined) {
106
+ if (!isPlainObject(support.install)) {
107
+ errors.push('"support.install" must be an object');
108
+ } else {
109
+ if (
110
+ support.install.required !== undefined &&
111
+ typeof support.install.required !== 'boolean'
112
+ ) {
113
+ errors.push('"support.install.required" must be a boolean');
114
+ }
115
+
116
+ if (
117
+ support.install.repairRequired !== undefined &&
118
+ typeof support.install.repairRequired !== 'boolean'
119
+ ) {
120
+ errors.push('"support.install.repairRequired" must be a boolean');
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ function collectPluginValidationErrors(plugin) {
127
+ const errors = [];
128
+
129
+ if (!plugin || typeof plugin !== 'object' || Array.isArray(plugin)) {
130
+ return ['Plugin must be a non-null object'];
131
+ }
132
+
133
+ const required = ['id', 'name', 'version', 'directories'];
134
+
135
+ required.forEach((field) => {
136
+ if (!plugin[field]) {
137
+ errors.push(`Missing required field "${field}"`);
138
+ }
139
+ });
140
+
141
+ if (plugin.id !== undefined && typeof plugin.id !== 'string') {
142
+ errors.push('"id" must be a string');
143
+ }
144
+ if (plugin.name !== undefined && typeof plugin.name !== 'string') {
145
+ errors.push('"name" must be a string');
146
+ }
147
+ if (plugin.version !== undefined && typeof plugin.version !== 'string') {
148
+ errors.push('"version" must be a string');
149
+ }
150
+ if (plugin.directories !== undefined) {
151
+ if (!isPlainObject(plugin.directories)) {
152
+ errors.push('"directories" must be an object');
153
+ } else if (Object.keys(plugin.directories).length === 0) {
154
+ errors.push('"directories" must not be empty');
155
+ }
156
+ }
157
+
158
+ if (plugin.description !== undefined && typeof plugin.description !== 'string') {
159
+ errors.push('"description" must be a string');
160
+ }
161
+ if (plugin.homepage !== undefined && typeof plugin.homepage !== 'string') {
162
+ errors.push('"homepage" must be a string');
163
+ }
164
+
165
+ if (plugin.capabilities !== undefined) {
166
+ if (!isPlainObject(plugin.capabilities)) {
167
+ errors.push('"capabilities" must be an object');
168
+ } else {
169
+ validateCapabilityFlags(plugin.capabilities, errors);
170
+ }
171
+ }
172
+
173
+ validateSupportMetadata(plugin.support, errors);
174
+
175
+ return errors;
176
+ }
177
+
178
+ function inferNativeSurface(plugin) {
179
+ if (plugin.support && typeof plugin.support.surface === 'string') {
180
+ return plugin.support.surface;
181
+ }
182
+
183
+ if (plugin.id && SURFACE_BY_AGENT_ID[plugin.id]) {
184
+ return SURFACE_BY_AGENT_ID[plugin.id];
185
+ }
186
+
187
+ if (plugin.directories && (plugin.directories.instructions || plugin.directories.prompts)) {
188
+ return 'editor-native';
189
+ }
190
+
191
+ return 'cli-first';
192
+ }
193
+
194
+ function normalizePluginMetadata(plugin) {
195
+ const validationErrors = collectPluginValidationErrors(plugin);
196
+ if (validationErrors.length > 0) {
197
+ throw new Error(`Invalid plugin schema: ${validationErrors.join('; ')}`);
198
+ }
199
+
200
+ const capabilities = isPlainObject(plugin.capabilities) ? plugin.capabilities : {};
201
+ const support = isPlainObject(plugin.support) ? plugin.support : {};
202
+ const hooks = capabilities.hooks;
203
+ const blockingHooks = isPlainObject(hooks) ? hooks.blocking : hooks;
204
+
205
+ return {
206
+ ...plugin,
207
+ normalizedCapabilities: {
208
+ nativeSurface: inferNativeSurface(plugin),
209
+ supportStatus: support.status || DEFAULT_SUPPORT_STATUS,
210
+ commands: Boolean(capabilities.commands),
211
+ rules: Boolean(capabilities.rules),
212
+ skills: Boolean(capabilities.skills),
213
+ mcp: Boolean(capabilities.mcp),
214
+ contextMode: Boolean(capabilities.contextMode),
215
+ hooks: {
216
+ blocking: Boolean(blockingHooks),
217
+ },
218
+ install: {
219
+ required: Boolean(support.install?.required),
220
+ repairRequired: Boolean(support.install?.repairRequired),
221
+ },
222
+ },
223
+ };
224
+ }
225
+
11
226
  class PluginManager {
12
227
  constructor() {
13
228
  this.plugins = new Map();
@@ -34,13 +249,14 @@ class PluginManager {
34
249
  const content = fs.readFileSync(path.join(pluginDir, file), 'utf-8');
35
250
  const plugin = JSON.parse(content);
36
251
  this.validatePlugin(plugin);
252
+ const normalizedPlugin = normalizePluginMetadata(plugin);
37
253
 
38
254
  // Check for duplicate IDs
39
- if (this.plugins.has(plugin.id)) {
40
- throw new Error(`Plugin with ID "${plugin.id}" already exists`);
255
+ if (this.plugins.has(normalizedPlugin.id)) {
256
+ throw new Error(`Plugin with ID "${normalizedPlugin.id}" already exists`);
41
257
  }
42
258
 
43
- this.plugins.set(plugin.id, plugin);
259
+ this.plugins.set(normalizedPlugin.id, normalizedPlugin);
44
260
  } catch (error) {
45
261
  // Re-throw with file context
46
262
  throw new Error(`Failed to load plugin ${file}: ${error.message}`);
@@ -54,35 +270,10 @@ class PluginManager {
54
270
  * @throws {Error} If validation fails
55
271
  */
56
272
  validatePlugin(plugin) {
57
- const required = ['id', 'name', 'version', 'directories'];
273
+ const errors = collectPluginValidationErrors(plugin);
58
274
 
59
- // Check required fields exist
60
- required.forEach(field => {
61
- if (!plugin[field]) {
62
- throw new Error(`Plugin validation failed: missing required field "${field}"`);
63
- }
64
- });
65
-
66
- // Validate field types
67
- if (typeof plugin.id !== 'string') {
68
- throw new TypeError('Plugin validation failed: "id" must be a string');
69
- }
70
- if (typeof plugin.name !== 'string') {
71
- throw new TypeError('Plugin validation failed: "name" must be a string');
72
- }
73
- if (typeof plugin.version !== 'string') {
74
- throw new TypeError('Plugin validation failed: "version" must be a string');
75
- }
76
- if (typeof plugin.directories !== 'object' || Array.isArray(plugin.directories)) {
77
- throw new TypeError('Plugin validation failed: "directories" must be an object');
78
- }
79
-
80
- // Validate optional fields if present
81
- if (plugin.description && typeof plugin.description !== 'string') {
82
- throw new TypeError('Plugin validation failed: "description" must be a string');
83
- }
84
- if (plugin.homepage && typeof plugin.homepage !== 'string') {
85
- throw new TypeError('Plugin validation failed: "homepage" must be a string');
275
+ if (errors.length > 0) {
276
+ throw new Error(`Plugin validation failed: ${errors[0]}`);
86
277
  }
87
278
  }
88
279
 
@@ -118,49 +309,11 @@ class PluginManager {
118
309
  * @returns {{valid: boolean, errors: Array<string>}}
119
310
  */
120
311
  function validatePluginSchema(plugin) {
121
- const errors = [];
122
-
123
- // Check if plugin is an object
124
- if (!plugin || typeof plugin !== 'object' || Array.isArray(plugin)) {
125
- return { valid: false, errors: ['Plugin must be a non-null object'] };
126
- }
127
-
128
- // Check required fields
129
- const required = ['id', 'name', 'version', 'directories'];
130
- required.forEach(field => {
131
- if (!plugin[field]) {
132
- errors.push(`Missing required field "${field}"`);
133
- }
134
- });
135
-
136
- // Validate field types
137
- if (plugin.id !== undefined && typeof plugin.id !== 'string') {
138
- errors.push('"id" must be a string');
139
- }
140
- if (plugin.name !== undefined && typeof plugin.name !== 'string') {
141
- errors.push('"name" must be a string');
142
- }
143
- if (plugin.version !== undefined && typeof plugin.version !== 'string') {
144
- errors.push('"version" must be a string');
145
- }
146
- if (plugin.directories !== undefined) {
147
- if (typeof plugin.directories !== 'object' || Array.isArray(plugin.directories)) {
148
- errors.push('"directories" must be an object');
149
- } else if (Object.keys(plugin.directories).length === 0) {
150
- errors.push('"directories" must not be empty');
151
- }
152
- }
153
-
154
- // Validate optional fields if present
155
- if (plugin.description !== undefined && typeof plugin.description !== 'string') {
156
- errors.push('"description" must be a string');
157
- }
158
- if (plugin.homepage !== undefined && typeof plugin.homepage !== 'string') {
159
- errors.push('"homepage" must be a string');
160
- }
161
-
312
+ const errors = collectPluginValidationErrors(plugin);
162
313
  return { valid: errors.length === 0, errors };
163
314
  }
164
315
 
165
316
  module.exports = PluginManager;
317
+ module.exports.PluginManager = PluginManager;
166
318
  module.exports.validatePluginSchema = validatePluginSchema;
319
+ module.exports.normalizePluginMetadata = normalizePluginMetadata;