forge-workflow 0.0.5 → 0.0.7
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/.claude/commands/dev.md +6 -1
- package/.claude/commands/plan.md +59 -14
- package/.claude/commands/premerge.md +10 -0
- package/.claude/commands/review.md +7 -1
- package/.claude/commands/ship.md +95 -47
- package/.claude/commands/status.md +42 -0
- package/.claude/commands/validate.md +7 -1
- package/.claude/commands/verify.md +52 -4
- package/.claude/rules/workflow.md +16 -0
- package/.claude/scripts/greptile-resolve.sh +32 -0
- package/.cline/workflows/dev.md +6 -1
- package/.cline/workflows/plan.md +59 -14
- package/.cline/workflows/premerge.md +10 -0
- package/.cline/workflows/review.md +7 -1
- package/.cline/workflows/ship.md +95 -47
- package/.cline/workflows/status.md +42 -0
- package/.cline/workflows/validate.md +7 -1
- package/.cline/workflows/verify.md +52 -4
- package/.codex/skills/dev/SKILL.md +6 -1
- package/.codex/skills/plan/SKILL.md +59 -14
- package/.codex/skills/premerge/SKILL.md +10 -0
- package/.codex/skills/review/SKILL.md +7 -1
- package/.codex/skills/ship/SKILL.md +95 -47
- package/.codex/skills/status/SKILL.md +42 -0
- package/.codex/skills/validate/SKILL.md +7 -1
- package/.codex/skills/verify/SKILL.md +52 -4
- package/.cursor/commands/dev.md +6 -1
- package/.cursor/commands/plan.md +59 -14
- package/.cursor/commands/premerge.md +10 -0
- package/.cursor/commands/review.md +7 -1
- package/.cursor/commands/ship.md +95 -47
- package/.cursor/commands/status.md +42 -0
- package/.cursor/commands/validate.md +7 -1
- package/.cursor/commands/verify.md +52 -4
- package/.cursorrules +149 -0
- package/.github/prompts/dev.prompt.md +6 -1
- package/.github/prompts/plan.prompt.md +59 -14
- package/.github/prompts/premerge.prompt.md +10 -0
- package/.github/prompts/review.prompt.md +7 -1
- package/.github/prompts/ship.prompt.md +95 -47
- package/.github/prompts/status.prompt.md +42 -0
- package/.github/prompts/validate.prompt.md +7 -1
- package/.github/prompts/verify.prompt.md +52 -4
- package/.kilocode/workflows/dev.md +6 -1
- package/.kilocode/workflows/plan.md +59 -14
- package/.kilocode/workflows/premerge.md +10 -0
- package/.kilocode/workflows/review.md +7 -1
- package/.kilocode/workflows/ship.md +95 -47
- package/.kilocode/workflows/status.md +42 -0
- package/.kilocode/workflows/validate.md +7 -1
- package/.kilocode/workflows/verify.md +52 -4
- package/.opencode/commands/dev.md +6 -1
- package/.opencode/commands/plan.md +59 -14
- package/.opencode/commands/premerge.md +10 -0
- package/.opencode/commands/review.md +7 -1
- package/.opencode/commands/ship.md +95 -47
- package/.opencode/commands/status.md +42 -0
- package/.opencode/commands/validate.md +7 -1
- package/.opencode/commands/verify.md +52 -4
- package/.roo/commands/dev.md +6 -1
- package/.roo/commands/plan.md +59 -14
- package/.roo/commands/premerge.md +10 -0
- package/.roo/commands/review.md +7 -1
- package/.roo/commands/ship.md +95 -47
- package/.roo/commands/status.md +42 -0
- package/.roo/commands/validate.md +7 -1
- package/.roo/commands/verify.md +52 -4
- package/AGENTS.md +97 -0
- package/CLAUDE.md +10 -0
- package/README.md +2 -2
- package/bin/forge-cmd.js +5 -1
- package/bin/forge-preflight.js +15 -2
- package/bin/forge.js +211 -9
- package/docs/ENHANCED_ONBOARDING.md +96 -86
- package/docs/ROADMAP.md +2 -2
- package/docs/TOOLCHAIN.md +23 -0
- package/docs/VALIDATION.md +1 -1
- package/lefthook.yml +11 -0
- 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 +129 -81
- package/lib/codex-skills.js +50 -0
- package/lib/commands/_registry.js +173 -0
- package/lib/commands/clean.js +181 -0
- package/lib/commands/commands-reset.js +147 -0
- package/lib/commands/dev.js +84 -0
- package/lib/commands/plan.js +18 -0
- package/lib/commands/push.js +196 -0
- package/lib/commands/recommend.js +1 -1
- package/lib/commands/setup.js +4295 -0
- package/lib/commands/ship.js +20 -0
- package/lib/commands/status.js +210 -44
- package/lib/commands/sync.js +71 -0
- package/lib/commands/team.js +37 -0
- package/lib/commands/test.js +207 -0
- package/lib/commands/validate.js +13 -0
- package/lib/commands/worktree.js +310 -0
- package/lib/detect-agent.js +38 -8
- package/lib/detection-utils.js +405 -0
- package/lib/docs-command.js +51 -0
- package/lib/docs-copy.js +50 -0
- package/lib/file-utils.js +260 -0
- package/lib/forge-context.js +42 -0
- package/lib/freshness-token.js +148 -0
- package/lib/frontmatter.js +79 -0
- package/lib/greptile-match.js +80 -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/reset.js +309 -0
- package/lib/runtime-health.js +305 -0
- package/lib/shell-utils.js +50 -0
- package/lib/task-ownership.js +117 -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 +16 -6
- package/scripts/beads-context.sh +165 -22
- package/scripts/beads-context.test.js +5 -1
- package/scripts/check-agents.js +103 -0
- package/scripts/check-forge-token.js +98 -0
- package/scripts/conflict-detect.sh +2 -2
- package/scripts/dep-guard.sh +6 -28
- package/scripts/file-index.sh +117 -23
- package/scripts/forge-team/index.sh +86 -0
- package/scripts/forge-team/lib/agent-prompt.sh +52 -0
- package/scripts/forge-team/lib/claim.sh +256 -0
- package/scripts/forge-team/lib/dashboard.sh +341 -0
- package/scripts/forge-team/lib/epic.sh +332 -0
- package/scripts/forge-team/lib/hooks.sh +253 -0
- package/scripts/forge-team/lib/identity.sh +235 -0
- package/scripts/forge-team/lib/sync-github.sh +317 -0
- package/scripts/forge-team/lib/verify.sh +284 -0
- package/scripts/forge-team/lib/workload.sh +296 -0
- package/scripts/forge-team/tests/agent-prompt.test.sh +72 -0
- package/scripts/forge-team/tests/claim.test.sh +179 -0
- package/scripts/forge-team/tests/dashboard.test.sh +170 -0
- package/scripts/forge-team/tests/dispatcher.test.sh +79 -0
- package/scripts/forge-team/tests/epic.test.sh +176 -0
- package/scripts/forge-team/tests/hooks.test.sh +239 -0
- package/scripts/forge-team/tests/identity.test.sh +176 -0
- package/scripts/forge-team/tests/integration.test.sh +371 -0
- package/scripts/forge-team/tests/sync-github.test.sh +209 -0
- package/scripts/forge-team/tests/verify.test.sh +314 -0
- package/scripts/forge-team/tests/workflow-integration.test.sh +43 -0
- package/scripts/forge-team/tests/workload.test.sh +209 -0
- package/scripts/lib/eval-runner.js +39 -0
- package/scripts/lib/jsonl-lock.sh +48 -0
- package/scripts/lib/sanitize.sh +116 -0
- package/scripts/pr-coordinator.sh +756 -0
- package/scripts/smart-status.sh +58 -21
- package/scripts/sync-commands.js +49 -20
- package/scripts/sync-utils.sh +24 -29
- package/scripts/test.js +18 -1
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;
|
package/lib/project-discovery.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
const fs = require('node:fs');
|
|
8
8
|
const path = require('node:path');
|
|
9
9
|
const { execFileSync } = require('node:child_process');
|
|
10
|
+
const { normalizeAgentId } = require('./detect-agent');
|
|
10
11
|
|
|
11
12
|
async function detectFramework(projectPath) {
|
|
12
13
|
try {
|
|
@@ -283,12 +284,45 @@ async function detectInstalledAgents(projectPath) {
|
|
|
283
284
|
]
|
|
284
285
|
},
|
|
285
286
|
{
|
|
286
|
-
name: '
|
|
287
|
+
name: 'cline',
|
|
287
288
|
checks: [
|
|
288
|
-
|
|
289
|
+
async () => fs.existsSync(path.join(projectPath, '.clinerules')),
|
|
289
290
|
async () => {
|
|
290
|
-
const
|
|
291
|
-
return fs.existsSync(
|
|
291
|
+
const clineDir = path.join(projectPath, '.cline');
|
|
292
|
+
return fs.existsSync(clineDir) && (await fs.promises.stat(clineDir)).isDirectory();
|
|
293
|
+
}
|
|
294
|
+
]
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
name: 'kilocode',
|
|
298
|
+
checks: [
|
|
299
|
+
async () => {
|
|
300
|
+
const kilocodeDir = path.join(projectPath, '.kilocode');
|
|
301
|
+
return fs.existsSync(kilocodeDir) && (await fs.promises.stat(kilocodeDir)).isDirectory();
|
|
302
|
+
},
|
|
303
|
+
async () => fs.existsSync(path.join(projectPath, '.kilocode', 'workflows', 'forge-workflow.md')),
|
|
304
|
+
async () => fs.existsSync(path.join(projectPath, '.kilocode', 'rules', 'workflow.md')),
|
|
305
|
+
async () => fs.existsSync(path.join(projectPath, '.kilocode', 'skills', 'forge-workflow', 'SKILL.md'))
|
|
306
|
+
]
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
name: 'roo',
|
|
310
|
+
checks: [
|
|
311
|
+
async () => fs.existsSync(path.join(projectPath, '.roorules')),
|
|
312
|
+
async () => {
|
|
313
|
+
const rooDir = path.join(projectPath, '.roo');
|
|
314
|
+
return fs.existsSync(rooDir) && (await fs.promises.stat(rooDir)).isDirectory();
|
|
315
|
+
},
|
|
316
|
+
async () => fs.existsSync(path.join(projectPath, '.roo', 'rules'))
|
|
317
|
+
]
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
name: 'codex',
|
|
321
|
+
checks: [
|
|
322
|
+
async () => fs.existsSync(path.join(projectPath, 'codex.md')),
|
|
323
|
+
async () => {
|
|
324
|
+
const codexDir = path.join(projectPath, '.codex');
|
|
325
|
+
return fs.existsSync(codexDir) && (await fs.promises.stat(codexDir)).isDirectory();
|
|
292
326
|
}
|
|
293
327
|
]
|
|
294
328
|
},
|
|
@@ -311,7 +345,7 @@ async function detectInstalledAgents(projectPath) {
|
|
|
311
345
|
try {
|
|
312
346
|
const detected = await check();
|
|
313
347
|
if (detected) {
|
|
314
|
-
detectedAgents.push(detector.name);
|
|
348
|
+
detectedAgents.push(normalizeAgentId(detector.name));
|
|
315
349
|
break; // Agent detected, no need to check other patterns
|
|
316
350
|
}
|
|
317
351
|
} catch (_error) {
|