dream-wf 0.1.1 → 0.1.2
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/README.md +91 -24
- package/core/grill-prd-policy.md +2 -0
- package/core/workflow-profile.md +1 -0
- package/package.json +4 -3
- package/src/cli/index.js +192 -65
- package/src/deps/index.js +50 -0
- package/src/lib/catalog.js +82 -0
- package/src/lib/mcp.js +305 -0
- package/src/lib/platforms.js +13 -3
- package/src/lib/trellis.js +22 -2
- package/src/platforms/claude-code/index.js +7 -3
- package/src/platforms/codex/index.js +83 -0
- package/src/platforms/cursor/index.js +7 -3
- package/src/platforms/opencode/index.js +7 -3
- package/src/platforms/shared.js +11 -0
- package/src/tui/index.js +445 -0
- package/templates/hooks/claude-code/__pycache__/dream-wf-guard.cpython-314.pyc +0 -0
- package/templates/hooks/claude-code/dream-wf-guard.py +33 -11
- package/templates/hooks/codex/__pycache__/dream-wf-guard.cpython-314.pyc +0 -0
- package/templates/hooks/codex/dream-wf-guard.py +150 -0
- package/templates/hooks/cursor/__pycache__/dream-wf-guard.cpython-314.pyc +0 -0
- package/templates/hooks/cursor/dream-wf-guard.py +30 -11
- package/templates/hooks/opencode/dream-wf-guard.js +28 -10
- package/templates/rules/claude-code/dream-wf-block.md +2 -0
- package/templates/rules/codex/dream-wf-block.md +43 -0
- package/templates/rules/cursor/dream-wf.mdc +2 -1
- package/templates/rules/opencode/dream-wf-block.md +2 -0
- package/templates/skills/dream-wf-grill-prd/SKILL.md +54 -2
- package/templates/spec/guides/dream-wf-prd-policy.md +14 -0
package/src/deps/index.js
CHANGED
|
@@ -2,6 +2,8 @@ import { spawnSync } from 'node:child_process';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { pathExists, readTextIfExists } from '../lib/files.js';
|
|
4
4
|
import { commandExists } from '../lib/trellis.js';
|
|
5
|
+
import { readMcpServers, mcpConfigExists } from '../lib/mcp.js';
|
|
6
|
+
import { MCP_CATALOG } from '../lib/catalog.js';
|
|
5
7
|
|
|
6
8
|
export async function checkDependencies(rootDir, platform) {
|
|
7
9
|
const checks = [];
|
|
@@ -18,18 +20,31 @@ export async function checkDependencies(rootDir, platform) {
|
|
|
18
20
|
checks.push(await fileCheck(path.join(rootDir, '.cursor', 'rules', 'dream-wf.mdc'), 'Cursor dream-wf always-on rule'));
|
|
19
21
|
checks.push(await fileCheck(path.join(rootDir, '.cursor', 'skills', 'dream-wf-grill-prd', 'SKILL.md'), 'Cursor dream-wf grill PRD skill'));
|
|
20
22
|
checks.push(await fileCheck(path.join(rootDir, '.cursor', 'skills', 'dream-wf-mcp-policy', 'SKILL.md'), 'Cursor dream-wf MCP policy skill'));
|
|
23
|
+
checks.push(await mcpConfigCheck(rootDir, 'cursor'));
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
if (platform === 'claude') {
|
|
24
27
|
checks.push(await contentCheck(path.join(rootDir, 'CLAUDE.md'), '<!-- DREAM-WF:START -->', 'Claude Code dream-wf entry block'));
|
|
25
28
|
checks.push(await fileCheck(path.join(rootDir, '.claude', 'skills', 'dream-wf-grill-prd', 'SKILL.md'), 'Claude Code dream-wf grill PRD skill'));
|
|
26
29
|
checks.push(await fileCheck(path.join(rootDir, '.claude', 'skills', 'dream-wf-mcp-policy', 'SKILL.md'), 'Claude Code dream-wf MCP policy skill'));
|
|
30
|
+
checks.push(await mcpConfigCheck(rootDir, 'claude'));
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
if (platform === 'opencode') {
|
|
30
34
|
checks.push(await contentCheck(path.join(rootDir, 'AGENTS.md'), '<!-- DREAM-WF:START -->', 'OpenCode dream-wf entry block'));
|
|
31
35
|
checks.push(await fileCheck(path.join(rootDir, '.opencode', 'skills', 'dream-wf-grill-prd', 'SKILL.md'), 'OpenCode dream-wf grill PRD skill'));
|
|
32
36
|
checks.push(await fileCheck(path.join(rootDir, '.opencode', 'skills', 'dream-wf-mcp-policy', 'SKILL.md'), 'OpenCode dream-wf MCP policy skill'));
|
|
37
|
+
checks.push(await mcpConfigCheck(rootDir, 'opencode'));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (platform === 'codex') {
|
|
41
|
+
checks.push(await contentCheck(path.join(rootDir, 'AGENTS.md'), '<!-- DREAM-WF:START -->', 'Codex dream-wf entry block'));
|
|
42
|
+
checks.push(await fileCheck(path.join(rootDir, '.codex', 'skills', 'dream-wf-grill-prd', 'SKILL.md'), 'Codex dream-wf grill PRD skill'));
|
|
43
|
+
checks.push(await fileCheck(path.join(rootDir, '.codex', 'skills', 'dream-wf-mcp-policy', 'SKILL.md'), 'Codex dream-wf MCP policy skill'));
|
|
44
|
+
checks.push(await fileCheck(path.join(rootDir, '.codex', 'hooks', 'dream-wf-guard.py'), 'Codex dream-wf guard hook'));
|
|
45
|
+
checks.push(await contentCheck(path.join(rootDir, '.codex', 'hooks.json'), 'dream-wf-guard.py', 'Codex hooks.json registration'));
|
|
46
|
+
checks.push(await contentCheck(path.join(rootDir, '.codex', 'config.toml'), 'hooks = true', 'Codex hooks feature enabled'));
|
|
47
|
+
checks.push(await mcpConfigCheck(rootDir, 'codex'));
|
|
33
48
|
}
|
|
34
49
|
|
|
35
50
|
checks.push(await secretScan(rootDir));
|
|
@@ -37,6 +52,41 @@ export async function checkDependencies(rootDir, platform) {
|
|
|
37
52
|
return checks;
|
|
38
53
|
}
|
|
39
54
|
|
|
55
|
+
// 检查 MCP 配置文件存在性以及是否包含 catalog 里的默认 MCP 条目。
|
|
56
|
+
async function mcpConfigCheck(rootDir, platform) {
|
|
57
|
+
const configPaths = {
|
|
58
|
+
cursor: '.cursor/mcp.json',
|
|
59
|
+
claude: '.mcp.json',
|
|
60
|
+
opencode: 'opencode.json',
|
|
61
|
+
codex: '.codex/config.toml'
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const exists = await mcpConfigExists(rootDir, platform);
|
|
65
|
+
if (!exists) {
|
|
66
|
+
return {
|
|
67
|
+
name: `MCP config (${configPaths[platform]})`,
|
|
68
|
+
ok: false,
|
|
69
|
+
hint: `Missing ${configPaths[platform]}. Run dream-wf init or TUI to configure MCP servers.`
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const servers = await readMcpServers(rootDir, platform);
|
|
74
|
+
const missing = MCP_CATALOG.filter((entry) => !servers[entry.name]).map((entry) => entry.name);
|
|
75
|
+
if (missing.length > 0) {
|
|
76
|
+
return {
|
|
77
|
+
name: `MCP config (${configPaths[platform]})`,
|
|
78
|
+
ok: false,
|
|
79
|
+
hint: `Missing MCP servers: ${missing.join(', ')}.`
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
name: `MCP config (${configPaths[platform]})`,
|
|
85
|
+
ok: true,
|
|
86
|
+
hint: `MCP config OK (${MCP_CATALOG.map((entry) => entry.name).join(', ')}).`
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
40
90
|
function binaryCheck(command, hint) {
|
|
41
91
|
return {
|
|
42
92
|
name: command,
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// 安装聚合器的可选 skill 和 mcp 条目。
|
|
2
|
+
// 每个 skill 对应一个模板目录 templates/skills/<name>/,由 shared.installSkill 装入各平台 skills 目录。
|
|
3
|
+
// 每个 mcp 对应一个独立 server 配置,由 mcp.installMcpServer 写入各平台 mcp 配置文件。
|
|
4
|
+
|
|
5
|
+
export const SKILL_CATALOG = [
|
|
6
|
+
{
|
|
7
|
+
id: 'trellis-dream-wf-patch',
|
|
8
|
+
name: 'dream-wf-grill-prd',
|
|
9
|
+
label: 'dream-wf-grill-prd (Trellis patch · grill-me style PRD)',
|
|
10
|
+
description: 'grill-me 风格的 PRD 澄清 skill,dream-wf 的核心 patch。',
|
|
11
|
+
templateDir: 'dream-wf-grill-prd',
|
|
12
|
+
default: true
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
id: 'dream-wf-mcp-policy',
|
|
16
|
+
name: 'dream-wf-mcp-policy',
|
|
17
|
+
label: 'dream-wf-mcp-policy (MCP 优先级策略 skill)',
|
|
18
|
+
description: '强制 fast-context-mcp / grok-search-mcp 优先级的策略 skill。',
|
|
19
|
+
templateDir: 'dream-wf-mcp-policy',
|
|
20
|
+
default: true
|
|
21
|
+
}
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export const MCP_CATALOG = [
|
|
25
|
+
{
|
|
26
|
+
id: 'fast-context',
|
|
27
|
+
name: 'fast-context',
|
|
28
|
+
label: 'fast-context-mcp (代码语义检索)',
|
|
29
|
+
description: '代码库语义理解优先 MCP,来源 SammySnake-d/fast-context-mcp。',
|
|
30
|
+
server: {
|
|
31
|
+
command: 'npx',
|
|
32
|
+
args: ['-y', '--prefer-online', 'fast-context-mcp@latest'],
|
|
33
|
+
env: {
|
|
34
|
+
WINDSURF_API_KEY: 'devin-session-xx'
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
default: true,
|
|
38
|
+
requires: {
|
|
39
|
+
binaries: ['npx']
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'grok-search',
|
|
44
|
+
name: 'grok-search',
|
|
45
|
+
label: 'grok-search-mcp (外部文档/实时网络检索)',
|
|
46
|
+
description: '外部文档和实时网络检索优先 MCP,来源 GuDaStudio/GrokSearch。',
|
|
47
|
+
server: {
|
|
48
|
+
type: 'stdio',
|
|
49
|
+
command: 'uvx',
|
|
50
|
+
args: ['--from', 'git+https://github.com/GuDaStudio/GrokSearch@grok-with-tavily', 'grok-search'],
|
|
51
|
+
env: {
|
|
52
|
+
GROK_API_URL: 'https://your-api-endpoint.com/v1',
|
|
53
|
+
GROK_API_KEY: 'your-grok-api-key',
|
|
54
|
+
GROK_MODEL: 'your-model-name',
|
|
55
|
+
TAVILY_API_KEY: 'optional-tavily-key',
|
|
56
|
+
TAVILY_API_URL: 'https://api.tavily.com'
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
default: true,
|
|
60
|
+
requires: {
|
|
61
|
+
binaries: ['uvx']
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
export function defaultSkillIds() {
|
|
67
|
+
return SKILL_CATALOG.filter((item) => item.default).map((item) => item.id);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function defaultMcpIds() {
|
|
71
|
+
return MCP_CATALOG.filter((item) => item.default).map((item) => item.id);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function resolveSkills(ids) {
|
|
75
|
+
const set = new Set(ids);
|
|
76
|
+
return SKILL_CATALOG.filter((item) => set.has(item.id));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function resolveMcps(ids) {
|
|
80
|
+
const set = new Set(ids);
|
|
81
|
+
return MCP_CATALOG.filter((item) => set.has(item.id));
|
|
82
|
+
}
|
package/src/lib/mcp.js
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { pathExists, readTextIfExists, writeTextFile } from './files.js';
|
|
3
|
+
import { readJsonObject, writeJsonObject } from './json.js';
|
|
4
|
+
|
|
5
|
+
// 把一个 mcp catalog 条目规范化成可写入的 server 描述对象。
|
|
6
|
+
// 去掉 catalog 里的 type 字段时保留 type(如果原始 server 已带),并保证字段顺序稳定。
|
|
7
|
+
function normalizeServer(entry) {
|
|
8
|
+
const server = entry.server;
|
|
9
|
+
const out = {};
|
|
10
|
+
if (server.type) {
|
|
11
|
+
out.type = server.type;
|
|
12
|
+
}
|
|
13
|
+
out.command = server.command;
|
|
14
|
+
if (Array.isArray(server.args)) {
|
|
15
|
+
out.args = server.args;
|
|
16
|
+
}
|
|
17
|
+
if (server.env && Object.keys(server.env).length > 0) {
|
|
18
|
+
out.env = server.env;
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Cursor: .cursor/mcp.json -> { mcpServers: { name: {...} } }
|
|
24
|
+
async function installCursorMcp(rootDir, mcpEntries) {
|
|
25
|
+
const configPath = path.join(rootDir, '.cursor', 'mcp.json');
|
|
26
|
+
const config = await readJsonObject(configPath, { mcpServers: {} });
|
|
27
|
+
config.mcpServers = config.mcpServers ?? {};
|
|
28
|
+
|
|
29
|
+
let changed = false;
|
|
30
|
+
for (const entry of mcpEntries) {
|
|
31
|
+
const server = normalizeServer(entry);
|
|
32
|
+
if (!deepEqual(config.mcpServers[entry.name], server)) {
|
|
33
|
+
config.mcpServers[entry.name] = server;
|
|
34
|
+
changed = true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (changed) {
|
|
39
|
+
await writeJsonObject(configPath, config);
|
|
40
|
+
}
|
|
41
|
+
return { changed, action: changed ? 'updated' : 'unchanged', path: configPath };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Claude Code: .mcp.json -> { mcpServers: { name: {...} } }
|
|
45
|
+
async function installClaudeMcp(rootDir, mcpEntries) {
|
|
46
|
+
const configPath = path.join(rootDir, '.mcp.json');
|
|
47
|
+
const config = await readJsonObject(configPath, { mcpServers: {} });
|
|
48
|
+
config.mcpServers = config.mcpServers ?? {};
|
|
49
|
+
|
|
50
|
+
let changed = false;
|
|
51
|
+
for (const entry of mcpEntries) {
|
|
52
|
+
const server = normalizeServer(entry);
|
|
53
|
+
if (!deepEqual(config.mcpServers[entry.name], server)) {
|
|
54
|
+
config.mcpServers[entry.name] = server;
|
|
55
|
+
changed = true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (changed) {
|
|
60
|
+
await writeJsonObject(configPath, config);
|
|
61
|
+
}
|
|
62
|
+
return { changed, action: changed ? 'updated' : 'unchanged', path: configPath };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// OpenCode: opencode.json -> { mcp: { servers: { name: {...} } } }
|
|
66
|
+
async function installOpenCodeMcp(rootDir, mcpEntries) {
|
|
67
|
+
const configPath = path.join(rootDir, 'opencode.json');
|
|
68
|
+
const config = await readJsonObject(configPath, { mcp: { servers: {} } });
|
|
69
|
+
config.mcp = config.mcp ?? {};
|
|
70
|
+
config.mcp.servers = config.mcp.servers ?? {};
|
|
71
|
+
|
|
72
|
+
let changed = false;
|
|
73
|
+
for (const entry of mcpEntries) {
|
|
74
|
+
const server = normalizeServer(entry);
|
|
75
|
+
if (!deepEqual(config.mcp.servers[entry.name], server)) {
|
|
76
|
+
config.mcp.servers[entry.name] = server;
|
|
77
|
+
changed = true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (changed) {
|
|
82
|
+
await writeJsonObject(configPath, config);
|
|
83
|
+
}
|
|
84
|
+
return { changed, action: changed ? 'updated' : 'unchanged', path: configPath };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Codex: ~/.codex/config.toml 的 [mcp_servers.<name>] 段。
|
|
88
|
+
// 项目级 Codex 配置也支持放到 .codex/config.toml(Codex CLI 0.8+ 读取项目目录)。
|
|
89
|
+
async function installCodexMcp(rootDir, mcpEntries) {
|
|
90
|
+
const configPath = path.join(rootDir, '.codex', 'config.toml');
|
|
91
|
+
const existing = await readTextIfExists(configPath);
|
|
92
|
+
const lines = existing ? existing.split('\n') : [];
|
|
93
|
+
|
|
94
|
+
let working = [...lines];
|
|
95
|
+
|
|
96
|
+
for (const entry of mcpEntries) {
|
|
97
|
+
const block = renderCodexServerBlock(entry);
|
|
98
|
+
const marker = `[mcp_servers.${entry.name}]`;
|
|
99
|
+
const next = replaceOrAppendTomlBlock(working, marker, block);
|
|
100
|
+
working = next.lines;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 规范化:去掉末尾空行后再统一加一个尾换行。
|
|
104
|
+
while (working.length > 0 && working[working.length - 1] === '') {
|
|
105
|
+
working.pop();
|
|
106
|
+
}
|
|
107
|
+
const normalized = [...working, ''].join('\n');
|
|
108
|
+
const original = existing ? `${existing.replace(/\s+$/, '')}\n` : '';
|
|
109
|
+
|
|
110
|
+
const changed = normalized !== original;
|
|
111
|
+
if (changed) {
|
|
112
|
+
await writeTextFile(configPath, normalized);
|
|
113
|
+
}
|
|
114
|
+
return { changed, action: changed ? (existing ? 'updated' : 'created') : 'unchanged', path: configPath };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function renderCodexServerBlock(entry) {
|
|
118
|
+
const server = entry.server;
|
|
119
|
+
const out = [`[mcp_servers.${entry.name}]`];
|
|
120
|
+
out.push(`command = ${tomlString(server.command)}`);
|
|
121
|
+
if (Array.isArray(server.args) && server.args.length > 0) {
|
|
122
|
+
out.push(`args = [${server.args.map(tomlString).join(', ')}]`);
|
|
123
|
+
}
|
|
124
|
+
if (server.env && Object.keys(server.env).length > 0) {
|
|
125
|
+
out.push(`[mcp_servers.${entry.name}.env]`);
|
|
126
|
+
for (const [key, value] of Object.entries(server.env)) {
|
|
127
|
+
out.push(`${key} = ${tomlString(value)}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return out.join('\n');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function replaceOrAppendTomlBlock(lines, marker, blockText) {
|
|
134
|
+
const start = lines.findIndex((line) => line.trim() === marker.trim());
|
|
135
|
+
if (start === -1) {
|
|
136
|
+
const newLines = [...lines];
|
|
137
|
+
if (newLines.length > 0 && newLines[newLines.length - 1] !== '') {
|
|
138
|
+
newLines.push('');
|
|
139
|
+
}
|
|
140
|
+
newLines.push(blockText, '');
|
|
141
|
+
return { changed: true, lines: newLines };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 找到该块的结束位置:下一个顶层 [ 开头的行或文件末尾。
|
|
145
|
+
let end = start + 1;
|
|
146
|
+
while (end < lines.length) {
|
|
147
|
+
const trimmed = lines[end].trim();
|
|
148
|
+
if (trimmed.startsWith('[') && !trimmed.startsWith('[mcp_servers.') || trimmed === marker) {
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
if (trimmed.startsWith('[') && !trimmed.startsWith(`[mcp_servers.${extractTableName(marker)}.`)) {
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
end += 1;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const blockLines = blockText.split('\n');
|
|
158
|
+
const before = lines.slice(0, start);
|
|
159
|
+
const after = lines.slice(end);
|
|
160
|
+
const next = [...before, ...blockLines, '', ...after];
|
|
161
|
+
return { changed: true, lines: next };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function extractTableName(marker) {
|
|
165
|
+
const match = marker.match(/^\[mcp_servers\.([^\].]+)\]$/);
|
|
166
|
+
return match ? match[1] : '';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function tomlString(value) {
|
|
170
|
+
if (typeof value !== 'string') {
|
|
171
|
+
return String(value);
|
|
172
|
+
}
|
|
173
|
+
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
174
|
+
return `"${escaped}"`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function deepEqual(a, b) {
|
|
178
|
+
return JSON.stringify(sortKeys(a)) === JSON.stringify(sortKeys(b));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function sortKeys(value) {
|
|
182
|
+
if (Array.isArray(value)) {
|
|
183
|
+
return value.map(sortKeys);
|
|
184
|
+
}
|
|
185
|
+
if (value && typeof value === 'object') {
|
|
186
|
+
return Object.keys(value).sort().reduce((acc, key) => {
|
|
187
|
+
acc[key] = sortKeys(value[key]);
|
|
188
|
+
return acc;
|
|
189
|
+
}, {});
|
|
190
|
+
}
|
|
191
|
+
return value;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const INSTALLERS = {
|
|
195
|
+
cursor: installCursorMcp,
|
|
196
|
+
claude: installClaudeMcp,
|
|
197
|
+
opencode: installOpenCodeMcp,
|
|
198
|
+
codex: installCodexMcp
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
export async function installMcpServers(rootDir, platform, mcpEntries) {
|
|
202
|
+
const installer = INSTALLERS[platform];
|
|
203
|
+
if (!installer) {
|
|
204
|
+
throw new Error(`MCP install is not supported for platform "${platform}".`);
|
|
205
|
+
}
|
|
206
|
+
return installer(rootDir, mcpEntries);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 读取已存在的 mcp 配置,用于 doctor 检查。
|
|
210
|
+
export async function readMcpServers(rootDir, platform) {
|
|
211
|
+
switch (platform) {
|
|
212
|
+
case 'cursor': {
|
|
213
|
+
const config = await readJsonObject(path.join(rootDir, '.cursor', 'mcp.json'), { mcpServers: {} });
|
|
214
|
+
return config.mcpServers ?? {};
|
|
215
|
+
}
|
|
216
|
+
case 'claude': {
|
|
217
|
+
const config = await readJsonObject(path.join(rootDir, '.mcp.json'), { mcpServers: {} });
|
|
218
|
+
return config.mcpServers ?? {};
|
|
219
|
+
}
|
|
220
|
+
case 'opencode': {
|
|
221
|
+
const config = await readJsonObject(path.join(rootDir, 'opencode.json'), { mcp: { servers: {} } });
|
|
222
|
+
return config.mcp?.servers ?? {};
|
|
223
|
+
}
|
|
224
|
+
case 'codex': {
|
|
225
|
+
const text = await readTextIfExists(path.join(rootDir, '.codex', 'config.toml'));
|
|
226
|
+
return parseCodexMcpServers(text ?? '');
|
|
227
|
+
}
|
|
228
|
+
default:
|
|
229
|
+
return {};
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function parseCodexMcpServers(text) {
|
|
234
|
+
const servers = {};
|
|
235
|
+
const lines = text.split('\n');
|
|
236
|
+
let currentName = null;
|
|
237
|
+
let inEnv = false;
|
|
238
|
+
for (const line of lines) {
|
|
239
|
+
const trimmed = line.trim();
|
|
240
|
+
const head = trimmed.match(/^\[mcp_servers\.([^\].]+)\]$/);
|
|
241
|
+
if (head) {
|
|
242
|
+
currentName = head[1];
|
|
243
|
+
servers[currentName] = {};
|
|
244
|
+
inEnv = false;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (currentName && trimmed.startsWith(`[mcp_servers.${currentName}.env]`)) {
|
|
248
|
+
servers[currentName].env = {};
|
|
249
|
+
inEnv = true;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (currentName && trimmed.startsWith('[')) {
|
|
253
|
+
currentName = null;
|
|
254
|
+
inEnv = false;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (!currentName) {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const match = trimmed.match(/^([A-Za-z0-9_]+)\s*=\s*(.*)$/);
|
|
261
|
+
if (!match) {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const [, key, rawValue] = match;
|
|
265
|
+
const value = parseTomlValue(rawValue);
|
|
266
|
+
if (inEnv) {
|
|
267
|
+
servers[currentName].env[key] = value;
|
|
268
|
+
} else if (key === 'args' && Array.isArray(value)) {
|
|
269
|
+
servers[currentName].args = value;
|
|
270
|
+
} else {
|
|
271
|
+
servers[currentName][key] = value;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return servers;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function parseTomlValue(raw) {
|
|
278
|
+
const value = raw.trim();
|
|
279
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
280
|
+
const inner = value.slice(1, -1).trim();
|
|
281
|
+
if (!inner) {
|
|
282
|
+
return [];
|
|
283
|
+
}
|
|
284
|
+
return inner.split(',').map((item) => parseTomlValue(item.trim())).filter((item) => item !== undefined);
|
|
285
|
+
}
|
|
286
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
287
|
+
return value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
288
|
+
}
|
|
289
|
+
return value;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function mcpConfigExists(rootDir, platform) {
|
|
293
|
+
switch (platform) {
|
|
294
|
+
case 'cursor':
|
|
295
|
+
return pathExists(path.join(rootDir, '.cursor', 'mcp.json'));
|
|
296
|
+
case 'claude':
|
|
297
|
+
return pathExists(path.join(rootDir, '.mcp.json'));
|
|
298
|
+
case 'opencode':
|
|
299
|
+
return pathExists(path.join(rootDir, 'opencode.json'));
|
|
300
|
+
case 'codex':
|
|
301
|
+
return pathExists(path.join(rootDir, '.codex', 'config.toml'));
|
|
302
|
+
default:
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
}
|
package/src/lib/platforms.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
export const SUPPORTED_PLATFORMS = new Set(['cursor', 'claude', 'opencode']);
|
|
1
|
+
export const SUPPORTED_PLATFORMS = new Set(['cursor', 'claude', 'opencode', 'codex']);
|
|
2
|
+
|
|
3
|
+
export const PLATFORM_LABELS = {
|
|
4
|
+
cursor: 'Cursor',
|
|
5
|
+
claude: 'Claude Code',
|
|
6
|
+
opencode: 'OpenCode',
|
|
7
|
+
codex: 'Codex'
|
|
8
|
+
};
|
|
2
9
|
|
|
3
10
|
export function normalizePlatform(value) {
|
|
4
11
|
if (!value) {
|
|
@@ -10,11 +17,11 @@ export function normalizePlatform(value) {
|
|
|
10
17
|
|
|
11
18
|
export function assertSupportedPlatform(platform) {
|
|
12
19
|
if (!platform) {
|
|
13
|
-
throw new Error('Missing required -p <cursor|claude|opencode>.');
|
|
20
|
+
throw new Error('Missing required -p <cursor|claude|opencode|codex>.');
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
if (!SUPPORTED_PLATFORMS.has(platform)) {
|
|
17
|
-
throw new Error(`Unsupported platform "${platform}". Use one of: cursor, claude, opencode.`);
|
|
24
|
+
throw new Error(`Unsupported platform "${platform}". Use one of: cursor, claude, opencode, codex.`);
|
|
18
25
|
}
|
|
19
26
|
}
|
|
20
27
|
|
|
@@ -28,5 +35,8 @@ export function trellisPlatformFlag(platform) {
|
|
|
28
35
|
if (platform === 'opencode') {
|
|
29
36
|
return '--opencode';
|
|
30
37
|
}
|
|
38
|
+
if (platform === 'codex') {
|
|
39
|
+
return '--codex';
|
|
40
|
+
}
|
|
31
41
|
assertSupportedPlatform(platform);
|
|
32
42
|
}
|
package/src/lib/trellis.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
import process from 'node:process';
|
|
2
3
|
import { spawnSync } from 'node:child_process';
|
|
3
4
|
import { appendBlockOnce, pathExists, readTextIfExists, writeTextFile } from './files.js';
|
|
4
5
|
import { trellisPlatformFlag } from './platforms.js';
|
|
5
6
|
|
|
6
7
|
const DREAM_WF_MARKER = '<!-- dream-wf:profile:v1 -->';
|
|
7
8
|
|
|
9
|
+
function writeOutput(message) {
|
|
10
|
+
process.stdout.write(`${message}\n`);
|
|
11
|
+
}
|
|
12
|
+
|
|
8
13
|
export async function detectTrellis(rootDir) {
|
|
9
14
|
const trellisDir = path.join(rootDir, '.trellis');
|
|
10
15
|
const workflowPath = path.join(trellisDir, 'workflow.md');
|
|
@@ -37,11 +42,25 @@ export async function ensureTrellisInitialized(rootDir, options) {
|
|
|
37
42
|
throw new Error('Pass --developer <name> when using --install-deps so dream-wf can run trellis init non-interactively.');
|
|
38
43
|
}
|
|
39
44
|
|
|
45
|
+
// trellis CLI 未安装时,先 npm install -g。
|
|
40
46
|
if (!state.cli) {
|
|
41
|
-
|
|
47
|
+
writeOutput('Installing @mindfoldhq/trellis globally...');
|
|
48
|
+
const installResult = spawnSync('npm', ['install', '-g', '@mindfoldhq/trellis@latest'], {
|
|
49
|
+
stdio: 'inherit'
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (installResult.status !== 0) {
|
|
53
|
+
throw new Error(`Failed to install trellis with npm, exit code ${installResult.status ?? 'unknown'}.`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 重新检测。
|
|
57
|
+
state.cli = commandExists('trellis');
|
|
58
|
+
if (!state.cli) {
|
|
59
|
+
throw new Error('trellis CLI still not found after npm install. Check your PATH.');
|
|
60
|
+
}
|
|
42
61
|
}
|
|
43
62
|
|
|
44
|
-
const result = spawnSync('trellis', ['init', '-u', options.developer, platformFlag], {
|
|
63
|
+
const result = spawnSync('trellis', ['init', '-u', options.developer, platformFlag, '--yes'], {
|
|
45
64
|
cwd: rootDir,
|
|
46
65
|
stdio: 'inherit'
|
|
47
66
|
});
|
|
@@ -99,6 +118,7 @@ function dreamWorkflowBlock() {
|
|
|
99
118
|
'- Use grill-me behavior for requirement discovery: ask one question at a time, provide 2-3 options and a recommended answer, and inspect code/docs/config before asking the user.',
|
|
100
119
|
'- Update `prd.md` only after a user answer, confirmed existing fact, or explicit decision is available.',
|
|
101
120
|
'- Treat PRD confirmation as separate from task creation consent.',
|
|
121
|
+
'- **Before requesting PRD confirmation, perform a Knowledge Verification pass.** Use `grok-search-mcp` (`web_search`, `web_fetch`) to verify technical assumptions that could be outdated or wrong (API names, hook events, config formats, version-specific behavior, release status). Record results in the `## Knowledge Verification` section of `prd.md`. Correct any outdated assumptions. Move unverified points to `Open Questions`. Add `knowledge verified` to the PRD after verification is complete.',
|
|
102
122
|
'- Generate initial spec candidates from user answers, PRD decisions, and verified project/code facts; require user review before treating them as stable conventions.',
|
|
103
123
|
'- Do not guess or fabricate unknown facts, APIs, package behavior, release status, or external documentation; search with preferred MCP tools or ask the user until accurate information is available.',
|
|
104
124
|
'- Write README and project documentation in Chinese. Write code comments in Chinese when comments are necessary, and avoid obvious comments.',
|
|
@@ -2,16 +2,20 @@ import path from 'node:path';
|
|
|
2
2
|
import { readFile, chmod } from 'node:fs/promises';
|
|
3
3
|
import { readJsonObject, writeJsonObject, pushUniqueByCommand } from '../../lib/json.js';
|
|
4
4
|
import { writeIfChanged } from '../../lib/files.js';
|
|
5
|
-
import { installCommonDreamWfFiles, installManagedBlock,
|
|
5
|
+
import { installCommonDreamWfFiles, installManagedBlock, installSelectedSkills } from '../shared.js';
|
|
6
|
+
import { installMcpServers } from '../../lib/mcp.js';
|
|
6
7
|
|
|
7
8
|
export async function installClaudeCode(packageRoot, targetRoot, options) {
|
|
8
9
|
const results = [];
|
|
9
10
|
|
|
10
11
|
results.push(await installManagedBlock(packageRoot, targetRoot, 'templates/rules/claude-code/dream-wf-block.md', 'CLAUDE.md', '<!-- DREAM-WF:START -->', '<!-- DREAM-WF:END -->'));
|
|
11
|
-
results.push(await
|
|
12
|
-
results.push(await installSkill(packageRoot, targetRoot, '.claude', 'dream-wf-mcp-policy'));
|
|
12
|
+
results.push(...await installSelectedSkills(packageRoot, targetRoot, '.claude', options.skills));
|
|
13
13
|
results.push(...await installCommonDreamWfFiles(packageRoot, targetRoot));
|
|
14
14
|
|
|
15
|
+
if (options.mcps && options.mcps.length > 0) {
|
|
16
|
+
results.push(await installMcpServers(targetRoot, 'claude', options.mcps));
|
|
17
|
+
}
|
|
18
|
+
|
|
15
19
|
if (options.mode === 'strict') {
|
|
16
20
|
results.push(await installClaudeHook(packageRoot, targetRoot));
|
|
17
21
|
results.push(await mergeClaudeSettings(targetRoot));
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readFile, chmod } from 'node:fs/promises';
|
|
3
|
+
import { readJsonObject, writeJsonObject, pushUniqueByCommand } from '../../lib/json.js';
|
|
4
|
+
import { writeIfChanged, readTextIfExists, writeTextFile } from '../../lib/files.js';
|
|
5
|
+
import { installCommonDreamWfFiles, installManagedBlock, installSelectedSkills } from '../shared.js';
|
|
6
|
+
import { installMcpServers } from '../../lib/mcp.js';
|
|
7
|
+
|
|
8
|
+
// Codex CLI 读取项目根的 AGENTS.md 作为入口规则。
|
|
9
|
+
// Codex 支持 PreToolUse 阻塞式 hook,配置在 .codex/hooks.json(和 config.toml [hooks] 段等效)。
|
|
10
|
+
// 需要在 config.toml 里加 [features] hooks = true 来启用 hooks 功能。
|
|
11
|
+
// hook 脚本放在 .codex/hooks/dream-wf-guard.py。
|
|
12
|
+
export async function installCodex(packageRoot, targetRoot, options) {
|
|
13
|
+
const results = [];
|
|
14
|
+
|
|
15
|
+
results.push(await installManagedBlock(packageRoot, targetRoot, 'templates/rules/codex/dream-wf-block.md', 'AGENTS.md', '<!-- DREAM-WF:START -->', '<!-- DREAM-WF:END -->'));
|
|
16
|
+
results.push(...await installSelectedSkills(packageRoot, targetRoot, '.codex', options.skills));
|
|
17
|
+
results.push(...await installCommonDreamWfFiles(packageRoot, targetRoot));
|
|
18
|
+
|
|
19
|
+
if (options.mcps && options.mcps.length > 0) {
|
|
20
|
+
results.push(await installMcpServers(targetRoot, 'codex', options.mcps));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (options.mode === 'strict') {
|
|
24
|
+
results.push(await installCodexHook(packageRoot, targetRoot));
|
|
25
|
+
results.push(await ensureCodexHooksFeature(targetRoot));
|
|
26
|
+
results.push(await mergeCodexHooks(targetRoot));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return results;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function installCodexHook(packageRoot, targetRoot) {
|
|
33
|
+
const sourcePath = path.join(packageRoot, 'templates', 'hooks', 'codex', 'dream-wf-guard.py');
|
|
34
|
+
const targetPath = path.join(targetRoot, '.codex', 'hooks', 'dream-wf-guard.py');
|
|
35
|
+
const contents = await readFile(sourcePath, 'utf8');
|
|
36
|
+
const result = await writeIfChanged(targetPath, contents);
|
|
37
|
+
await chmod(targetPath, 0o755);
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 在 config.toml 里确保 [features] hooks = true 存在。
|
|
42
|
+
// 用简单的文本检查实现幂等:如果已有则不动。
|
|
43
|
+
async function ensureCodexHooksFeature(rootDir) {
|
|
44
|
+
const configPath = path.join(rootDir, '.codex', 'config.toml');
|
|
45
|
+
const existing = await readTextIfExists(configPath);
|
|
46
|
+
const hasFeature = existing?.includes('hooks = true') || existing?.includes('hooks=true');
|
|
47
|
+
|
|
48
|
+
if (hasFeature) {
|
|
49
|
+
return { changed: false, action: 'unchanged', path: configPath };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const featureBlock = '[features]\nhooks = true\n';
|
|
53
|
+
const prefix = existing && !existing.endsWith('\n') ? `${existing}\n\n` : existing ? `${existing}\n` : '';
|
|
54
|
+
await writeTextFile(configPath, `${prefix}${featureBlock}`);
|
|
55
|
+
return { changed: true, action: existing ? 'updated' : 'created', path: configPath };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Codex hooks.json 格式和 Claude Code 的 settings.json hooks 段一致:
|
|
59
|
+
// { "hooks": { "PreToolUse": [ { "matcher": "...", "hooks": [ { "type": "command", "command": "...", "timeout": 10 } ] } ] } }
|
|
60
|
+
// Codex 的 matcher 是正则匹配 tool_name,用 Bash|Shell|apply_patch|Edit|Write 匹配变更类工具。
|
|
61
|
+
async function mergeCodexHooks(rootDir) {
|
|
62
|
+
const hooksPath = path.join(rootDir, '.codex', 'hooks.json');
|
|
63
|
+
const hooks = await readJsonObject(hooksPath, { hooks: {} });
|
|
64
|
+
hooks.hooks = hooks.hooks ?? {};
|
|
65
|
+
hooks.hooks.PreToolUse = hooks.hooks.PreToolUse ?? [];
|
|
66
|
+
|
|
67
|
+
const changed = pushUniqueByCommand(hooks.hooks.PreToolUse, {
|
|
68
|
+
matcher: 'Bash|Shell|shell|apply_patch|Edit|Write',
|
|
69
|
+
hooks: [
|
|
70
|
+
{
|
|
71
|
+
type: 'command',
|
|
72
|
+
command: 'python3 "$CODEX_PROJECT_DIR/.codex/hooks/dream-wf-guard.py"',
|
|
73
|
+
timeout: 10
|
|
74
|
+
}
|
|
75
|
+
]
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (changed) {
|
|
79
|
+
await writeJsonObject(hooksPath, hooks);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { changed, action: changed ? 'updated' : 'unchanged', path: hooksPath };
|
|
83
|
+
}
|