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.
- 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 +10 -8
- 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 +164 -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,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
|