@rafter-security/cli 0.7.7 → 0.7.9

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.
@@ -11,6 +11,7 @@ import { createRequire } from "module";
11
11
  import { createInterface } from "readline";
12
12
  import { fmt } from "../../utils/formatter.js";
13
13
  import { injectInstructionFile } from "./instruction-block.js";
14
+ import yaml from "js-yaml";
14
15
  const __filename = fileURLToPath(import.meta.url);
15
16
  const __dirname = path.dirname(__filename);
16
17
  /**
@@ -54,17 +55,23 @@ function installGlobalInstructions(platforms, root, scope) {
54
55
  console.log(fmt.warning(`Failed to write Claude Code instructions: ${e}`));
55
56
  }
56
57
  }
57
- // Codex~/.codex/AGENTS.md (user) or <cwd>/AGENTS.md (project)
58
- if (platforms.codex) {
58
+ // AGENTS.mdread natively by Codex AND Windsurf. Codex at user scope keeps
59
+ // its own copy at ~/.codex/AGENTS.md; everything else (project scope, or any
60
+ // scope where Windsurf is in play) writes <root>/AGENTS.md once.
61
+ if (platforms.codex || platforms.windsurf) {
59
62
  try {
60
- const filePath = scope === "user"
63
+ const codexUser = scope === "user" && platforms.codex && !platforms.windsurf;
64
+ const filePath = codexUser
61
65
  ? path.join(root, ".codex", "AGENTS.md")
62
66
  : path.join(root, "AGENTS.md");
63
67
  injectInstructionFile(filePath);
64
- console.log(fmt.success(`Installed Rafter instructions to ${filePath}`));
68
+ const readers = [platforms.codex && "Codex", platforms.windsurf && "Windsurf"]
69
+ .filter(Boolean)
70
+ .join(" + ");
71
+ console.log(fmt.success(`Installed Rafter instructions for ${readers} to ${filePath}`));
65
72
  }
66
73
  catch (e) {
67
- console.log(fmt.warning(`Failed to write Codex instructions: ${e}`));
74
+ console.log(fmt.warning(`Failed to write AGENTS.md: ${e}`));
68
75
  }
69
76
  }
70
77
  // Gemini — ~/.gemini/GEMINI.md (user) or <cwd>/GEMINI.md (project)
@@ -80,17 +87,10 @@ function installGlobalInstructions(platforms, root, scope) {
80
87
  console.log(fmt.warning(`Failed to write Gemini instructions: ${e}`));
81
88
  }
82
89
  }
83
- // Cursor <root>/.cursor/rules/rafter-security.mdc
84
- if (platforms.cursor) {
85
- try {
86
- const filePath = path.join(root, ".cursor", "rules", "rafter-security.mdc");
87
- injectInstructionFile(filePath);
88
- console.log(fmt.success(`Installed Rafter instructions to ${filePath}`));
89
- }
90
- catch (e) {
91
- console.log(fmt.warning(`Failed to write Cursor instructions: ${e}`));
92
- }
93
- }
90
+ // Cursor uses per-skill rules at <root>/.cursor/rules/<skill>.mdc and the
91
+ // rafter sub-agent at <root>/.cursor/agents/rafter.md (installed in the
92
+ // Cursor branch above). The consolidated rafter-security.mdc was retired
93
+ // in rf-svn3 in favor of per-skill rules with trigger-first descriptions.
94
94
  }
95
95
  function installClaudeCodeHooks(root) {
96
96
  const settingsPath = path.join(root, ".claude", "settings.json");
@@ -170,11 +170,30 @@ function installCodexHooks(root) {
170
170
  // Remove existing rafter hooks
171
171
  config.hooks.PreToolUse = config.hooks.PreToolUse.filter((entry) => !(entry.hooks || []).some((h) => h.command?.startsWith("rafter hook pretool")));
172
172
  config.hooks.PostToolUse = config.hooks.PostToolUse.filter((entry) => !(entry.hooks || []).some((h) => h.command?.startsWith("rafter hook posttool")));
173
- config.hooks.PreToolUse.push({ matcher: "Bash", hooks: [preHook] });
173
+ // PreToolUse intercepts the tools Codex documents support for: Bash and
174
+ // apply_patch (file edits). Per developers.openai.com/codex/hooks PreToolUse
175
+ // also covers MCP tool calls via patterns like `mcp__<server>__<tool>` —
176
+ // when an MCP server is wired up, install a separate matcher for it.
177
+ // (rf-ovql verification 2026-05-03.)
178
+ config.hooks.PreToolUse.push({ matcher: "Bash|apply_patch", hooks: [preHook] });
179
+ // PostToolUse fires for the same tool surface; .* keeps all events in the
180
+ // audit log without filtering.
174
181
  config.hooks.PostToolUse.push({ matcher: ".*", hooks: [postHook] });
175
182
  fs.writeFileSync(hooksPath, JSON.stringify(config, null, 2), "utf-8");
176
183
  console.log(fmt.success(`Installed hooks to ${hooksPath}`));
177
184
  }
185
+ /**
186
+ * Install Cursor hooks at <root>/.cursor/hooks.json.
187
+ *
188
+ * Covers the full pre/post-tool lifecycle plus shell-specific gating:
189
+ * - preToolUse — rafter classifies every tool call
190
+ * - postToolUse — rafter post-hook (audit, telemetry)
191
+ * - beforeShellExecution — narrower complement; some Cursor versions
192
+ * fire this without firing preToolUse for shell.
193
+ *
194
+ * Idempotent — repeated installs do not duplicate rafter entries.
195
+ * Non-rafter entries (other tools' hooks, unrelated events) are preserved.
196
+ */
178
197
  function installCursorHooks(root) {
179
198
  const cursorDir = path.join(root, ".cursor");
180
199
  if (!fs.existsSync(cursorDir)) {
@@ -194,18 +213,116 @@ function installCursorHooks(root) {
194
213
  config.version = 1;
195
214
  if (!config.hooks)
196
215
  config.hooks = {};
197
- if (!config.hooks.beforeShellExecution)
198
- config.hooks.beforeShellExecution = [];
199
- // Remove existing rafter hooks
200
- config.hooks.beforeShellExecution = config.hooks.beforeShellExecution.filter((entry) => !entry.command?.includes("rafter hook pretool"));
201
- config.hooks.beforeShellExecution.push({
202
- command: "rafter hook pretool --format cursor",
203
- type: "command",
204
- timeout: 5000,
205
- });
216
+ const events = [
217
+ { event: "preToolUse", command: "rafter hook pretool --format cursor" },
218
+ { event: "postToolUse", command: "rafter hook posttool --format cursor" },
219
+ { event: "beforeShellExecution", command: "rafter hook pretool --format cursor" },
220
+ ];
221
+ for (const { event, command } of events) {
222
+ if (!Array.isArray(config.hooks[event]))
223
+ config.hooks[event] = [];
224
+ config.hooks[event] = config.hooks[event].filter((entry) => !entry?.command?.includes("rafter hook"));
225
+ config.hooks[event].push({ command, type: "command", timeout: 5000 });
226
+ }
206
227
  fs.writeFileSync(hooksPath, JSON.stringify(config, null, 2), "utf-8");
207
228
  console.log(fmt.success(`Installed hooks to ${hooksPath}`));
208
229
  }
230
+ /** Skills shipped as both Cursor rules and (Claude Code / Codex / Gemini) skills. */
231
+ const CURSOR_RULE_SKILLS = [
232
+ "rafter",
233
+ "rafter-secure-design",
234
+ "rafter-code-review",
235
+ "rafter-skill-review",
236
+ ];
237
+ /**
238
+ * Install per-skill Cursor rules at <root>/.cursor/rules/<skill>.mdc.
239
+ *
240
+ * One file per shipped skill. Each rule's frontmatter description is reused
241
+ * verbatim from the skill's SKILL.md frontmatter (trigger-first phrasing per
242
+ * rf-4ei / rf-8po) so Cursor surfaces it on the same triggers as Claude Code.
243
+ *
244
+ * Replaces the legacy consolidated `.cursor/rules/rafter-security.mdc` — that
245
+ * single file is no longer written by the Cursor install path.
246
+ */
247
+ function installCursorRules(root) {
248
+ const rulesDir = path.join(root, ".cursor", "rules");
249
+ fs.mkdirSync(rulesDir, { recursive: true });
250
+ // Resolve resources/cursor-rules relative to this module.
251
+ // After build: dist/commands/agent/init.js -> ../../../resources/cursor-rules
252
+ const candidates = [
253
+ path.resolve(__dirname, "..", "..", "..", "resources", "cursor-rules"),
254
+ path.resolve(__dirname, "..", "..", "resources", "cursor-rules"),
255
+ ];
256
+ const sourceDir = candidates.find((p) => fs.existsSync(p));
257
+ if (!sourceDir) {
258
+ console.log(fmt.warning(`Cursor rule templates not found in resources/cursor-rules`));
259
+ return;
260
+ }
261
+ for (const name of CURSOR_RULE_SKILLS) {
262
+ const src = path.join(sourceDir, `${name}.mdc`);
263
+ const dest = path.join(rulesDir, `${name}.mdc`);
264
+ if (!fs.existsSync(src)) {
265
+ console.log(fmt.warning(`Cursor rule template missing: ${src}`));
266
+ continue;
267
+ }
268
+ fs.copyFileSync(src, dest);
269
+ console.log(fmt.success(`Installed Cursor rule to ${dest}`));
270
+ }
271
+ // Remove the legacy consolidated rule if present, so reinstall on top of
272
+ // an old layout migrates cleanly.
273
+ const legacy = path.join(rulesDir, "rafter-security.mdc");
274
+ if (fs.existsSync(legacy)) {
275
+ try {
276
+ fs.unlinkSync(legacy);
277
+ console.log(fmt.info(`Removed legacy ${legacy} (superseded by per-skill rules)`));
278
+ }
279
+ catch {
280
+ /* best-effort */
281
+ }
282
+ }
283
+ }
284
+ /**
285
+ * Install Cursor sub-agent at <root>/.cursor/agents/rafter.md.
286
+ *
287
+ * Reuses the Claude-Code sub-agent body (rf-q7j) with one shape difference:
288
+ * Cursor's frontmatter has no `tools:` field — tools inherit from the parent
289
+ * agent. The hard rules in the body (no code modification, no commits) still
290
+ * apply, since Cursor relies on prompt-level constraints rather than
291
+ * structural restriction.
292
+ */
293
+ function installCursorSubAgents(root) {
294
+ const agentsDir = path.join(root, ".cursor", "agents");
295
+ fs.mkdirSync(agentsDir, { recursive: true });
296
+ const candidates = [
297
+ path.resolve(__dirname, "..", "..", "..", "resources", "agents", "rafter.md"),
298
+ path.resolve(__dirname, "..", "..", "resources", "agents", "rafter.md"),
299
+ ];
300
+ const src = candidates.find((p) => fs.existsSync(p));
301
+ if (!src) {
302
+ console.log(fmt.warning(`Rafter sub-agent template not found in resources/agents`));
303
+ return;
304
+ }
305
+ const raw = fs.readFileSync(src, "utf-8");
306
+ const cursored = stripToolsFromFrontmatter(raw);
307
+ const dest = path.join(agentsDir, "rafter.md");
308
+ fs.writeFileSync(dest, cursored, "utf-8");
309
+ console.log(fmt.success(`Installed Cursor sub-agent to ${dest}`));
310
+ }
311
+ /** Strip the Claude-Code `tools:` line from sub-agent frontmatter — Cursor doesn't have it. */
312
+ function stripToolsFromFrontmatter(content) {
313
+ if (!content.startsWith("---\n"))
314
+ return content;
315
+ const fmEnd = content.indexOf("\n---", 4);
316
+ if (fmEnd === -1)
317
+ return content;
318
+ const frontmatter = content.slice(4, fmEnd);
319
+ const body = content.slice(fmEnd);
320
+ const cleaned = frontmatter
321
+ .split("\n")
322
+ .filter((line) => !/^tools:\s/.test(line))
323
+ .join("\n");
324
+ return `---\n${cleaned}${body}`;
325
+ }
209
326
  function installGeminiHooks(root) {
210
327
  const geminiDir = path.join(root, ".gemini");
211
328
  if (!fs.existsSync(geminiDir)) {
@@ -230,8 +347,12 @@ function installGeminiHooks(root) {
230
347
  // Remove existing rafter hooks
231
348
  settings.hooks.BeforeTool = settings.hooks.BeforeTool.filter((entry) => !(entry.hooks || []).some((h) => h.command?.includes("rafter hook pretool")));
232
349
  settings.hooks.AfterTool = settings.hooks.AfterTool.filter((entry) => !(entry.hooks || []).some((h) => h.command?.includes("rafter hook posttool")));
350
+ // Gemini matchers are regexes against built-in tool names per
351
+ // geminicli.com/docs/hooks/reference. Match the mutating tools by name
352
+ // explicitly: run_shell_command, write_file, replace, edit. (rf-044o
353
+ // verification 2026-05-03 — schema confirmed against current Gemini docs.)
233
354
  settings.hooks.BeforeTool.push({
234
- matcher: "shell|write_file",
355
+ matcher: "run_shell_command|write_file|replace|edit",
235
356
  hooks: [{ type: "command", command: "rafter hook pretool --format gemini", timeout: 5000 }],
236
357
  });
237
358
  settings.hooks.AfterTool.push({
@@ -241,71 +362,47 @@ function installGeminiHooks(root) {
241
362
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf-8");
242
363
  console.log(fmt.success(`Installed hooks to ${settingsPath}`));
243
364
  }
244
- function installWindsurfHooks(root) {
245
- const windsurfDir = path.join(root, ".windsurf");
246
- if (!fs.existsSync(windsurfDir)) {
247
- fs.mkdirSync(windsurfDir, { recursive: true });
248
- }
249
- const hooksPath = path.join(windsurfDir, "hooks.json");
250
- let config = {};
251
- if (fs.existsSync(hooksPath)) {
252
- try {
253
- config = JSON.parse(fs.readFileSync(hooksPath, "utf-8"));
254
- }
255
- catch {
256
- console.log(fmt.warning("Existing Windsurf hooks.json was unreadable, creating new one"));
257
- }
258
- }
259
- if (!config.hooks)
260
- config.hooks = {};
261
- if (!config.hooks.pre_run_command)
262
- config.hooks.pre_run_command = [];
263
- if (!config.hooks.pre_write_code)
264
- config.hooks.pre_write_code = [];
265
- // Remove existing rafter hooks
266
- config.hooks.pre_run_command = config.hooks.pre_run_command.filter((entry) => !entry.command?.includes("rafter hook pretool"));
267
- config.hooks.pre_write_code = config.hooks.pre_write_code.filter((entry) => !entry.command?.includes("rafter hook pretool"));
268
- config.hooks.pre_run_command.push({
269
- command: "rafter hook pretool --format windsurf",
270
- show_output: true,
271
- });
272
- config.hooks.pre_write_code.push({
273
- command: "rafter hook pretool --format windsurf",
274
- show_output: true,
275
- });
276
- fs.writeFileSync(hooksPath, JSON.stringify(config, null, 2), "utf-8");
277
- console.log(fmt.success(`Installed hooks to ${hooksPath}`));
278
- }
279
- function installContinueDevHooks(root) {
280
- const continueDir = path.join(root, ".continue");
281
- if (!fs.existsSync(continueDir)) {
282
- fs.mkdirSync(continueDir, { recursive: true });
365
+ /** Skills shipped as Windsurf per-workspace rules at .windsurf/rules/<skill>.md (rf-0vr3). */
366
+ const WINDSURF_RULE_SKILLS = [
367
+ "rafter",
368
+ "rafter-secure-design",
369
+ "rafter-code-review",
370
+ "rafter-skill-review",
371
+ ];
372
+ /**
373
+ * Install per-skill Windsurf rules at <root>/.windsurf/rules/<skill>.md.
374
+ *
375
+ * Windsurf reads workspace rules from .windsurf/rules/*.md (12KB cap per file
376
+ * per docs). Each file uses Windsurf YAML frontmatter (`trigger: model_decision`
377
+ * + `description:`) so the agent fetches the rule when its description matches
378
+ * the task. Body content mirrors the Cursor pointer-rule pattern.
379
+ *
380
+ * Replaces the prior `~/.windsurf/hooks.json` install, which was a silent
381
+ * no-op — Windsurf has no documented hook surface as of v1.x (research bead
382
+ * rf-s1n3, gap reports rf-p1ri / rf-vayl).
383
+ */
384
+ function installWindsurfRules(root) {
385
+ const rulesDir = path.join(root, ".windsurf", "rules");
386
+ fs.mkdirSync(rulesDir, { recursive: true });
387
+ const candidates = [
388
+ path.resolve(__dirname, "..", "..", "..", "resources", "windsurf-rules"),
389
+ path.resolve(__dirname, "..", "..", "resources", "windsurf-rules"),
390
+ ];
391
+ const sourceDir = candidates.find((p) => fs.existsSync(p));
392
+ if (!sourceDir) {
393
+ console.log(fmt.warning(`Windsurf rule templates not found in resources/windsurf-rules`));
394
+ return;
283
395
  }
284
- const settingsPath = path.join(continueDir, "settings.json");
285
- let settings = {};
286
- if (fs.existsSync(settingsPath)) {
287
- try {
288
- settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
289
- }
290
- catch {
291
- console.log(fmt.warning("Existing Continue.dev settings.json was unreadable, creating new one"));
396
+ for (const name of WINDSURF_RULE_SKILLS) {
397
+ const src = path.join(sourceDir, `${name}.md`);
398
+ const dest = path.join(rulesDir, `${name}.md`);
399
+ if (!fs.existsSync(src)) {
400
+ console.log(fmt.warning(`Windsurf rule template missing: ${src}`));
401
+ continue;
292
402
  }
403
+ fs.copyFileSync(src, dest);
404
+ console.log(fmt.success(`Installed Windsurf rule to ${dest}`));
293
405
  }
294
- if (!settings.hooks)
295
- settings.hooks = {};
296
- if (!settings.hooks.PreToolUse)
297
- settings.hooks.PreToolUse = [];
298
- if (!settings.hooks.PostToolUse)
299
- settings.hooks.PostToolUse = [];
300
- // Continue.dev uses the same protocol as Claude Code
301
- const preHook = { type: "command", command: "rafter hook pretool" };
302
- const postHook = { type: "command", command: "rafter hook posttool" };
303
- settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter((entry) => !(entry.hooks || []).some((h) => h.command?.startsWith("rafter hook pretool")));
304
- settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter((entry) => !(entry.hooks || []).some((h) => h.command?.startsWith("rafter hook posttool")));
305
- settings.hooks.PreToolUse.push({ matcher: "Bash", hooks: [preHook] }, { matcher: "Write|Edit", hooks: [preHook] });
306
- settings.hooks.PostToolUse.push({ matcher: ".*", hooks: [postHook] });
307
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf-8");
308
- console.log(fmt.success(`Installed hooks to ${settingsPath}`));
309
406
  }
310
407
  /** MCP server entry for rafter — shared across MCP-native clients */
311
408
  const RAFTER_MCP_ENTRY = {
@@ -409,6 +506,46 @@ function installWindsurfMcp(root) {
409
506
  console.log(fmt.success(`Installed Rafter MCP server to ${mcpPath}`));
410
507
  return true;
411
508
  }
509
+ /** Skills shipped as Continue.dev rules at .continue/rules/<skill>.md (rf-acz0). */
510
+ const CONTINUE_RULE_SKILLS = [
511
+ "rafter",
512
+ "rafter-secure-design",
513
+ "rafter-code-review",
514
+ "rafter-skill-review",
515
+ ];
516
+ /**
517
+ * Install per-skill Continue.dev rules at <root>/.continue/rules/<skill>.md.
518
+ *
519
+ * Continue.dev reads workspace rules from .continue/rules/*.md (per-rule files,
520
+ * lexicographic load order). Frontmatter: `name`, `description`, `alwaysApply`.
521
+ * Each rule body mirrors the Cursor / Windsurf pointer-rule pattern.
522
+ *
523
+ * Continue.dev has no documented hook surface (the prior hooks install was
524
+ * pruned in rf-cia phase b). Rules + MCP are the only intercepts.
525
+ */
526
+ function installContinueDevRules(root) {
527
+ const rulesDir = path.join(root, ".continue", "rules");
528
+ fs.mkdirSync(rulesDir, { recursive: true });
529
+ const candidates = [
530
+ path.resolve(__dirname, "..", "..", "..", "resources", "continue-rules"),
531
+ path.resolve(__dirname, "..", "..", "resources", "continue-rules"),
532
+ ];
533
+ const sourceDir = candidates.find((p) => fs.existsSync(p));
534
+ if (!sourceDir) {
535
+ console.log(fmt.warning(`Continue.dev rule templates not found in resources/continue-rules`));
536
+ return;
537
+ }
538
+ for (const name of CONTINUE_RULE_SKILLS) {
539
+ const src = path.join(sourceDir, `${name}.md`);
540
+ const dest = path.join(rulesDir, `${name}.md`);
541
+ if (!fs.existsSync(src)) {
542
+ console.log(fmt.warning(`Continue.dev rule template missing: ${src}`));
543
+ continue;
544
+ }
545
+ fs.copyFileSync(src, dest);
546
+ console.log(fmt.success(`Installed Continue.dev rule to ${dest}`));
547
+ }
548
+ }
412
549
  /**
413
550
  * Install MCP server config for Continue.dev (~/.continue/config.json)
414
551
  */
@@ -447,50 +584,129 @@ function installContinueDevMcp(root) {
447
584
  return true;
448
585
  }
449
586
  /**
450
- * Install MCP config for Aider (~/.aider.conf.yml)
451
- * Aider uses YAML config with mcpServers list
587
+ * Install Rafter context for Aider (rf-du2o).
588
+ *
589
+ * Aider has no native MCP support and no plugin/hook surface. Its only
590
+ * intercept-friendly persistent-context primitive is the `read:` flag in
591
+ * `.aider.conf.yml`, which injects read-only files into every session.
592
+ *
593
+ * Behavior:
594
+ * 1. Write `<root>/RAFTER.md` with the rafter instruction block.
595
+ * 2. Update `<root>/.aider.conf.yml` so `read:` includes `RAFTER.md`
596
+ * (preserves any pre-existing `read:` entries; preserves other YAML keys).
597
+ * 3. Strip the legacy `mcp-server-command: rafter mcp serve` line if present
598
+ * — it was a silent no-op in earlier rafter versions (Aider has no MCP).
599
+ *
600
+ * Returns true on success.
452
601
  */
453
- function installAiderMcp(root) {
602
+ function installAiderRead(root) {
603
+ const rafterMdPath = path.join(root, "RAFTER.md");
454
604
  const configPath = path.join(root, ".aider.conf.yml");
455
- // Aider's YAML config is simple — we append the MCP flag if not present
456
- let content = "";
605
+ const RAFTER_READ_ENTRY = "RAFTER.md";
606
+ // 1. Write RAFTER.md (idempotent — the marker block is replaced in place).
607
+ injectInstructionFile(rafterMdPath);
608
+ // 2. Update .aider.conf.yml read: list.
609
+ let raw = "";
457
610
  if (fs.existsSync(configPath)) {
458
- content = fs.readFileSync(configPath, "utf-8");
611
+ raw = fs.readFileSync(configPath, "utf-8");
459
612
  }
460
- // Check if rafter MCP is already configured
461
- if (content.includes("rafter mcp serve")) {
462
- console.log(fmt.success("Rafter MCP already configured in Aider config"));
463
- return true;
613
+ // 2a. Strip the legacy mcp-server-command line(s). Match a contiguous block
614
+ // that may include the preceding `# Rafter security MCP server` comment.
615
+ raw = raw.replace(/\n?#\s*Rafter security MCP server\s*\nmcp-server-command:\s*rafter\s+mcp\s+serve\s*\n?/g, "\n");
616
+ raw = raw.replace(/^mcp-server-command:\s*rafter\s+mcp\s+serve\s*\n?/gm, "");
617
+ // 2b. Parse remaining YAML, normalize read:.
618
+ let parsed = {};
619
+ if (raw.trim().length > 0) {
620
+ try {
621
+ const loaded = yaml.load(raw);
622
+ if (loaded && typeof loaded === "object" && !Array.isArray(loaded)) {
623
+ parsed = loaded;
624
+ }
625
+ }
626
+ catch {
627
+ console.log(fmt.warning(`Existing ${configPath} was not valid YAML — preserving raw content and appending read: entry`));
628
+ // Append the read: line at the bottom rather than rewriting an
629
+ // unparseable file. We still need to make sure RAFTER.md ends up in it.
630
+ if (!new RegExp(`^read:\\s*\\[?[^\\n]*\\b${RAFTER_READ_ENTRY}\\b`, "m").test(raw)) {
631
+ const sep = raw.length > 0 && !raw.endsWith("\n") ? "\n" : "";
632
+ raw = `${raw}${sep}read:\n - ${RAFTER_READ_ENTRY}\n`;
633
+ fs.writeFileSync(configPath, raw, "utf-8");
634
+ }
635
+ console.log(fmt.success(`Installed Rafter read-only context to ${configPath}`));
636
+ return true;
637
+ }
464
638
  }
465
- // Append MCP server config
466
- const mcpLine = "\n# Rafter security MCP server\nmcp-server-command: rafter mcp serve\n";
467
- fs.writeFileSync(configPath, content + mcpLine, "utf-8");
468
- console.log(fmt.success(`Installed Rafter MCP server to ${configPath}`));
639
+ // Normalize `read:` to a string array.
640
+ let reads = [];
641
+ if (Array.isArray(parsed.read)) {
642
+ reads = parsed.read.map(String);
643
+ }
644
+ else if (typeof parsed.read === "string") {
645
+ reads = [parsed.read];
646
+ }
647
+ if (!reads.includes(RAFTER_READ_ENTRY)) {
648
+ reads.push(RAFTER_READ_ENTRY);
649
+ }
650
+ parsed.read = reads;
651
+ fs.writeFileSync(configPath, yaml.dump(parsed), "utf-8");
652
+ console.log(fmt.success(`Installed Rafter read-only context to ${rafterMdPath} + ${configPath}`));
469
653
  return true;
470
654
  }
471
- function installSkillsTo(skillsDir) {
472
- if (!fs.existsSync(skillsDir)) {
473
- fs.mkdirSync(skillsDir, { recursive: true });
655
+ // Copies an entire skill source directory (SKILL.md + any subfolders like docs/)
656
+ // into the destination. Without this, `docs/` reference material referenced from
657
+ // SKILL.md would never reach users — only SKILL.md would.
658
+ function installSkillDir(sourceSkillDir, destSkillDir, label) {
659
+ const sourceSkillFile = path.join(sourceSkillDir, "SKILL.md");
660
+ if (!fs.existsSync(sourceSkillFile)) {
661
+ console.log(fmt.warning(`${label} skill template not found at ${sourceSkillFile}`));
662
+ return;
474
663
  }
664
+ fs.mkdirSync(destSkillDir, { recursive: true });
665
+ fs.cpSync(sourceSkillDir, destSkillDir, { recursive: true });
666
+ console.log(fmt.success(`Installed ${label} skill to ${destSkillDir}`));
667
+ }
668
+ function skillResourceDir(name) {
669
+ return path.join(__dirname, "..", "..", "..", "resources", "skills", name);
670
+ }
671
+ function installSkillsTo(skillsDir) {
672
+ fs.mkdirSync(skillsDir, { recursive: true });
475
673
  for (const skill of AGENT_SKILLS) {
476
- const destDir = path.join(skillsDir, skill.name);
477
- const destPath = path.join(destDir, "SKILL.md");
478
- const srcPath = path.join(__dirname, "..", "..", "..", "resources", "skills", skill.name, "SKILL.md");
479
- if (!fs.existsSync(destDir)) {
480
- fs.mkdirSync(destDir, { recursive: true });
481
- }
674
+ installSkillDir(skillResourceDir(skill.name), path.join(skillsDir, skill.name), skill.description);
675
+ }
676
+ }
677
+ async function installClaudeCodeSkills(root) {
678
+ installSkillsTo(path.join(root, ".claude", "skills"));
679
+ }
680
+ /**
681
+ * Sub-agents shipped by `rafter agent init --with-claude-code`.
682
+ *
683
+ * These land in <root>/.claude/agents/<name>.md and become first-class
684
+ * delegation targets (Agent(subagent_type='<name>')) in the calling Claude
685
+ * Code session — distinct from skills, which only surface in the activation
686
+ * prompt. Source files live in `resources/agents/<name>.md`.
687
+ *
688
+ * Keep this list in sync with the Python installer.
689
+ */
690
+ const CLAUDE_CODE_SUBAGENTS = [
691
+ { name: "rafter", description: "Rafter Security" },
692
+ ];
693
+ function installClaudeCodeSubAgents(root) {
694
+ const agentsDir = path.join(root, ".claude", "agents");
695
+ if (!fs.existsSync(agentsDir)) {
696
+ fs.mkdirSync(agentsDir, { recursive: true });
697
+ }
698
+ for (const sub of CLAUDE_CODE_SUBAGENTS) {
699
+ const destPath = path.join(agentsDir, `${sub.name}.md`);
700
+ const srcPath = path.join(__dirname, "..", "..", "..", "resources", "agents", `${sub.name}.md`);
482
701
  if (fs.existsSync(srcPath)) {
483
702
  fs.copyFileSync(srcPath, destPath);
484
- console.log(fmt.success(`Installed ${skill.description} skill to ${destPath}`));
703
+ console.log(fmt.success(`Installed ${sub.description} sub-agent to ${destPath}`));
485
704
  }
486
705
  else {
487
- console.log(fmt.warning(`${skill.description} skill template not found at ${srcPath}`));
706
+ console.log(fmt.warning(`${sub.description} sub-agent template not found at ${srcPath}`));
488
707
  }
489
708
  }
490
709
  }
491
- async function installClaudeCodeSkills(root) {
492
- installSkillsTo(path.join(root, ".claude", "skills"));
493
- }
494
710
  function installCodexSkills(root) {
495
711
  installSkillsTo(path.join(root, ".agents", "skills"));
496
712
  }
@@ -598,14 +814,25 @@ export function createInitCommand() {
598
814
  // Resolve opt-in flags (--all enables all detected, --interactive prompts).
599
815
  // In --local scope, --all is restricted to platforms that have a project-local
600
816
  // config story (claudeCode, codex, gemini, cursor). The rest require user scope.
817
+ // OpenClaw returns to --all in rf-zgwj — the integration was rebuilt
818
+ // to ship a ClawHub-shaped skill at the canonical workspace path
819
+ // (~/.openclaw/workspace/skills/rafter-security/SKILL.md), so OpenClaw
820
+ // actually auto-discovers it now. (User-scope only; --local doesn't
821
+ // apply since the platform is user-config-driven.)
601
822
  let wantOpenClaw = opts.withOpenclaw || (opts.all && !opts.local);
602
823
  let wantClaudeCode = opts.withClaudeCode || opts.all;
603
824
  let wantCodex = opts.withCodex || opts.all;
604
825
  let wantGemini = opts.withGemini || opts.all;
605
826
  let wantCursor = opts.withCursor || opts.all;
606
- let wantWindsurf = opts.withWindsurf || (opts.all && !opts.local);
607
- let wantContinue = opts.withContinue || (opts.all && !opts.local);
608
- let wantAider = opts.withAider || (opts.all && !opts.local);
827
+ // Windsurf can install at --local scope (project rules + AGENTS.md)
828
+ // since rf-0vr3. User scope still also installs the MCP entry.
829
+ let wantWindsurf = opts.withWindsurf || opts.all;
830
+ // Continue.dev can install at --local scope (project rules) since rf-acz0.
831
+ // User scope additionally registers the MCP entry.
832
+ let wantContinue = opts.withContinue || opts.all;
833
+ // Aider can install at --local scope (writes RAFTER.md + .aider.conf.yml
834
+ // in cwd) since rf-du2o.
835
+ let wantAider = opts.withAider || opts.all;
609
836
  let wantGitleaks = opts.withGitleaks || (opts.all && !opts.local);
610
837
  // Interactive mode: prompt for each detected integration
611
838
  if (opts.interactive && !opts.all) {
@@ -625,7 +852,7 @@ export function createInitCommand() {
625
852
  if (hasWindsurf && !wantWindsurf)
626
853
  wantWindsurf = await askYesNo("Install Windsurf MCP + hooks?");
627
854
  if (hasContinueDev && !wantContinue)
628
- wantContinue = await askYesNo("Install Continue.dev MCP + hooks?");
855
+ wantContinue = await askYesNo("Install Continue.dev MCP server?");
629
856
  if (hasAider && !wantAider)
630
857
  wantAider = await askYesNo("Install Aider MCP server?");
631
858
  if (!wantGitleaks)
@@ -793,6 +1020,7 @@ export function createInitCommand() {
793
1020
  if ((hasClaudeCode || opts.withClaudeCode || (opts.local && wantClaudeCode)) && wantClaudeCode) {
794
1021
  try {
795
1022
  await installClaudeCodeSkills(root);
1023
+ installClaudeCodeSubAgents(root);
796
1024
  installClaudeCodeHooks(root);
797
1025
  if (scope === "project") {
798
1026
  const components = (manager.get("agent.components") ?? {});
@@ -842,12 +1070,14 @@ export function createInitCommand() {
842
1070
  console.error(fmt.error(`Failed to install Gemini CLI integration: ${e}`));
843
1071
  }
844
1072
  }
845
- // Install Cursor MCP + hooks if opted in
1073
+ // Install Cursor MCP + hooks + per-skill rules + sub-agent if opted in
846
1074
  let cursorOk = false;
847
1075
  if ((hasCursor || (opts.local && wantCursor)) && wantCursor) {
848
1076
  try {
849
1077
  cursorOk = installCursorMcp(root);
850
1078
  installCursorHooks(root);
1079
+ installCursorRules(root);
1080
+ installCursorSubAgents(root);
851
1081
  if (cursorOk && scope === "user")
852
1082
  manager.set("agent.environments.cursor.enabled", true);
853
1083
  }
@@ -855,59 +1085,78 @@ export function createInitCommand() {
855
1085
  console.error(fmt.error(`Failed to install Cursor integration: ${e}`));
856
1086
  }
857
1087
  }
858
- // Install Windsurf MCP + hooks if opted in
1088
+ // Install Windsurf integration if opted in.
1089
+ // - User scope: MCP entry under ~/.codeium/windsurf/ + per-skill rules
1090
+ // at .windsurf/rules/ (workspace) + AGENTS.md (workspace, written by
1091
+ // installGlobalInstructions below).
1092
+ // - Project scope (--local): per-skill rules + AGENTS.md only (no
1093
+ // user-scope MCP entry written from a project init).
1094
+ // The previous ~/.windsurf/hooks.json install was pruned: Windsurf has
1095
+ // no documented hook surface (rf-0vr3).
859
1096
  let windsurfOk = false;
860
- if (hasWindsurf && wantWindsurf) {
1097
+ if (wantWindsurf && (hasWindsurf || opts.local)) {
861
1098
  try {
862
- windsurfOk = installWindsurfMcp(root);
863
- installWindsurfHooks(root);
864
- if (windsurfOk)
865
- manager.set("agent.environments.windsurf.enabled", true);
1099
+ if (hasWindsurf) {
1100
+ windsurfOk = installWindsurfMcp(root);
1101
+ if (windsurfOk)
1102
+ manager.set("agent.environments.windsurf.enabled", true);
1103
+ }
1104
+ installWindsurfRules(root);
1105
+ // AGENTS.md is written below in installGlobalInstructions when
1106
+ // platforms.windsurf is true.
1107
+ if (!hasWindsurf)
1108
+ windsurfOk = true; // local-scope success: rules + AGENTS.md
866
1109
  }
867
1110
  catch (e) {
868
1111
  console.error(fmt.error(`Failed to install Windsurf integration: ${e}`));
869
1112
  }
870
1113
  }
871
- else if (opts.local && wantWindsurf) {
872
- localUnsupported("Windsurf");
873
- }
874
- // Install Continue.dev MCP + hooks if opted in
1114
+ // Install Continue.dev integration if opted in (rf-acz0).
1115
+ // - User scope: per-skill rules (.continue/rules/) + MCP entry under
1116
+ // ~/.continue/config.json.
1117
+ // - Project scope (--local): rules only.
1118
+ // Continue.dev has no hook surface — the prior hooks install was pruned
1119
+ // in rf-cia phase b.
875
1120
  let continueOk = false;
876
- if (hasContinueDev && wantContinue) {
1121
+ if (wantContinue && (hasContinueDev || opts.local)) {
877
1122
  try {
878
- continueOk = installContinueDevMcp(root);
879
- installContinueDevHooks(root);
880
- if (continueOk)
881
- manager.set("agent.environments.continueDev.enabled", true);
1123
+ if (hasContinueDev) {
1124
+ continueOk = installContinueDevMcp(root);
1125
+ if (continueOk)
1126
+ manager.set("agent.environments.continueDev.enabled", true);
1127
+ }
1128
+ installContinueDevRules(root);
1129
+ if (!hasContinueDev)
1130
+ continueOk = true; // local-scope success: rules only
882
1131
  }
883
1132
  catch (e) {
884
1133
  console.error(fmt.error(`Failed to install Continue.dev integration: ${e}`));
885
1134
  }
886
1135
  }
887
- else if (opts.local && wantContinue) {
888
- localUnsupported("Continue.dev");
889
- }
890
- // Install Aider MCP if opted in
1136
+ // Install Aider integration if opted in (rf-du2o).
1137
+ // Aider has no MCP and no hook surface — its only intercept is the
1138
+ // `read:` flag in .aider.conf.yml. We write RAFTER.md and ensure
1139
+ // `read:` includes it. The legacy mcp-server-command YAML line (a
1140
+ // silent no-op) is stripped on reinstall.
891
1141
  let aiderOk = false;
892
- if (hasAider && wantAider) {
1142
+ if (wantAider && (hasAider || opts.local)) {
893
1143
  try {
894
- aiderOk = installAiderMcp(root);
895
- if (aiderOk)
1144
+ aiderOk = installAiderRead(root);
1145
+ if (aiderOk && hasAider)
896
1146
  manager.set("agent.environments.aider.enabled", true);
897
1147
  }
898
1148
  catch (e) {
899
1149
  console.error(fmt.error(`Failed to install Aider integration: ${e}`));
900
1150
  }
901
1151
  }
902
- else if (opts.local && wantAider) {
903
- localUnsupported("Aider");
904
- }
905
- // Install global instruction files for platforms that support them
1152
+ // Install global instruction files for platforms that support them.
1153
+ // Cursor is intentionally absent — Cursor uses per-skill rules + the
1154
+ // rafter sub-agent installed in the Cursor branch above (rf-svn3).
906
1155
  installGlobalInstructions({
907
1156
  claudeCode: claudeCodeOk,
908
1157
  codex: codexOk,
909
1158
  gemini: geminiOk,
910
- cursor: cursorOk,
1159
+ windsurf: windsurfOk,
911
1160
  }, root, scope);
912
1161
  console.log();
913
1162
  console.log(fmt.success("Agent security initialized!"));
@@ -930,7 +1179,7 @@ export function createInitCommand() {
930
1179
  if (continueOk)
931
1180
  console.log(" - Restart Continue.dev to load MCP server");
932
1181
  if (aiderOk)
933
- console.log(" - Restart Aider to load MCP server");
1182
+ console.log(" - Restart Aider to load RAFTER.md from .aider.conf.yml read:");
934
1183
  }
935
1184
  else if (scope === "project") {
936
1185
  console.log("No integrations were installed. In --local mode, pass one or more opt-in flags:");