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.
- package/.claude-plugin/plugin.json +2 -2
- package/.cursor/hooks.json +105 -64
- package/.cursor/skills/configure-scc/SKILL.md +20 -20
- package/.cursor/skills/mcp-server-patterns/SKILL.md +1 -1
- package/.cursor/skills/sf-harness-audit/SKILL.md +6 -6
- package/.cursor/skills/sf-quickstart/SKILL.md +7 -7
- package/.cursor-plugin/plugin.json +2 -2
- package/README.md +51 -37
- package/docs/ARCHITECTURE.md +4 -4
- package/docs/authoring-guide.md +2 -2
- package/docs/workflow-examples.md +38 -38
- package/hooks/hooks.json +56 -71
- package/manifests/install-modules.json +5 -3
- package/package.json +4 -3
- package/schemas/hooks.schema.json +83 -72
- package/schemas/plugin.schema.json +59 -21
- package/scripts/cli/install-apply.js +9 -9
- package/scripts/hooks/doc-file-warning.js +3 -1
- package/scripts/hooks/governor-check.js +3 -2
- package/scripts/hooks/post-bash-build-complete.js +3 -2
- package/scripts/hooks/post-bash-pr-created.js +4 -2
- package/scripts/hooks/post-edit-console-warn.js +3 -1
- package/scripts/hooks/post-edit-format.js +3 -2
- package/scripts/hooks/post-edit-typecheck.js +3 -2
- package/scripts/hooks/post-write.js +3 -1
- package/scripts/hooks/pre-bash-git-push-reminder.js +3 -2
- package/scripts/hooks/pre-bash-tmux-reminder.js +3 -1
- package/scripts/hooks/pre-tool-use.js +3 -1
- package/scripts/hooks/quality-gate.js +3 -2
- package/scripts/hooks/sfdx-scanner-check.js +3 -1
- package/scripts/hooks/sfdx-validate.js +3 -1
- package/scripts/lib/hook-input.js +105 -0
- package/scripts/lib/hooks-adapter.js +265 -0
- package/scripts/lib/install-executor.js +153 -1
- package/scripts/scc.js +14 -14
- package/skills/configure-scc/SKILL.md +20 -20
- package/skills/mcp-server-patterns/SKILL.md +1 -1
- package/skills/sf-harness-audit/SKILL.md +6 -6
- 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:
|
|
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
|
```
|