| 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 |