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.
- package/.cursorrules +149 -0
- package/bin/forge.js +43 -3
- package/lib/agents/README.md +46 -1
- package/lib/agents/cline.plugin.json +11 -4
- package/lib/agents/codex.plugin.json +2 -2
- package/lib/agents/copilot.plugin.json +5 -5
- package/lib/agents/cursor.plugin.json +1 -1
- package/lib/agents/kilocode.plugin.json +1 -1
- package/lib/agents/opencode.plugin.json +7 -4
- package/lib/agents/roo.plugin.json +10 -3
- package/lib/agents-config.js +127 -79
- package/lib/codex-skills.js +50 -0
- package/lib/commands/_issue.js +172 -0
- package/lib/commands/_registry.js +40 -1
- package/lib/commands/claim.js +5 -0
- package/lib/commands/close.js +5 -0
- package/lib/commands/commands-reset.js +147 -0
- package/lib/commands/create.js +5 -0
- package/lib/commands/dev.js +26 -0
- package/lib/commands/issue.js +5 -0
- package/lib/commands/list.js +5 -0
- package/lib/commands/plan.js +18 -0
- package/lib/commands/ready.js +5 -0
- package/lib/commands/setup.js +4295 -0
- package/lib/commands/ship.js +20 -0
- package/lib/commands/show.js +5 -0
- package/lib/commands/status.js +210 -44
- package/lib/commands/sync.js +19 -1
- package/lib/commands/update.js +5 -0
- package/lib/commands/validate.js +13 -0
- package/lib/detect-agent.js +38 -8
- package/lib/detection-utils.js +405 -0
- package/lib/file-utils.js +260 -0
- package/lib/forge-context.js +42 -0
- package/lib/frontmatter.js +79 -0
- package/lib/husky-migration.js +113 -12
- package/lib/lefthook-check.js +27 -6
- package/lib/plugin-manager.js +225 -72
- package/lib/project-discovery.js +39 -5
- package/lib/runtime-health.js +305 -0
- package/lib/shell-utils.js +50 -0
- package/lib/ui-utils.js +43 -0
- package/lib/validation-utils.js +163 -0
- package/lib/workflow/enforce-stage.js +179 -0
- package/lib/workflow/stages.js +201 -0
- package/lib/workflow/state.js +332 -0
- package/opencode.json +67 -0
- package/package.json +15 -5
- package/scripts/beads-context.sh +12 -4
- package/scripts/check-agents.js +103 -0
- package/scripts/lib/eval-runner.js +50 -0
- package/scripts/pr-coordinator.sh +71 -21
- package/scripts/smart-status.sh +21 -11
- package/scripts/sync-commands.js +49 -20
- 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 };
|
package/lib/husky-migration.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
87
|
-
|
|
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
|
|
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 (
|
|
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: ${
|
|
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
|
|
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
|
|
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`(
|
|
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
|
|
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
|
|
514
|
+
} catch {
|
|
414
515
|
// Expected: existing lefthook.yml may be unreadable — will create a new one instead
|
|
415
516
|
}
|
|
416
517
|
}
|
package/lib/lefthook-check.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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 {
|
|
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(
|
|
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 {
|
|
78
|
+
return {
|
|
79
|
+
installed: true,
|
|
80
|
+
binaryAvailable: true,
|
|
81
|
+
state: 'installed',
|
|
82
|
+
message: ''
|
|
83
|
+
};
|
|
63
84
|
}
|
|
64
85
|
|
|
65
86
|
module.exports = { checkLefthookStatus };
|
package/lib/plugin-manager.js
CHANGED
|
@@ -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(
|
|
40
|
-
throw new Error(`Plugin with ID "${
|
|
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(
|
|
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
|
|
273
|
+
const errors = collectPluginValidationErrors(plugin);
|
|
58
274
|
|
|
59
|
-
|
|
60
|
-
|
|
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;
|