scc-universal 1.1.0 → 1.2.1

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 +10 -8
  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 +164 -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,160 @@ 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
+ // Remap adapter paths from scripts/hooks/ to .cursor/hooks/
322
+ // (adapter outputs plugin-relative paths, but install copies to .cursor/hooks/)
323
+ for (const hooks of Object.values(cursorHooks.hooks)) {
324
+ for (const hook of hooks) {
325
+ if (hook.command) {
326
+ hook.command = hook.command.replace(/\bnode scripts\/hooks\//g, 'node "$CURSOR_PROJECT_DIR"/.cursor/hooks/');
327
+ hook.command = hook.command.replace(/\bbash scripts\/hooks\//g, 'bash "$CURSOR_PROJECT_DIR"/.cursor/hooks/');
328
+ }
329
+ }
330
+ }
331
+
332
+ if (dryRun) {
333
+ const hookCount = Object.values(cursorHooks.hooks).reduce((sum, arr) => sum + arr.length, 0);
334
+ console.log(` [dry-run] Would generate .cursor/hooks.json (${hookCount} hooks)`);
335
+ } else {
336
+ const destDir = path.dirname(cursorHooksPath);
337
+ if (!fs.existsSync(destDir)) {
338
+ fs.mkdirSync(destDir, { recursive: true });
339
+ }
340
+ fs.writeFileSync(cursorHooksPath, JSON.stringify(cursorHooks, null, 2) + '\n', 'utf8');
341
+ console.log(` [OK] Generated .cursor/hooks.json`);
342
+ }
343
+
344
+ installed.push({
345
+ destPath: cursorHooksPath,
346
+ srcPath: hooksSourcePath,
347
+ module: moduleName,
348
+ hash: simpleHash(hooksSourcePath),
349
+ });
350
+ }
351
+ }
352
+
353
+ return installed;
354
+ }
355
+
200
356
  /**
201
357
  * Install files for a single module definition.
202
358
  * Supports two formats:
@@ -218,6 +374,13 @@ function installModule(moduleDef, moduleName, pluginRoot, targetName, projectRoo
218
374
  let hasAnyTarget = false;
219
375
 
220
376
  for (const group of moduleDef.pathGroups) {
377
+ // Special handling for hooks install type
378
+ if (group.installType === 'hooks') {
379
+ hasAnyTarget = true;
380
+ installed.push(...installHooks(group, pluginRoot, targetName, projectRoot, moduleName, dryRun));
381
+ continue;
382
+ }
383
+
221
384
  const destRelative = (group.targets || {})[targetName];
222
385
  if (!destRelative) continue;
223
386
  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