scc-universal 1.1.0 → 1.2.0

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 (39) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/.cursor/hooks.json +105 -64
  3. package/.cursor/skills/configure-scc/SKILL.md +20 -20
  4. package/.cursor/skills/mcp-server-patterns/SKILL.md +1 -1
  5. package/.cursor/skills/sf-harness-audit/SKILL.md +6 -6
  6. package/.cursor/skills/sf-quickstart/SKILL.md +7 -7
  7. package/.cursor-plugin/plugin.json +2 -2
  8. package/README.md +51 -37
  9. package/docs/ARCHITECTURE.md +4 -4
  10. package/docs/authoring-guide.md +2 -2
  11. package/docs/workflow-examples.md +38 -38
  12. package/hooks/hooks.json +56 -71
  13. package/manifests/install-modules.json +5 -3
  14. package/package.json +4 -3
  15. package/schemas/hooks.schema.json +83 -72
  16. package/schemas/plugin.schema.json +59 -21
  17. package/scripts/cli/install-apply.js +9 -9
  18. package/scripts/hooks/doc-file-warning.js +3 -1
  19. package/scripts/hooks/governor-check.js +3 -2
  20. package/scripts/hooks/post-bash-build-complete.js +3 -2
  21. package/scripts/hooks/post-bash-pr-created.js +4 -2
  22. package/scripts/hooks/post-edit-console-warn.js +3 -1
  23. package/scripts/hooks/post-edit-format.js +3 -2
  24. package/scripts/hooks/post-edit-typecheck.js +3 -2
  25. package/scripts/hooks/post-write.js +3 -1
  26. package/scripts/hooks/pre-bash-git-push-reminder.js +3 -2
  27. package/scripts/hooks/pre-bash-tmux-reminder.js +3 -1
  28. package/scripts/hooks/pre-tool-use.js +3 -1
  29. package/scripts/hooks/quality-gate.js +3 -2
  30. package/scripts/hooks/sfdx-scanner-check.js +3 -1
  31. package/scripts/hooks/sfdx-validate.js +3 -1
  32. package/scripts/lib/hook-input.js +105 -0
  33. package/scripts/lib/hooks-adapter.js +265 -0
  34. package/scripts/lib/install-executor.js +153 -1
  35. package/scripts/scc.js +14 -14
  36. package/skills/configure-scc/SKILL.md +20 -20
  37. package/skills/mcp-server-patterns/SKILL.md +1 -1
  38. package/skills/sf-harness-audit/SKILL.md +6 -6
  39. package/skills/sf-quickstart/SKILL.md +7 -7
@@ -0,0 +1,105 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * hook-input.js — Normalize hook input across Claude Code and Cursor formats.
5
+ *
6
+ * Claude Code and Cursor send different JSON structures for the same logical data:
7
+ *
8
+ * | Data | Claude Code | Cursor |
9
+ * |---------------|--------------------------------|---------------------------|
10
+ * | Shell command | tool_input.command | command |
11
+ * | File path | tool_input.file_path | file_path |
12
+ * | Shell output | tool_output (stringified JSON) | output (raw text) |
13
+ * | File edits | N/A | edits[] |
14
+ * | Duration | N/A | duration (ms) |
15
+ * | Sandbox | N/A | sandbox (boolean) |
16
+ * | Prompt text | prompt | prompt |
17
+ * | Tool name | tool_name | tool_name |
18
+ * | Working dir | cwd | cwd |
19
+ *
20
+ * This module provides a single normalizeInput() function that merges both
21
+ * formats into a consistent shape. Scripts import this instead of parsing
22
+ * raw input directly.
23
+ */
24
+
25
+ /**
26
+ * Normalize hook input from either Claude Code or Cursor format.
27
+ *
28
+ * @param {object} raw - The parsed JSON input from stdin
29
+ * @returns {object} Normalized input with consistent field names
30
+ */
31
+ function normalizeInput(raw) {
32
+ if (!raw || typeof raw !== 'object') {
33
+ return {
34
+ filePath: '',
35
+ command: '',
36
+ toolName: '',
37
+ cwd: '',
38
+ output: '',
39
+ edits: [],
40
+ duration: 0,
41
+ sandbox: false,
42
+ prompt: '',
43
+ raw: raw || {},
44
+ };
45
+ }
46
+
47
+ return {
48
+ // File path: Cursor sends top-level, Claude Code nests under tool_input
49
+ filePath: raw.file_path || (raw.tool_input && raw.tool_input.file_path) || '',
50
+
51
+ // Shell command: Cursor sends top-level, Claude Code nests under tool_input
52
+ command: raw.command || (raw.tool_input && raw.tool_input.command) || '',
53
+
54
+ // Tool name: same field in both harnesses
55
+ toolName: raw.tool_name || '',
56
+
57
+ // Working directory: same field in both harnesses
58
+ cwd: raw.cwd || '',
59
+
60
+ // Shell output: Cursor sends 'output' (string), Claude Code sends 'tool_output'
61
+ // tool_output can be a string or object { output: '...' }
62
+ output: raw.output
63
+ || (typeof raw.tool_output === 'string' ? raw.tool_output : '')
64
+ || (raw.tool_output && raw.tool_output.output) || '',
65
+
66
+ // File edits: Cursor afterFileEdit sends edits[], Claude Code does not
67
+ edits: raw.edits || [],
68
+
69
+ // Duration: Cursor afterShellExecution sends duration (ms)
70
+ duration: raw.duration || 0,
71
+
72
+ // Sandbox: Cursor beforeShellExecution indicates sandboxed commands
73
+ sandbox: raw.sandbox || false,
74
+
75
+ // Prompt text: same field in both harnesses (UserPromptSubmit / beforeSubmitPrompt)
76
+ prompt: raw.prompt || '',
77
+
78
+ // Preserve the full raw input for any hook-specific fields
79
+ raw,
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Read and parse JSON from stdin, then normalize.
85
+ * Convenience wrapper used by most hook scripts.
86
+ *
87
+ * @returns {Promise<object>} Normalized input
88
+ */
89
+ function readInput() {
90
+ return new Promise((resolve) => {
91
+ let data = '';
92
+ process.stdin.setEncoding('utf8');
93
+ process.stdin.on('data', (chunk) => { data += chunk; });
94
+ process.stdin.on('end', () => {
95
+ try {
96
+ const parsed = JSON.parse(data);
97
+ resolve(normalizeInput(parsed));
98
+ } catch {
99
+ resolve(normalizeInput({}));
100
+ }
101
+ });
102
+ });
103
+ }
104
+
105
+ module.exports = { normalizeInput, readInput };
@@ -0,0 +1,265 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * hooks-adapter.js — Transforms SCC hooks from Claude Code format to Cursor format.
5
+ *
6
+ * Claude Code hooks use:
7
+ * - PascalCase event names (PreToolUse, PostToolUse, SessionStart)
8
+ * - Nested structure: { matcher, hooks: [{ type, command, async, timeout }] }
9
+ * - Regex matchers on tool names
10
+ * - ${CLAUDE_PLUGIN_ROOT} path variable
11
+ * - run-with-flags.js profile gating wrapper
12
+ * - 4 hook types: command, http, prompt, agent
13
+ *
14
+ * Cursor hooks use:
15
+ * - camelCase event names (preToolUse, beforeShellExecution, afterFileEdit)
16
+ * - Flat structure: { command, timeout, matcher, failClosed, loop_limit }
17
+ * - Tool-specific events instead of matchers (beforeShellExecution vs PreToolUse+Bash)
18
+ * - Relative paths from project root
19
+ * - No profile gating
20
+ * - 2 hook types: command, prompt
21
+ * - version: 1 required
22
+ */
23
+
24
+ // ── Event mapping ────────────────────────────────────────────────────────────
25
+
26
+ /**
27
+ * Map Claude Code event+matcher → Cursor event.
28
+ * Some Claude Code events with tool matchers map to Cursor-specific events.
29
+ */
30
+ const EVENT_MAP = {
31
+ SessionStart: 'sessionStart',
32
+ SessionEnd: 'sessionEnd',
33
+ PreCompact: 'preCompact',
34
+ Stop: 'stop',
35
+ PostToolUseFailure: 'postToolUseFailure',
36
+ };
37
+
38
+ /**
39
+ * PreToolUse matcher → Cursor event mapping.
40
+ * Claude Code uses PreToolUse + matcher; Cursor has tool-specific events.
41
+ */
42
+ const PRE_TOOL_USE_MAP = {
43
+ Bash: 'beforeShellExecution',
44
+ };
45
+
46
+ /**
47
+ * PostToolUse matcher → Cursor event mapping.
48
+ */
49
+ const POST_TOOL_USE_MAP = {
50
+ Bash: 'afterShellExecution',
51
+ Edit: 'afterFileEdit',
52
+ Write: 'afterFileEdit',
53
+ };
54
+
55
+ // Cursor hook types (command and prompt only; http and agent are Claude Code only)
56
+ const CURSOR_SUPPORTED_TYPES = new Set(['command', 'prompt']);
57
+
58
+ // ── Path remapping ───────────────────────────────────────────────────────────
59
+
60
+ /**
61
+ * Strip the run-with-flags.js wrapper and extract the actual script command.
62
+ * Claude Code: node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js" <id> <profile> "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/<script>.js"
63
+ * Cursor: node scripts/hooks/<script>.js
64
+ *
65
+ * Also handles run-with-flags-shell.sh for bash hooks.
66
+ */
67
+ function remapCommand(command) {
68
+ // Pattern 1: node "...run-with-flags.js" <id> <profile> ".../<script>.js"
69
+ const runWithFlagsMatch = command.match(
70
+ /node\s+"?\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/hooks\/run-with-flags\.js"?\s+\S+\s+\S+\s+"?\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/hooks\/([^"]+)"?/
71
+ );
72
+ if (runWithFlagsMatch) {
73
+ return `node scripts/hooks/${runWithFlagsMatch[1]}`;
74
+ }
75
+
76
+ // Pattern 2: bash "...run-with-flags-shell.sh" <id> "scripts/hooks/<script>" <profiles>
77
+ const shellFlagsMatch = command.match(
78
+ /bash\s+"?\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/hooks\/run-with-flags-shell\.sh"?\s+\S+\s+"?scripts\/hooks\/([^"]+)"?\s+\S+/
79
+ );
80
+ if (shellFlagsMatch) {
81
+ return `bash scripts/hooks/${shellFlagsMatch[1]}`;
82
+ }
83
+
84
+ // Pattern 3: Direct node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/<script>.js"
85
+ const directMatch = command.match(
86
+ /node\s+"?\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/hooks\/([^"]+)"?/
87
+ );
88
+ if (directMatch) {
89
+ return `node scripts/hooks/${directMatch[1]}`;
90
+ }
91
+
92
+ // Pattern 4: npx commands (pass through as-is)
93
+ if (command.startsWith('npx ')) {
94
+ return command;
95
+ }
96
+
97
+ // Fallback: replace ${CLAUDE_PLUGIN_ROOT}/ with empty
98
+ return command.replace(/\$\{CLAUDE_PLUGIN_ROOT\}\//g, '');
99
+ }
100
+
101
+ // ── Security classification ──────────────────────────────────────────────────
102
+
103
+ /**
104
+ * Determine if a hook should use failClosed: true in Cursor.
105
+ * Security-critical hooks should block on failure rather than fail-open.
106
+ */
107
+ const FAIL_CLOSED_HOOKS = new Set([
108
+ 'mcp-health-check',
109
+ 'block-no-verify',
110
+ ]);
111
+
112
+ function shouldFailClosed(command) {
113
+ return [...FAIL_CLOSED_HOOKS].some(hook => command.includes(hook));
114
+ }
115
+
116
+ // ── Core transform ──────────────────────────────────────────────────────────
117
+
118
+ /**
119
+ * Resolve the Cursor event name for a Claude Code event + matcher combination.
120
+ */
121
+ function resolveCursorEvent(claudeEvent, matcher) {
122
+ // Direct mapping (no tool-specific routing needed)
123
+ if (EVENT_MAP[claudeEvent]) {
124
+ return EVENT_MAP[claudeEvent];
125
+ }
126
+
127
+ // PreToolUse: check if matcher maps to a Cursor-specific event
128
+ if (claudeEvent === 'PreToolUse') {
129
+ if (matcher && PRE_TOOL_USE_MAP[matcher]) {
130
+ return PRE_TOOL_USE_MAP[matcher];
131
+ }
132
+ return 'preToolUse';
133
+ }
134
+
135
+ // PostToolUse: check if matcher maps to a Cursor-specific event
136
+ if (claudeEvent === 'PostToolUse') {
137
+ if (matcher && POST_TOOL_USE_MAP[matcher]) {
138
+ return POST_TOOL_USE_MAP[matcher];
139
+ }
140
+ return 'postToolUse';
141
+ }
142
+
143
+ // Fallback: camelCase the event name
144
+ return claudeEvent.charAt(0).toLowerCase() + claudeEvent.slice(1);
145
+ }
146
+
147
+ /**
148
+ * Transform a single Claude Code hook entry to a Cursor hook entry.
149
+ * @param {object} hook - Claude Code hook { type, command, async, timeout }
150
+ * @param {string} groupMatcher - The matcher from the parent group
151
+ * @param {string} cursorEvent - The resolved Cursor event name
152
+ * @returns {object|null} - Cursor hook entry, or null if unsupported
153
+ */
154
+ function transformHookEntry(hook, groupMatcher, cursorEvent) {
155
+ // Skip unsupported hook types (http, agent)
156
+ const hookType = hook.type || 'command';
157
+ if (!CURSOR_SUPPORTED_TYPES.has(hookType)) {
158
+ return null;
159
+ }
160
+
161
+ const entry = {};
162
+
163
+ // Command (required)
164
+ if (hookType === 'command') {
165
+ entry.command = remapCommand(hook.command);
166
+ } else if (hookType === 'prompt') {
167
+ entry.type = 'prompt';
168
+ entry.prompt = hook.prompt;
169
+ }
170
+
171
+ // Timeout (if specified)
172
+ if (hook.timeout) {
173
+ entry.timeout = hook.timeout;
174
+ }
175
+
176
+ // Note: Claude Code 'async' field is dropped — Cursor does not support it.
177
+ // Claude Code 'if' field is dropped — Cursor uses matcher against command text instead.
178
+ // Claude Code 'statusMessage' is dropped — Cursor does not support it.
179
+
180
+ // failClosed for security-critical hooks
181
+ if (hookType === 'command' && shouldFailClosed(hook.command)) {
182
+ entry.failClosed = true;
183
+ }
184
+
185
+ // Matcher: only add if the Cursor event supports it AND the Claude Code
186
+ // matcher wasn't already consumed by event routing (Bash → beforeShellExecution)
187
+ const toolSpecificEvents = new Set([
188
+ 'beforeShellExecution', 'afterShellExecution',
189
+ 'afterFileEdit',
190
+ ]);
191
+
192
+ if (groupMatcher && !toolSpecificEvents.has(cursorEvent)) {
193
+ // For preToolUse/postToolUse, the matcher maps to Cursor's tool type format
194
+ // Claude Code uses "Edit|Write", Cursor uses "Write" (tool names)
195
+ const cursorMatcher = mapMatcherToCursor(groupMatcher);
196
+ if (cursorMatcher) {
197
+ entry.matcher = cursorMatcher;
198
+ }
199
+ }
200
+
201
+ // For beforeShellExecution, pass through the original matcher as command filter
202
+ if (cursorEvent === 'beforeShellExecution' && groupMatcher && groupMatcher !== 'Bash') {
203
+ entry.matcher = groupMatcher;
204
+ }
205
+
206
+ // loop_limit for stop hooks (Cursor default is 5, Claude Code default is null/unlimited)
207
+ if (cursorEvent === 'stop') {
208
+ entry.loop_limit = 3;
209
+ }
210
+
211
+ return entry;
212
+ }
213
+
214
+ /**
215
+ * Map Claude Code regex matcher to Cursor tool matcher format.
216
+ * Cursor matchers: Shell, Read, Write, Grep, Delete, Task, MCP:<tool>
217
+ */
218
+ function mapMatcherToCursor(claudeMatcher) {
219
+ if (!claudeMatcher || claudeMatcher === '') return null;
220
+
221
+ const mappings = {
222
+ Bash: 'Shell',
223
+ 'Edit|Write': 'Write',
224
+ Edit: 'Write',
225
+ Write: 'Write',
226
+ };
227
+
228
+ return mappings[claudeMatcher] || claudeMatcher;
229
+ }
230
+
231
+ /**
232
+ * Transform Claude Code hooks.json to Cursor hooks.json format.
233
+ * @param {object} claudeHooks - Parsed Claude Code hooks.json
234
+ * @returns {object} - Cursor-format hooks.json
235
+ */
236
+ function transformHooks(claudeHooks) {
237
+ const cursorHooks = {};
238
+ const sourceHooks = claudeHooks.hooks || {};
239
+
240
+ for (const [claudeEvent, groups] of Object.entries(sourceHooks)) {
241
+ for (const group of groups) {
242
+ const matcher = group.matcher || '';
243
+ const cursorEvent = resolveCursorEvent(claudeEvent, matcher);
244
+
245
+ if (!cursorHooks[cursorEvent]) {
246
+ cursorHooks[cursorEvent] = [];
247
+ }
248
+
249
+ const hooks = group.hooks || [];
250
+ for (const hook of hooks) {
251
+ const entry = transformHookEntry(hook, matcher, cursorEvent);
252
+ if (entry) {
253
+ cursorHooks[cursorEvent].push(entry);
254
+ }
255
+ }
256
+ }
257
+ }
258
+
259
+ return {
260
+ version: 1,
261
+ hooks: cursorHooks,
262
+ };
263
+ }
264
+
265
+ module.exports = { transformHooks, remapCommand, resolveCursorEvent, transformHookEntry };
@@ -14,6 +14,7 @@ const { copyFile, readJson, fileExists, simpleHash } = require('./utils');
14
14
  const { saveState } = require('./state-store');
15
15
  const { transformSkillDir } = require('./skill-adapter');
16
16
  const { transformAgentFile } = require('./agent-adapter');
17
+ const { transformHooks } = require('./hooks-adapter');
17
18
 
18
19
  const VALID_TARGETS = ['claude', 'cursor'];
19
20
  const VALID_PROFILES = ['apex', 'lwc', 'full'];
@@ -31,13 +32,14 @@ function getTargetDirs(target, projectRoot) {
31
32
  skills: path.join(projectRoot, '.claude', 'skills'),
32
33
  commands: path.join(projectRoot, '.claude', 'commands'),
33
34
  hooks: path.join(projectRoot, '.claude', 'hooks'),
35
+ settings: path.join(projectRoot, '.claude', 'settings.json'),
34
36
  };
35
37
  case 'cursor':
36
38
  return {
37
39
  agents: path.join(projectRoot, '.cursor', 'agents'),
38
40
  skills: path.join(projectRoot, '.cursor', 'skills'),
39
41
  commands: path.join(projectRoot, '.cursor', 'commands'),
40
- hooks: null,
42
+ hooks: path.join(projectRoot, '.cursor', 'hooks.json'),
41
43
  };
42
44
  default:
43
45
  throw new Error(`Unknown target: ${target}`);
@@ -197,6 +199,149 @@ function installPaths(pathsList, destDir, pluginRoot, moduleName, dryRun, target
197
199
  return installed;
198
200
  }
199
201
 
202
+ /**
203
+ * Remap hook commands from plugin paths to project-local paths.
204
+ * ${CLAUDE_PLUGIN_ROOT}/scripts/hooks/foo.js → "$CLAUDE_PROJECT_DIR"/.claude/hooks/foo.js
205
+ */
206
+ function remapHookCommandForProject(command) {
207
+ // Strip run-with-flags wrapper and extract the actual script
208
+ const runWithFlagsMatch = command.match(
209
+ /node\s+"?\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/hooks\/run-with-flags\.js"?\s+\S+\s+\S+\s+"?\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/hooks\/([^"]+)"?/
210
+ );
211
+ if (runWithFlagsMatch) {
212
+ return `node "$CLAUDE_PROJECT_DIR"/.claude/hooks/${runWithFlagsMatch[1]}`;
213
+ }
214
+
215
+ // Shell flags wrapper
216
+ const shellFlagsMatch = command.match(
217
+ /bash\s+"?\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/hooks\/run-with-flags-shell\.sh"?\s+\S+\s+"?scripts\/hooks\/([^"]+)"?\s+\S+/
218
+ );
219
+ if (shellFlagsMatch) {
220
+ return `bash "$CLAUDE_PROJECT_DIR"/.claude/hooks/${shellFlagsMatch[1]}`;
221
+ }
222
+
223
+ // Direct script reference
224
+ const directMatch = command.match(
225
+ /node\s+"?\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/hooks\/([^"]+)"?/
226
+ );
227
+ if (directMatch) {
228
+ return `node "$CLAUDE_PROJECT_DIR"/.claude/hooks/${directMatch[1]}`;
229
+ }
230
+
231
+ // npx commands pass through
232
+ if (command.startsWith('npx ')) {
233
+ return command;
234
+ }
235
+
236
+ return command.replace(/\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/hooks\//g, '"$CLAUDE_PROJECT_DIR"/.claude/hooks/');
237
+ }
238
+
239
+ /**
240
+ * Install hooks by merging into .claude/settings.json (Claude Code target)
241
+ * or generating .cursor/hooks.json (Cursor target).
242
+ *
243
+ * Claude Code reads hooks from settings.json, NOT from a separate hooks.json.
244
+ * The hooks/hooks.json format is only for plugins.
245
+ */
246
+ function installHooks(group, pluginRoot, targetName, projectRoot, moduleName, dryRun) {
247
+ const installed = [];
248
+ const hooksSourcePath = path.join(pluginRoot, group.hooksSource || 'hooks/hooks.json');
249
+
250
+ if (!fileExists(hooksSourcePath)) {
251
+ console.warn(` [WARN] Hooks source not found: ${hooksSourcePath}`);
252
+ return installed;
253
+ }
254
+
255
+ // Step 1: Copy hook scripts to target directory
256
+ const destRelative = (group.targets || {})[targetName];
257
+ if (destRelative) {
258
+ const destDir = path.join(projectRoot, destRelative);
259
+ installed.push(...installPaths(group.paths || [], destDir, pluginRoot, moduleName, dryRun, targetName));
260
+ }
261
+
262
+ // Step 2: For Claude Code target, merge hooks into .claude/settings.json
263
+ if (targetName === 'claude') {
264
+ const settingsPath = path.join(projectRoot, '.claude', 'settings.json');
265
+ const hooksJson = readJson(hooksSourcePath);
266
+
267
+ if (!hooksJson || !hooksJson.hooks) {
268
+ console.warn(' [WARN] No hooks found in hooks source');
269
+ return installed;
270
+ }
271
+
272
+ // Remap all hook commands from plugin paths to project-local paths
273
+ const remappedHooks = {};
274
+ for (const [event, groups] of Object.entries(hooksJson.hooks)) {
275
+ remappedHooks[event] = groups.map(g => ({
276
+ ...g,
277
+ hooks: (g.hooks || []).map(h => ({
278
+ ...h,
279
+ command: h.command ? remapHookCommandForProject(h.command) : h.command,
280
+ })),
281
+ }));
282
+ }
283
+
284
+ if (dryRun) {
285
+ const hookCount = Object.values(remappedHooks).reduce((sum, arr) => sum + arr.length, 0);
286
+ console.log(` [dry-run] Would merge ${hookCount} hook groups into .claude/settings.json`);
287
+ } else {
288
+ // Read existing settings or create new
289
+ let settings = {};
290
+ if (fileExists(settingsPath)) {
291
+ settings = readJson(settingsPath) || {};
292
+ }
293
+
294
+ // Merge hooks (SCC hooks replace any existing SCC hooks)
295
+ settings.hooks = remappedHooks;
296
+
297
+ // Write settings.json
298
+ const settingsDir = path.dirname(settingsPath);
299
+ if (!fs.existsSync(settingsDir)) {
300
+ fs.mkdirSync(settingsDir, { recursive: true });
301
+ }
302
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
303
+ console.log(` [OK] Merged hooks into .claude/settings.json`);
304
+ }
305
+
306
+ installed.push({
307
+ destPath: settingsPath,
308
+ srcPath: hooksSourcePath,
309
+ module: moduleName,
310
+ hash: simpleHash(hooksSourcePath),
311
+ });
312
+ }
313
+
314
+ // Step 3: For Cursor target, generate .cursor/hooks.json via adapter
315
+ if (targetName === 'cursor') {
316
+ const hooksJson = readJson(hooksSourcePath);
317
+ if (hooksJson) {
318
+ const cursorHooksPath = path.join(projectRoot, '.cursor', 'hooks.json');
319
+ const cursorHooks = transformHooks(hooksJson);
320
+
321
+ if (dryRun) {
322
+ const hookCount = Object.values(cursorHooks.hooks).reduce((sum, arr) => sum + arr.length, 0);
323
+ console.log(` [dry-run] Would generate .cursor/hooks.json (${hookCount} hooks)`);
324
+ } else {
325
+ const destDir = path.dirname(cursorHooksPath);
326
+ if (!fs.existsSync(destDir)) {
327
+ fs.mkdirSync(destDir, { recursive: true });
328
+ }
329
+ fs.writeFileSync(cursorHooksPath, JSON.stringify(cursorHooks, null, 2) + '\n', 'utf8');
330
+ console.log(` [OK] Generated .cursor/hooks.json`);
331
+ }
332
+
333
+ installed.push({
334
+ destPath: cursorHooksPath,
335
+ srcPath: hooksSourcePath,
336
+ module: moduleName,
337
+ hash: simpleHash(hooksSourcePath),
338
+ });
339
+ }
340
+ }
341
+
342
+ return installed;
343
+ }
344
+
200
345
  /**
201
346
  * Install files for a single module definition.
202
347
  * Supports two formats:
@@ -218,6 +363,13 @@ function installModule(moduleDef, moduleName, pluginRoot, targetName, projectRoo
218
363
  let hasAnyTarget = false;
219
364
 
220
365
  for (const group of moduleDef.pathGroups) {
366
+ // Special handling for hooks install type
367
+ if (group.installType === 'hooks') {
368
+ hasAnyTarget = true;
369
+ installed.push(...installHooks(group, pluginRoot, targetName, projectRoot, moduleName, dryRun));
370
+ continue;
371
+ }
372
+
221
373
  const destRelative = (group.targets || {})[targetName];
222
374
  if (!destRelative) continue;
223
375
  hasAnyTarget = true;
package/scripts/scc.js CHANGED
@@ -23,14 +23,14 @@ function showHelp(exitCode = 0) {
23
23
  SCC — Salesforce Claude Code CLI
24
24
 
25
25
  Usage:
26
- scc <command> [args...]
27
- scc [install args...]
26
+ scc-universal <command> [args...]
27
+ scc-universal [install args...]
28
28
 
29
29
  Commands:
30
30
  ${PRIMARY_COMMANDS.map(cmd => ` ${cmd.padEnd(18)} ${COMMANDS[cmd].description}`).join('\n')}
31
31
 
32
32
  Compatibility:
33
- scc [args...] Without a command, args are routed to "install"
33
+ scc-universal [args...] Without a command, args are routed to "install"
34
34
  scc help <command> Show help for a specific command
35
35
 
36
36
  Install targets:
@@ -44,17 +44,17 @@ Install profiles:
44
44
  --profile full Complete suite — all 7 bundles (default)
45
45
 
46
46
  Examples:
47
- scc apex
48
- scc all
49
- scc install --config scc-install.json
50
- scc install --profile apex --target claude
51
- scc plan --config scc-install.json --target cursor
52
- scc doctor
53
- scc repair --dry-run
54
- scc status --json
55
- scc sessions
56
- scc session-inspect claude:latest
57
- scc uninstall --dry-run
47
+ scc-universal apex
48
+ scc-universal all
49
+ scc-universal install --config scc-install.json
50
+ scc-universal install --profile apex --target claude
51
+ scc-universal plan --config scc-install.json --target cursor
52
+ scc-universal doctor
53
+ scc-universal repair --dry-run
54
+ scc-universal status --json
55
+ scc-universal sessions
56
+ scc-universal session-inspect claude:latest
57
+ scc-universal uninstall --dry-run
58
58
 
59
59
  Environment:
60
60
  SCC_HOOK_PROFILE Hook profile: minimal | standard | strict
@@ -28,9 +28,9 @@ Interactive guide for installing and configuring Salesforce Claude Code.
28
28
  npm install -g scc-universal
29
29
 
30
30
  # Install with a profile
31
- npx scc install all # Everything (all agents, skills, rules)
32
- npx scc install apex # Apex-focused development
33
- npx scc install lwc # LWC-focused development
31
+ npx scc-universal install all # Everything (all agents, skills, rules)
32
+ npx scc-universal install apex # Apex-focused development
33
+ npx scc-universal install lwc # LWC-focused development
34
34
  ```
35
35
 
36
36
  ### Profile Details
@@ -44,10 +44,10 @@ npx scc install lwc # LWC-focused development
44
44
  ### Diagnostics
45
45
 
46
46
  ```bash
47
- npx scc doctor # Check for missing/drifted files
48
- npx scc status # View installed components
49
- npx scc repair # Restore drifted files
50
- npx scc uninstall # Remove SCC-managed files
47
+ npx scc-universal doctor # Check for missing/drifted files
48
+ npx scc-universal status # View installed components
49
+ npx scc-universal repair # Restore drifted files
50
+ npx scc-universal uninstall # Remove SCC-managed files
51
51
  ```
52
52
 
53
53
  ## Hook Configuration
@@ -113,10 +113,10 @@ CLAUDE_PACKAGE_MANAGER=npm
113
113
 
114
114
  | Problem | Cause | Fix |
115
115
  |---------|-------|-----|
116
- | `npx scc install` fails | Node.js < 20 | Upgrade: `nvm install 20` |
117
- | Hooks not firing | SCC not installed in project | Run `npx scc doctor` to check |
118
- | `Permission denied` on hooks | Script not executable | Run `npx scc repair` |
119
- | Skills not loading | Wrong install profile | Run `npx scc install all` |
116
+ | `npx scc-universal install` fails | Node.js < 20 | Upgrade: `nvm install 20` |
117
+ | Hooks not firing | SCC not installed in project | Run `npx scc-universal doctor` to check |
118
+ | `Permission denied` on hooks | Script not executable | Run `npx scc-universal repair` |
119
+ | Skills not loading | Wrong install profile | Run `npx scc-universal install all` |
120
120
  | `sf` command not found | SF CLI not installed | Install: `npm install -g @salesforce/cli` |
121
121
  | `sf` commands fail with errors | SF CLI version too old | Upgrade: `npm update -g @salesforce/cli` (SCC requires SF CLI v2.x / `sf` not `sfdx`) |
122
122
  | Hooks slow down session | Too many hooks enabled | Switch to `SCC_HOOK_PROFILE=minimal` |
@@ -125,19 +125,19 @@ CLAUDE_PACKAGE_MANAGER=npm
125
125
 
126
126
  ```bash
127
127
  # Full diagnostic report
128
- npx scc doctor
128
+ npx scc-universal doctor
129
129
 
130
130
  # See exactly what's installed
131
- npx scc list-installed
131
+ npx scc-universal list-installed
132
132
 
133
133
  # Preview what WOULD be installed (dry run)
134
- npx scc plan apex
134
+ npx scc-universal plan apex
135
135
 
136
136
  # Check state store
137
- npx scc status
137
+ npx scc-universal status
138
138
 
139
139
  # Reset everything and reinstall
140
- npx scc uninstall && npx scc install all
140
+ npx scc-universal uninstall && npx scc-universal install all
141
141
  ```
142
142
 
143
143
  ### Upgrading SCC
@@ -147,17 +147,17 @@ npx scc uninstall && npx scc install all
147
147
  npm install -g scc-universal@latest
148
148
 
149
149
  # Repair any drifted files after upgrade
150
- npx scc repair
150
+ npx scc-universal repair
151
151
 
152
152
  # Verify upgrade
153
- npx scc doctor
153
+ npx scc-universal doctor
154
154
  ```
155
155
 
156
156
  ## Verification
157
157
 
158
158
  ```bash
159
159
  npm test # Run all validators
160
- npx scc doctor # Check installation health
160
+ npx scc-universal doctor # Check installation health
161
161
  sf --version # Verify SF CLI is installed
162
- npx scc status # Confirm installed components
162
+ npx scc-universal status # Confirm installed components
163
163
  ```