dream-wf 0.1.0 → 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.
Files changed (30) hide show
  1. package/README.md +122 -55
  2. package/core/grill-prd-policy.md +2 -0
  3. package/core/workflow-profile.md +1 -0
  4. package/package.json +4 -3
  5. package/src/cli/index.js +192 -65
  6. package/src/deps/index.js +50 -0
  7. package/src/lib/catalog.js +82 -0
  8. package/src/lib/mcp.js +305 -0
  9. package/src/lib/platforms.js +13 -3
  10. package/src/lib/trellis.js +25 -2
  11. package/src/platforms/claude-code/index.js +7 -3
  12. package/src/platforms/codex/index.js +83 -0
  13. package/src/platforms/cursor/index.js +7 -3
  14. package/src/platforms/opencode/index.js +7 -3
  15. package/src/platforms/shared.js +11 -0
  16. package/src/tui/index.js +445 -0
  17. package/templates/hooks/claude-code/__pycache__/dream-wf-guard.cpython-314.pyc +0 -0
  18. package/templates/hooks/claude-code/dream-wf-guard.py +33 -11
  19. package/templates/hooks/codex/__pycache__/dream-wf-guard.cpython-314.pyc +0 -0
  20. package/templates/hooks/codex/dream-wf-guard.py +150 -0
  21. package/templates/hooks/cursor/__pycache__/dream-wf-guard.cpython-314.pyc +0 -0
  22. package/templates/hooks/cursor/dream-wf-guard.py +30 -11
  23. package/templates/hooks/opencode/dream-wf-guard.js +28 -10
  24. package/templates/rules/claude-code/dream-wf-block.md +20 -0
  25. package/templates/rules/codex/dream-wf-block.md +43 -0
  26. package/templates/rules/cursor/dream-wf.mdc +20 -1
  27. package/templates/rules/opencode/dream-wf-block.md +20 -0
  28. package/templates/skills/dream-wf-grill-prd/SKILL.md +54 -2
  29. package/templates/spec/guides/dream-wf-mcp-policy.md +6 -0
  30. package/templates/spec/guides/dream-wf-prd-policy.md +17 -0
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
+ }
@@ -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
  }
@@ -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
- throw new Error('trellis is not installed. Install it with: npm install -g @mindfoldhq/trellis@latest');
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,7 +118,11 @@ 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.',
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.',
124
+ '- Write README and project documentation in Chinese. Write code comments in Chinese when comments are necessary, and avoid obvious comments.',
125
+ '- Prefer concise file names: use one word when clear, or lowercase snake_case for necessary multi-word names.',
103
126
  '- Continue using `trellis-before-dev`, `trellis-check`, `trellis-update-spec`, and `trellis-break-loop` without replacing them.',
104
127
  '',
105
128
  '### Dream WF MCP Tool Policy',
@@ -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, installSkill } from '../shared.js';
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 installSkill(packageRoot, targetRoot, '.claude', 'dream-wf-grill-prd'));
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
+ }
@@ -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, installRuleFile, installSkill } from '../shared.js';
5
+ import { installCommonDreamWfFiles, installRuleFile, installSelectedSkills } from '../shared.js';
6
+ import { installMcpServers } from '../../lib/mcp.js';
6
7
 
7
8
  export async function installCursor(packageRoot, targetRoot, options) {
8
9
  const results = [];
9
10
 
10
11
  results.push(await installRuleFile(packageRoot, targetRoot, 'templates/rules/cursor/dream-wf.mdc', '.cursor/rules/dream-wf.mdc'));
11
- results.push(await installSkill(packageRoot, targetRoot, '.cursor', 'dream-wf-grill-prd'));
12
- results.push(await installSkill(packageRoot, targetRoot, '.cursor', 'dream-wf-mcp-policy'));
12
+ results.push(...await installSelectedSkills(packageRoot, targetRoot, '.cursor', 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, 'cursor', options.mcps));
17
+ }
18
+
15
19
  if (options.mode === 'strict') {
16
20
  results.push(await installCursorHook(packageRoot, targetRoot));
17
21
  results.push(await mergeCursorHooks(targetRoot));
@@ -1,16 +1,20 @@
1
1
  import path from 'node:path';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { writeIfChanged } from '../../lib/files.js';
4
- import { installCommonDreamWfFiles, installManagedBlock, installSkill } from '../shared.js';
4
+ import { installCommonDreamWfFiles, installManagedBlock, installSelectedSkills } from '../shared.js';
5
+ import { installMcpServers } from '../../lib/mcp.js';
5
6
 
6
7
  export async function installOpenCode(packageRoot, targetRoot, options) {
7
8
  const results = [];
8
9
 
9
10
  results.push(await installManagedBlock(packageRoot, targetRoot, 'templates/rules/opencode/dream-wf-block.md', 'AGENTS.md', '<!-- DREAM-WF:START -->', '<!-- DREAM-WF:END -->'));
10
- results.push(await installSkill(packageRoot, targetRoot, '.opencode', 'dream-wf-grill-prd'));
11
- results.push(await installSkill(packageRoot, targetRoot, '.opencode', 'dream-wf-mcp-policy'));
11
+ results.push(...await installSelectedSkills(packageRoot, targetRoot, '.opencode', options.skills));
12
12
  results.push(...await installCommonDreamWfFiles(packageRoot, targetRoot));
13
13
 
14
+ if (options.mcps && options.mcps.length > 0) {
15
+ results.push(await installMcpServers(targetRoot, 'opencode', options.mcps));
16
+ }
17
+
14
18
  if (options.mode === 'strict') {
15
19
  results.push(await installOpenCodePlugin(packageRoot, targetRoot));
16
20
  }
@@ -9,6 +9,17 @@ export async function installSkill(packageRoot, targetRoot, platformDir, skillNa
9
9
  return writeIfChanged(targetPath, contents);
10
10
  }
11
11
 
12
+ // 按 catalog 选中的 skill 条目安装。skills 为 resolveSkills 返回的数组。
13
+ // 若 skills 为 undefined,则安装全部(向后兼容旧 CLI 行为)。
14
+ export async function installSelectedSkills(packageRoot, targetRoot, platformDir, skills) {
15
+ const list = skills ?? [];
16
+ const results = [];
17
+ for (const skill of list) {
18
+ results.push(await installSkill(packageRoot, targetRoot, platformDir, skill.name));
19
+ }
20
+ return results;
21
+ }
22
+
12
23
  export async function installSpecGuide(packageRoot, targetRoot, fileName) {
13
24
  const sourcePath = path.join(packageRoot, 'templates', 'spec', 'guides', fileName);
14
25
  const targetPath = path.join(targetRoot, '.trellis', 'spec', 'guides', fileName);