返回 DeepSeek-Reasonix
claude_compat.go
根目录 / internal / pluginpkg / claude_compat.go
1 package pluginpkg
2
3 import (
4 "encoding/json"
5 "fmt"
6 "hash/fnv"
7 "maps"
8 "os"
9 "path/filepath"
10 "regexp"
11 "slices"
12 "sort"
13 "strings"
14
15 fileencoding "reasonix/internal/fileutil/encoding"
16 "reasonix/internal/frontmatter"
17 )
18
19 const (
20 claudeHooksPath = "hooks/hooks.json"
21 claudeMCPPath = ".mcp.json"
22 )
23
24 var claudeHookEvents = map[string]bool{
25 "PreToolUse": true, "PostToolUse": true, "PostToolUseFailure": true,
26 "PermissionRequest": true, "UserPromptSubmit": true, "Stop": true,
27 "StopFailure": true, "SessionStart": true, "SessionEnd": true,
28 "SubagentStop": true, "Notification": true, "PreCompact": true,
29 }
30
31 type claudeHookDocument struct {
32 Hooks map[string][]struct {
33 Matcher string `json:"matcher"`
34 Match string `json:"match"`
35 Hooks []struct {
36 Type string `json:"type"`
37 Command string `json:"command"`
38 Args *[]string `json:"args"`
39 Shell string `json:"shell"`
40 Description string `json:"description"`
41 Timeout int `json:"timeout"`
42 Async bool `json:"async"`
43 AsyncRewake bool `json:"asyncRewake"`
44 If string `json:"if"`
45 Env map[string]string `json:"env"`
46 } `json:"hooks"`
47 } `json:"hooks"`
48 }
49
50 // claudeStopBlockingEvents are the Claude hook events whose hook can block
51 // the action (force the turn/subagent to keep going on exit 2 or a top-level
52 // decision:"block") the way Claude's own contract does
53 // (https://code.claude.com/docs/en/hooks). Reasonix's Stop/SubagentStop hooks
54 // are observation-only and cannot force the loop to continue, so an imported
55 // hook here is a partial mapping, not a full one.
56 var claudeStopBlockingEvents = map[string]bool{"Stop": true, "SubagentStop": true}
57
58 // claudeToolScopedHookEvents are the events whose "matcher" field is
59 // evaluated against a tool name (see internal/hook's MatchesTool); other
60 // events ignore matcher entirely, so a matcher tool-name compatibility issue
61 // doesn't apply to them.
62 var claudeToolScopedHookEvents = map[string]bool{
63 "PreToolUse": true, "PostToolUse": true, "PostToolUseFailure": true,
64 "PermissionRequest": true,
65 }
66
67 // claudeUnsupportedToolMatchers are real Claude Code built-in tool names
68 // (https://code.claude.com/docs/en/tools-reference) that Reasonix never
69 // passes as a tool_name to a hook (see internal/hook's claudeToolNames /
70 // claudeToolMatchAliases, the actual dispatch-time mapping — keep the two in
71 // sync). A tool-scoped hook whose matcher names only entries from this set
72 // can never fire against a real Reasonix tool call. This is a conservative,
73 // individually-verified subset of Claude's full tool catalog, not an
74 // exhaustive enumeration — an unrecognized matcher is left unflagged rather
75 // than guessed at.
76 var claudeUnsupportedToolMatchers = map[string]bool{
77 "WebSearch": true, // Reasonix has no web-search tool
78 "ExitPlanMode": true, // plan approval is a controller decision, never a dispatched tool call
79 "EnterPlanMode": true, // same as ExitPlanMode
80 "Artifact": true, // Reasonix has no artifact-publishing tool
81 }
82
83 var bareClaudeToolNamePattern = regexp.MustCompile(`^[A-Za-z_]+$`)
84
85 // claudeMatcherNeverFires reports whether matcher — a plain tool name or a
86 // "|"-alternation of plain tool names, the two forms Claude's own docs use —
87 // names only tools from claudeUnsupportedToolMatchers. A matcher using any
88 // other regex syntax is left unevaluated: proving a general regex can never
89 // match Reasonix's tool universe isn't attempted here, so this only catches
90 // the common, unambiguous case.
91 func claudeMatcherNeverFires(matcher string) bool {
92 matcher = strings.TrimSpace(matcher)
93 if matcher == "" || matcher == "*" {
94 return false
95 }
96 for _, part := range strings.Split(matcher, "|") {
97 part = strings.TrimSpace(part)
98 if !bareClaudeToolNamePattern.MatchString(part) || !claudeUnsupportedToolMatchers[part] {
99 return false
100 }
101 }
102 return true
103 }
104
105 // claudeMatcherIncludesTool reports whether a Claude matcher can select the
106 // given tool using the same anchored-regex semantics as hook.MatchesTool.
107 // Empty and "*" matchers select every tool. Malformed regexes select none at
108 // runtime and therefore do not include the target here.
109 func claudeMatcherIncludesTool(matcher, toolName string) bool {
110 matcher = strings.TrimSpace(matcher)
111 if matcher == "" || matcher == "*" {
112 return true
113 }
114 re, err := regexp.Compile("^(?:" + matcher + ")$")
115 return err == nil && re.MatchString(toolName)
116 }
117
118 type claudeMCPIdentity struct {
119 Type string `json:"type"`
120 Command string `json:"command,omitempty"`
121 Args []string `json:"args,omitempty"`
122 Env map[string]string `json:"env,omitempty"`
123 URL string `json:"url,omitempty"`
124 Headers map[string]string `json:"headers,omitempty"`
125 }
126
127 // appendClaudeCompatibility maps Claude package conventions onto Reasonix's
128 // normalized manifest. It returns structured issues as well as compatibility
129 // warnings so frontends do not have to infer severity from English text.
130 func appendClaudeCompatibility(root string, manifest *Manifest) ([]string, []CompatibilityIssue) {
131 var warnings []string
132 var issues []CompatibilityIssue
133 for _, rel := range []string{claudeSettingsPath, claudeHooksPath} {
134 w, i := appendClaudeHooksFile(root, rel, manifest)
135 warnings = append(warnings, w...)
136 issues = append(issues, i...)
137 }
138 w, i := appendClaudeMCPFile(root, manifest)
139 warnings = append(warnings, w...)
140 issues = append(issues, i...)
141 return uniqueSorted(warnings), issues
142 }
143
144 func appendClaudeHooksFile(root, rel string, manifest *Manifest) ([]string, []CompatibilityIssue) {
145 path := filepath.Join(root, filepath.FromSlash(rel))
146 body, err := fileencoding.ReadFileUTF8(path)
147 if os.IsNotExist(err) {
148 return nil, nil
149 }
150 if err != nil {
151 return compatibilityFailure("hooks", rel, err)
152 }
153 var raw claudeHookDocument
154 if err := json.Unmarshal(body, &raw); err != nil {
155 return compatibilityFailure("hooks", rel, err)
156 }
157 if len(raw.Hooks) == 0 {
158 return nil, nil
159 }
160 if manifest.Hooks == nil {
161 manifest.Hooks = map[string][]Hook{}
162 }
163 var warnings []string
164 var issues []CompatibilityIssue
165 var gapWebFetch, gapNotebook, gapTaskOutput bool
166 for event, blocks := range raw.Hooks {
167 event = strings.TrimSpace(event)
168 if !claudeHookEvents[event] {
169 reason := fmt.Sprintf("unsupported Claude hook event %q", event)
170 warnings = append(warnings, rel+": "+reason)
171 issues = append(issues, CompatibilityIssue{Capability: "hooks", Path: rel, Reason: reason})
172 continue
173 }
174 for _, block := range blocks {
175 match := firstNonEmpty(strings.TrimSpace(block.Matcher), strings.TrimSpace(block.Match))
176 for _, item := range block.Hooks {
177 typ := strings.TrimSpace(item.Type)
178 if typ != "" && typ != "command" {
179 reason := fmt.Sprintf("unsupported hook type %q for %s", typ, event)
180 warnings = append(warnings, rel+": "+reason)
181 issues = append(issues, CompatibilityIssue{Capability: "hooks", Path: rel, Reason: reason})
182 continue
183 }
184 command := item.Command
185 if strings.TrimSpace(command) == "" {
186 continue
187 }
188 argsSet := item.Args != nil
189 var args []string
190 shell := ""
191 if argsSet {
192 // Exec-form arguments are literal. Preserve empty and
193 // whitespace-only values exactly as declared.
194 args = append([]string{}, (*item.Args)...)
195 } else {
196 shell = strings.ToLower(strings.TrimSpace(item.Shell))
197 if !validClaudeHookShell(shell) {
198 reason := fmt.Sprintf("%s hook %q uses unsupported shell %q", event, command, item.Shell)
199 warnings = append(warnings, rel+": "+reason)
200 issues = append(issues, CompatibilityIssue{Capability: "hooks", Path: rel, Reason: reason})
201 continue
202 }
203 }
204 if ifExpr := strings.TrimSpace(item.If); ifExpr != "" {
205 reason := fmt.Sprintf("%s hook %q has a conditional \"if\": %q that Reasonix does not evaluate — it runs unconditionally instead of only for the matching case", event, command, ifExpr)
206 warnings = append(warnings, rel+": "+reason)
207 issues = append(issues, CompatibilityIssue{Capability: "hooks", Path: rel, Reason: reason})
208 }
209 if item.AsyncRewake {
210 mode := "synchronously"
211 if item.Async {
212 mode = "async, but without a wake-on-exit-2 callback"
213 }
214 reason := fmt.Sprintf("%s hook %q uses \"asyncRewake\", which Reasonix does not support — it runs %s instead", event, command, mode)
215 warnings = append(warnings, rel+": "+reason)
216 issues = append(issues, CompatibilityIssue{Capability: "hooks", Path: rel, Reason: reason})
217 }
218 if claudeStopBlockingEvents[event] {
219 reason := fmt.Sprintf("%s hook %q cannot block the turn the way Claude's contract does — Reasonix's %s hook is observation-only and never forces the loop to continue", event, command, event)
220 warnings = append(warnings, rel+": "+reason)
221 issues = append(issues, CompatibilityIssue{Capability: "hooks", Path: rel, Reason: reason})
222 }
223 if claudeToolScopedHookEvents[event] && claudeMatcherNeverFires(match) {
224 reason := fmt.Sprintf("%s hook %q matcher %q names a Claude tool Reasonix has no equivalent for, so it will never fire", event, command, match)
225 warnings = append(warnings, rel+": "+reason)
226 issues = append(issues, CompatibilityIssue{Capability: "hooks", Path: rel, Reason: reason})
227 }
228 if claudeToolScopedHookEvents[event] {
229 gapWebFetch = gapWebFetch || claudeMatcherIncludesTool(match, "WebFetch")
230 gapNotebook = gapNotebook || claudeMatcherIncludesTool(match, "NotebookEdit")
231 gapTaskOutput = gapTaskOutput || claudeMatcherIncludesTool(match, "TaskOutput") || claudeMatcherIncludesTool(match, "BashOutput")
232 }
233 manifest.Hooks[event] = appendUniqueHook(manifest.Hooks[event], Hook{
234 Match: match,
235 Command: command,
236 Args: args,
237 ArgsSet: argsSet,
238 ShellCommand: true,
239 Shell: shell,
240 Async: item.Async,
241 PayloadFormat: "claude",
242 Description: firstNonEmpty(strings.TrimSpace(item.Description), "Claude-compatible hook from "+rel),
243 Timeout: claudeTimeoutMillis(item.Timeout),
244 Cwd: ".",
245 Env: cloneHookEnv(item.Env),
246 })
247 }
248 }
249 }
250 // Structural input gaps (fields Reasonix cannot losslessly express) are
251 // reported once per hooks file: every additional matching hook repeats
252 // the same information, and a wildcard-matcher plugin would otherwise
253 // collect one copy per hook. File-level wording also keeps the emitted
254 // set deterministic despite the random event-map iteration order above.
255 for _, gap := range []struct {
256 hit bool
257 reason string
258 }{
259 {gapWebFetch, `a tool-scoped hook matcher includes Claude WebFetch, but Reasonix web_fetch cannot supply Claude's required "prompt" input; such hooks receive only "url" for that tool`},
260 {gapNotebook, "a tool-scoped hook matcher includes Claude NotebookEdit, but Reasonix notebook_edit may target a cell by cell_number, which cannot be converted to Claude's opaque cell_id; such hooks receive cell_number as an extra field for those calls"},
261 {gapTaskOutput, "a tool-scoped hook matcher includes Claude TaskOutput, but Reasonix wait may cover multiple or all background jobs in one call, which cannot be represented by Claude's single task_id; such hooks receive job_ids as an extra field for those calls"},
262 } {
263 if !gap.hit {
264 continue
265 }
266 warnings = append(warnings, rel+": "+gap.reason)
267 issues = append(issues, CompatibilityIssue{Capability: "hooks", Path: rel, Reason: gap.reason})
268 }
269 return uniqueSorted(warnings), issues
270 }
271
272 func appendUniqueHook(hooks []Hook, candidate Hook) []Hook {
273 for _, existing := range hooks {
274 if hooksEqual(existing, candidate) {
275 return hooks
276 }
277 }
278 return append(hooks, candidate)
279 }
280
281 // hooksEqual reports whether two hooks are duplicate declarations of the same
282 // invocation. Two hooks that run the same command with the same matcher and
283 // args but different Env, Timeout, Async, or Cwd are distinct configurations,
284 // not duplicates — comparing only match/command/args/payload format would
285 // silently drop the second one.
286 func hooksEqual(a, b Hook) bool {
287 return a.Match == b.Match && a.Command == b.Command &&
288 a.ArgsSet == b.ArgsSet && slices.Equal(a.Args, b.Args) &&
289 a.ShellCommand == b.ShellCommand && a.Shell == b.Shell &&
290 a.PayloadFormat == b.PayloadFormat &&
291 a.Async == b.Async && a.Timeout == b.Timeout && a.Cwd == b.Cwd &&
292 maps.Equal(a.Env, b.Env)
293 }
294
295 func validClaudeHookShell(shell string) bool {
296 return shell == "" || shell == "bash" || shell == "powershell"
297 }
298
299 func appendClaudeMCPFile(root string, manifest *Manifest) ([]string, []CompatibilityIssue) {
300 path := filepath.Join(root, claudeMCPPath)
301 body, err := fileencoding.ReadFileUTF8(path)
302 if os.IsNotExist(err) {
303 return nil, nil
304 }
305 if err != nil {
306 return compatibilityFailure("mcp", claudeMCPPath, err)
307 }
308 var raw struct {
309 MCPServers map[string]struct {
310 Type string `json:"type"`
311 Command string `json:"command"`
312 Args []string `json:"args"`
313 Env map[string]string `json:"env"`
314 URL string `json:"url"`
315 Headers map[string]string `json:"headers"`
316 Title string `json:"title"`
317 Description string `json:"description"`
318 } `json:"mcpServers"`
319 }
320 if err := json.Unmarshal(body, &raw); err != nil {
321 return compatibilityFailure("mcp", claudeMCPPath, err)
322 }
323 if len(raw.MCPServers) == 0 {
324 return nil, nil
325 }
326 if manifest.MCPServers == nil {
327 manifest.MCPServers = map[string]MCPServer{}
328 }
329 names := make([]string, 0, len(raw.MCPServers))
330 for name := range raw.MCPServers {
331 names = append(names, name)
332 }
333 sort.Strings(names)
334 var warnings []string
335 var issues []CompatibilityIssue
336 for _, displayName := range names {
337 spec := raw.MCPServers[displayName]
338 typ := strings.ToLower(strings.TrimSpace(spec.Type))
339 switch typ {
340 case "streamable-http":
341 typ = "http"
342 case "local":
343 typ = "stdio"
344 }
345 if typ == "" {
346 if strings.TrimSpace(spec.URL) != "" {
347 typ = "http"
348 } else {
349 typ = "stdio"
350 }
351 }
352 var reason string
353 switch {
354 case typ != "stdio" && typ != "http" && typ != "sse":
355 reason = fmt.Sprintf("MCP server %q has unsupported transport %q", displayName, spec.Type)
356 case typ == "stdio" && strings.TrimSpace(spec.Command) == "":
357 reason = fmt.Sprintf("MCP server %q has no command", displayName)
358 case (typ == "http" || typ == "sse") && strings.TrimSpace(spec.URL) == "":
359 reason = fmt.Sprintf("MCP server %q has no URL", displayName)
360 }
361 if reason != "" {
362 warnings = append(warnings, claudeMCPPath+": "+reason)
363 issues = append(issues, CompatibilityIssue{Capability: "mcp", Path: claudeMCPPath, Reason: reason})
364 continue
365 }
366 identity := claudeMCPIdentity{
367 Type: typ, Command: strings.TrimSpace(spec.Command), Args: cleanStringList(spec.Args),
368 Env: cloneHookEnv(spec.Env), URL: strings.TrimSpace(spec.URL), Headers: cloneHookEnv(spec.Headers),
369 }
370 id := claudeMCPServerID(displayName, identity)
371 if _, exists := manifest.MCPServers[id]; exists {
372 reason := fmt.Sprintf("MCP server %q maps to duplicate internal name %q", displayName, id)
373 warnings = append(warnings, claudeMCPPath+": "+reason)
374 issues = append(issues, CompatibilityIssue{Capability: "mcp", Path: claudeMCPPath, Reason: reason})
375 continue
376 }
377 autoStart := false
378 manifest.MCPServers[id] = MCPServer{
379 Type: typ,
380 Command: strings.TrimSpace(spec.Command),
381 Args: cleanStringList(spec.Args),
382 Env: cloneHookEnv(spec.Env),
383 URL: strings.TrimSpace(spec.URL),
384 Headers: cloneHookEnv(spec.Headers),
385 AutoStart: &autoStart,
386 DisplayName: firstNonEmpty(strings.TrimSpace(spec.Title), strings.TrimSpace(displayName)),
387 Description: strings.TrimSpace(spec.Description),
388 Imported: true,
389 }
390 }
391 return uniqueSorted(warnings), issues
392 }
393
394 func claudeMCPServerID(name string, identity claudeMCPIdentity) string {
395 trimmed := strings.TrimSpace(name)
396 if IsValidName(trimmed) {
397 return trimmed
398 }
399 var b strings.Builder
400 lastDash := false
401 for _, r := range trimmed {
402 valid := r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-'
403 if valid {
404 b.WriteRune(r)
405 lastDash = false
406 } else if b.Len() > 0 && !lastDash {
407 b.WriteByte('-')
408 lastDash = true
409 }
410 }
411 base := strings.Trim(b.String(), "-_")
412 if base == "" {
413 base = "server"
414 }
415 body, _ := json.Marshal(identity)
416 h := fnv.New32a()
417 _, _ = h.Write([]byte(trimmed))
418 _, _ = h.Write(body)
419 suffix := fmt.Sprintf("_%08x", h.Sum32())
420 maxBase := 64 - len(suffix)
421 if len(base) > maxBase {
422 base = base[:maxBase]
423 }
424 return base + suffix
425 }
426
427 func compatibilityFailure(capability, path string, err error) ([]string, []CompatibilityIssue) {
428 reason := err.Error()
429 return []string{path + ": " + reason}, []CompatibilityIssue{{Capability: capability, Path: path, Reason: reason}}
430 }
431
432 // compatibilityFor reports what appendClaudeCompatibility could determine
433 // statically at install time: whether every declared capability in the
434 // manifest parsed and mapped to a Reasonix construct ("full"), some did not
435 // ("partial", see issues), or none did ("none"). It is not a guarantee that
436 // every runtime decision path an imported hook can take is honored — in
437 // particular, PreToolUse's "ask"/"defer" permissionDecision values and any
438 // hookSpecificOutput.updatedInput are decided by the hook script's stdout at
439 // call time, not by anything in the manifest, so they can't be flagged here.
440 // PreToolUse/PermissionRequest's "deny" and PermissionRequest's "allow" are
441 // the two runtime decisions Reasonix does implement (see claudeJSONDeny/
442 // claudeJSONAllow in internal/hook). Statically detectable gaps
443 // (if/asyncRewake, Stop/
444 // SubagentStop's inability to block the turn, WebFetch's unavailable required
445 // prompt input, NotebookEdit's untranslatable cell_number, and multi-job
446 // TaskOutput calls) already downgrade to "partial" via issues appended in
447 // appendClaudeHooksFile.
448 func compatibilityFor(pkg Package, issues []CompatibilityIssue) Compatibility {
449 mapped := make([]string, 0, 5)
450 skills, commands, hooks, mcp := pkg.CapabilityCounts()
451 if skills > 0 {
452 mapped = append(mapped, "skills")
453 }
454 if commands > 0 {
455 mapped = append(mapped, "commands")
456 }
457 if pkg.AgentCount() > 0 {
458 mapped = append(mapped, "agents")
459 }
460 if hooks > 0 {
461 mapped = append(mapped, "hooks")
462 }
463 if mcp > 0 {
464 mapped = append(mapped, "mcp")
465 }
466 status := "full"
467 if len(mapped) == 0 && pkg.ManifestKind != "reasonix" {
468 status = "none"
469 } else if len(issues) > 0 {
470 status = "partial"
471 }
472 return Compatibility{Status: status, Mapped: mapped, Skipped: issues}
473 }
474
475 func dirContainsAgentMd(dir string) bool { return len(loadAgentRefs(dir)) > 0 }
476
477 func (p Package) agentRefs() []AgentRef {
478 var out []AgentRef
479 for _, root := range p.AgentRoots() {
480 out = append(out, loadAgentRefs(root)...)
481 }
482 sort.SliceStable(out, func(i, j int) bool { return out[i].Name < out[j].Name })
483 return out
484 }
485
486 func loadAgentRefs(dir string) []AgentRef {
487 entries, err := os.ReadDir(dir)
488 if err != nil {
489 return nil
490 }
491 var out []AgentRef
492 for _, entry := range entries {
493 if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".md") {
494 continue
495 }
496 path := filepath.Join(dir, entry.Name())
497 body, err := fileencoding.ReadFileUTF8(path)
498 if err != nil {
499 continue
500 }
501 fm, _ := frontmatter.Split(string(body))
502 name := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name()))
503 if declared := strings.TrimSpace(fm["name"]); IsValidName(declared) {
504 name = declared
505 }
506 if !IsValidName(name) {
507 continue
508 }
509 out = append(out, AgentRef{
510 Name: name,
511 Description: strings.TrimSpace(fm["description"]),
512 Path: path,
513 Invocation: "/" + name,
514 Model: strings.TrimSpace(fm["model"]),
515 AllowedTools: splitCSV(fm["tools"]),
516 })
517 }
518 return out
519 }
520
521 func splitCSV(raw string) []string {
522 raw = strings.TrimSpace(raw)
523 if strings.HasPrefix(raw, "[") && strings.HasSuffix(raw, "]") {
524 raw = strings.TrimSpace(raw[1 : len(raw)-1])
525 }
526 var out []string
527 for _, item := range strings.Split(raw, ",") {
528 if item = strings.Trim(strings.TrimSpace(item), `"'`); item != "" {
529 out = append(out, item)
530 }
531 }
532 return out
533 }
534
535 func cleanStringList(in []string) []string {
536 out := make([]string, 0, len(in))
537 for _, value := range in {
538 if value = strings.TrimSpace(value); value != "" {
539 out = append(out, value)
540 }
541 }
542 return out
543 }
544
545 func uniqueSorted(in []string) []string {
546 seen := map[string]bool{}
547 out := make([]string, 0, len(in))
548 for _, value := range in {
549 if value = strings.TrimSpace(value); value != "" && !seen[value] {
550 seen[value] = true
551 out = append(out, value)
552 }
553 }
554 sort.Strings(out)
555 return out
556 }
557
557 lines GO