crabcode 0.1.6__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. crabcode-0.1.6.dist-info/METADATA +63 -0
  2. crabcode-0.1.6.dist-info/RECORD +205 -0
  3. crabcode-0.1.6.dist-info/WHEEL +4 -0
  4. crabcode-0.1.6.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +7 -0
  6. crabcode_cli/__main__.py +4 -0
  7. crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
  8. crabcode_cli/_native/runtime.json +6 -0
  9. crabcode_cli/app.py +776 -0
  10. crabcode_cli/banner.py +39 -0
  11. crabcode_cli/computer_use.py +286 -0
  12. crabcode_cli/model_manager.py +649 -0
  13. crabcode_cli/model_picker.py +168 -0
  14. crabcode_cli/pipe.py +148 -0
  15. crabcode_cli/render/__init__.py +0 -0
  16. crabcode_cli/render/messages.py +19 -0
  17. crabcode_cli/repl.py +4841 -0
  18. crabcode_cli/session_picker.py +239 -0
  19. crabcode_core/__init__.py +7 -0
  20. crabcode_core/_version.py +3 -0
  21. crabcode_core/_windows_process.py +68 -0
  22. crabcode_core/agent_manager.py +1731 -0
  23. crabcode_core/api/__init__.py +20 -0
  24. crabcode_core/api/anthropic_adapter.py +648 -0
  25. crabcode_core/api/azure_adapter.py +69 -0
  26. crabcode_core/api/base.py +233 -0
  27. crabcode_core/api/codex_adapter.py +1327 -0
  28. crabcode_core/api/error_logging.py +132 -0
  29. crabcode_core/api/gemini_adapter.py +26 -0
  30. crabcode_core/api/model_info.py +257 -0
  31. crabcode_core/api/network.py +62 -0
  32. crabcode_core/api/ollama_adapter.py +27 -0
  33. crabcode_core/api/openai_adapter.py +323 -0
  34. crabcode_core/api/registry.py +58 -0
  35. crabcode_core/command_line.py +18 -0
  36. crabcode_core/compact/__init__.py +17 -0
  37. crabcode_core/compact/compact.py +514 -0
  38. crabcode_core/compact/context_tokens.py +165 -0
  39. crabcode_core/computer_use_mode.py +38 -0
  40. crabcode_core/computer_use_observation.py +144 -0
  41. crabcode_core/config/__init__.py +3 -0
  42. crabcode_core/config/claudemd.py +61 -0
  43. crabcode_core/config/manager.py +237 -0
  44. crabcode_core/config/model_catalog.py +256 -0
  45. crabcode_core/events.py +4896 -0
  46. crabcode_core/file_lock.py +81 -0
  47. crabcode_core/filesystem.py +22 -0
  48. crabcode_core/goal.py +149 -0
  49. crabcode_core/hooks/__init__.py +5 -0
  50. crabcode_core/hooks/manager.py +290 -0
  51. crabcode_core/image_io.py +111 -0
  52. crabcode_core/io_worker.py +211 -0
  53. crabcode_core/logging_utils.py +168 -0
  54. crabcode_core/lsp/__init__.py +6 -0
  55. crabcode_core/lsp/client.py +786 -0
  56. crabcode_core/lsp/diagnostics.py +150 -0
  57. crabcode_core/lsp/manager.py +583 -0
  58. crabcode_core/mcp/__init__.py +4 -0
  59. crabcode_core/mcp/client.py +169 -0
  60. crabcode_core/mcp/config.py +57 -0
  61. crabcode_core/path_validation.py +34 -0
  62. crabcode_core/paths.py +24 -0
  63. crabcode_core/peer/__init__.py +10 -0
  64. crabcode_core/peer/runtime.py +518 -0
  65. crabcode_core/permissions/__init__.py +7 -0
  66. crabcode_core/permissions/ai_reviewer.py +184 -0
  67. crabcode_core/permissions/manager.py +187 -0
  68. crabcode_core/plan/__init__.py +5 -0
  69. crabcode_core/plan/executor.py +270 -0
  70. crabcode_core/plan/types.py +169 -0
  71. crabcode_core/prompts/__init__.py +0 -0
  72. crabcode_core/prompts/blocks.py +10 -0
  73. crabcode_core/prompts/context.py +229 -0
  74. crabcode_core/prompts/library.py +92 -0
  75. crabcode_core/prompts/profile.py +79 -0
  76. crabcode_core/prompts/system.py +356 -0
  77. crabcode_core/prompts/templates.py +104 -0
  78. crabcode_core/query/__init__.py +0 -0
  79. crabcode_core/query/computer_use_history.py +83 -0
  80. crabcode_core/query/loop.py +2446 -0
  81. crabcode_core/query/retry.py +116 -0
  82. crabcode_core/schedule/__init__.py +13 -0
  83. crabcode_core/schedule/manager.py +631 -0
  84. crabcode_core/schedule/models.py +204 -0
  85. crabcode_core/schedule/store.py +558 -0
  86. crabcode_core/schedule/timing.py +287 -0
  87. crabcode_core/session/__init__.py +12 -0
  88. crabcode_core/session/export.py +141 -0
  89. crabcode_core/session/meta_db.py +684 -0
  90. crabcode_core/session/storage.py +2032 -0
  91. crabcode_core/session/title_gen.py +52 -0
  92. crabcode_core/skills/__init__.py +6 -0
  93. crabcode_core/skills/context.py +23 -0
  94. crabcode_core/skills/loader.py +214 -0
  95. crabcode_core/skills/matcher.py +203 -0
  96. crabcode_core/snapshot/__init__.py +21 -0
  97. crabcode_core/snapshot/snapshot.py +436 -0
  98. crabcode_core/snapshot/tracker.py +359 -0
  99. crabcode_core/subprocess_utils.py +260 -0
  100. crabcode_core/team/__init__.py +27 -0
  101. crabcode_core/team/bridge.py +66 -0
  102. crabcode_core/team/inbox.py +237 -0
  103. crabcode_core/team/manager.py +1660 -0
  104. crabcode_core/team/message_bus.py +821 -0
  105. crabcode_core/team/models.py +191 -0
  106. crabcode_core/team/recovery.py +192 -0
  107. crabcode_core/text_io.py +52 -0
  108. crabcode_core/tools/__init__.py +112 -0
  109. crabcode_core/tools/_input_helpers.py +39 -0
  110. crabcode_core/tools/_input_schema.py +130 -0
  111. crabcode_core/tools/agent.py +413 -0
  112. crabcode_core/tools/apply_patch.py +587 -0
  113. crabcode_core/tools/ask_user.py +122 -0
  114. crabcode_core/tools/bash.py +154 -0
  115. crabcode_core/tools/browser.py +678 -0
  116. crabcode_core/tools/checklist.py +241 -0
  117. crabcode_core/tools/checkpoint.py +78 -0
  118. crabcode_core/tools/computer_use.py +599 -0
  119. crabcode_core/tools/diff_utils.py +123 -0
  120. crabcode_core/tools/file_edit.py +240 -0
  121. crabcode_core/tools/file_read.py +187 -0
  122. crabcode_core/tools/file_write.py +154 -0
  123. crabcode_core/tools/glob.py +123 -0
  124. crabcode_core/tools/goal.py +134 -0
  125. crabcode_core/tools/grep.py +247 -0
  126. crabcode_core/tools/image.py +173 -0
  127. crabcode_core/tools/image_generate.py +115 -0
  128. crabcode_core/tools/lint.py +388 -0
  129. crabcode_core/tools/loading.py +187 -0
  130. crabcode_core/tools/memory.py +456 -0
  131. crabcode_core/tools/monitor.py +677 -0
  132. crabcode_core/tools/peer.py +103 -0
  133. crabcode_core/tools/revert.py +90 -0
  134. crabcode_core/tools/schedule.py +683 -0
  135. crabcode_core/tools/skill.py +108 -0
  136. crabcode_core/tools/switch_mode.py +213 -0
  137. crabcode_core/tools/team.py +329 -0
  138. crabcode_core/tools/tool_search.py +64 -0
  139. crabcode_core/tools/web_search.py +444 -0
  140. crabcode_core/turn_details.py +64 -0
  141. crabcode_core/types/__init__.py +95 -0
  142. crabcode_core/types/config.py +434 -0
  143. crabcode_core/types/event.py +334 -0
  144. crabcode_core/types/message.py +263 -0
  145. crabcode_core/types/tool.py +208 -0
  146. crabcode_core/usage.py +289 -0
  147. crabcode_core/utf8_sanitize.py +29 -0
  148. crabcode_debugger/__init__.py +5 -0
  149. crabcode_debugger/adapters.py +332 -0
  150. crabcode_debugger/dap.py +304 -0
  151. crabcode_debugger/memory.py +1806 -0
  152. crabcode_debugger/process.py +707 -0
  153. crabcode_debugger/runtime.py +58 -0
  154. crabcode_debugger/sessions.py +387 -0
  155. crabcode_debugger/tool.py +731 -0
  156. crabcode_gateway/__init__.py +18 -0
  157. crabcode_gateway/acp/__init__.py +13 -0
  158. crabcode_gateway/acp/agent.py +1581 -0
  159. crabcode_gateway/acp/session.py +114 -0
  160. crabcode_gateway/acp/transport.py +39 -0
  161. crabcode_gateway/acp/types.py +90 -0
  162. crabcode_gateway/adapter.py +30 -0
  163. crabcode_gateway/auth.py +139 -0
  164. crabcode_gateway/computer_use.py +364 -0
  165. crabcode_gateway/document_engine.py +637 -0
  166. crabcode_gateway/document_worker.py +301 -0
  167. crabcode_gateway/event_bus.py +655 -0
  168. crabcode_gateway/grpc/__init__.py +0 -0
  169. crabcode_gateway/grpc/crabcode_pb2.py +70 -0
  170. crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
  171. crabcode_gateway/grpc/proto/crabcode.proto +160 -0
  172. crabcode_gateway/grpc/server.py +765 -0
  173. crabcode_gateway/middleware.py +344 -0
  174. crabcode_gateway/protocol.py +17 -0
  175. crabcode_gateway/routes/__init__.py +0 -0
  176. crabcode_gateway/routes/agent.py +392 -0
  177. crabcode_gateway/routes/auth.py +150 -0
  178. crabcode_gateway/routes/computer_use.py +101 -0
  179. crabcode_gateway/routes/config.py +1698 -0
  180. crabcode_gateway/routes/document.py +1810 -0
  181. crabcode_gateway/routes/event.py +3801 -0
  182. crabcode_gateway/routes/git_review.py +232 -0
  183. crabcode_gateway/routes/health.py +14 -0
  184. crabcode_gateway/routes/peer.py +78 -0
  185. crabcode_gateway/routes/permission.py +121 -0
  186. crabcode_gateway/routes/schedule.py +300 -0
  187. crabcode_gateway/routes/session.py +1496 -0
  188. crabcode_gateway/routes/snapshot.py +211 -0
  189. crabcode_gateway/routes/tasks.py +318 -0
  190. crabcode_gateway/routes/team.py +392 -0
  191. crabcode_gateway/routes/usage.py +49 -0
  192. crabcode_gateway/routes/workspace.py +299 -0
  193. crabcode_gateway/safe_file.py +63 -0
  194. crabcode_gateway/schemas.py +1820 -0
  195. crabcode_gateway/server.py +514 -0
  196. crabcode_gateway/session_registry.py +36 -0
  197. crabcode_gateway/task_registry.py +708 -0
  198. crabcode_gateway/turn_details.py +60 -0
  199. crabcode_search/__init__.py +11 -0
  200. crabcode_search/background.py +368 -0
  201. crabcode_search/chunker.py +517 -0
  202. crabcode_search/embedder.py +497 -0
  203. crabcode_search/indexer.py +215 -0
  204. crabcode_search/store.py +222 -0
  205. crabcode_search/tool.py +268 -0
@@ -0,0 +1,52 @@
1
+ """Async session title generation using an LLM."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from crabcode_core.logging_utils import get_logger
8
+
9
+ logger = get_logger(__name__)
10
+
11
+ _TITLE_PROMPT = (
12
+ "Generate a concise title (5-8 words, no quotes) that captures the main topic "
13
+ "of the following conversation start. Reply with ONLY the title, nothing else."
14
+ )
15
+
16
+
17
+ async def generate_title(
18
+ first_user_message: str,
19
+ first_assistant_text: str,
20
+ api_adapter: Any,
21
+ ) -> str | None:
22
+ """Generate a short title for a conversation using the LLM.
23
+
24
+ Returns a title string, or None on failure.
25
+ """
26
+ if not first_user_message:
27
+ return None
28
+
29
+ content = f"User: {first_user_message[:500]}"
30
+ if first_assistant_text:
31
+ content += f"\n\nAssistant: {first_assistant_text[:500]}"
32
+
33
+ try:
34
+ from crabcode_core.types.message import create_user_message
35
+
36
+ messages = [create_user_message(content=content)]
37
+ response = await api_adapter.chat(
38
+ messages=messages,
39
+ system_prompt=_TITLE_PROMPT,
40
+ tools=[],
41
+ max_tokens=50,
42
+ )
43
+ title = ""
44
+ for block in response.content:
45
+ if hasattr(block, "text"):
46
+ title += block.text
47
+ title = title.strip().strip('"').strip("'").strip()
48
+ if title and len(title) <= 200:
49
+ return title
50
+ except Exception:
51
+ logger.debug("Failed to generate session title", exc_info=True)
52
+ return None
@@ -0,0 +1,6 @@
1
+ """Skills package — skill discovery, definitions, and auto-trigger matching."""
2
+
3
+ from crabcode_core.skills.loader import SkillDefinition, load_skills
4
+ from crabcode_core.skills.matcher import auto_match, resolve_chain
5
+
6
+ __all__ = ["SkillDefinition", "load_skills", "auto_match", "resolve_chain"]
@@ -0,0 +1,23 @@
1
+ """Deduplicate auto-triggered skill content against the retained conversation."""
2
+
3
+ import hashlib
4
+
5
+ from crabcode_core.types.message import create_user_message
6
+
7
+
8
+ def auto_skill_messages(skills, messages):
9
+ retained = {(message.origin, message.content) for message in messages if isinstance(message.content, str)}
10
+ result = []
11
+ for skill in skills:
12
+ content = f"[Auto-triggered skill: {skill.name}] {skill.description or ''}\n{skill.content}"
13
+ origin = "auto-skill:" + hashlib.sha256(content.encode()).hexdigest()
14
+ wrapped = ("<system-reminder>\nFollow this skill when relevant to the user's request:\n"
15
+ + content + "\n</system-reminder>")
16
+ if (origin, wrapped) in retained:
17
+ continue
18
+ retained.add((origin, wrapped))
19
+ result.append(create_user_message(
20
+ content=wrapped,
21
+ origin=origin,
22
+ ))
23
+ return result
@@ -0,0 +1,214 @@
1
+ """Skill loading — scans .crabcode/skills/ and .claude/skills/ directories.
2
+
3
+ Search order (later entries override earlier ones with the same name):
4
+ 1. ~/.claude/skills/<name>/SKILL.md (user global, Claude Code compat)
5
+ 2. $CRABCODE_HOME/skills/<name>/SKILL.md (user global, default ~/.crabcode)
6
+ 3. .claude/skills/<name>/SKILL.md (project, walking up to home)
7
+ 4. .crabcode/skills/<name>/SKILL.md (project, walking up to home, highest priority)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from crabcode_core.paths import get_config_home
18
+
19
+
20
+ @dataclass
21
+ class SkillDefinition:
22
+ name: str
23
+ description: str
24
+ content: str
25
+ source_path: str
26
+ when_to_use: str | None = None
27
+ paths: list[str] = field(default_factory=list) # gating: only activate when user paths match
28
+ # Auto-trigger patterns
29
+ pathPatterns: list[str] = field(default_factory=list) # glob: file paths
30
+ bashPatterns: list[str] = field(default_factory=list) # regex: bash commands
31
+ importPatterns: list[str] = field(default_factory=list) # regex: imports
32
+ chainTo: list[str] = field(default_factory=list) # skill names to chain
33
+
34
+
35
+ def _parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
36
+ """Split YAML frontmatter from Markdown body.
37
+
38
+ Returns (frontmatter_dict, body). If no frontmatter block is found,
39
+ returns ({}, original text).
40
+
41
+ Supports scalar values and simple list values (one-per-line or inline
42
+ comma-separated). Example::
43
+
44
+ name: commit
45
+ pathPatterns: "src/**/*.py, tests/**/*.py"
46
+ bashPatterns:
47
+ - "git commit.*"
48
+ chainTo: "lint"
49
+ """
50
+ match = re.match(r"^---\s*\n(.*?)\n---\s*\n?", text, re.DOTALL)
51
+ if not match:
52
+ return {}, text
53
+
54
+ raw_yaml = match.group(1)
55
+ body = text[match.end():]
56
+
57
+ data: dict[str, Any] = {}
58
+ current_key: str | None = None
59
+ current_list: list[str] | None = None
60
+
61
+ for line in raw_yaml.splitlines():
62
+ # List item under a key: " - value"
63
+ list_item = re.match(r'^\s+-\s+"?(.*?)"?\s*$', line)
64
+ if list_item and current_key is not None:
65
+ if current_list is None:
66
+ current_list = []
67
+ current_list.append(list_item.group(1))
68
+ continue
69
+
70
+ # Key-value pair: "key: value"
71
+ kv = re.match(r'^([\w][\w_-]*):\s*"?(.*?)"?\s*$', line)
72
+ if kv:
73
+ # Flush previous list
74
+ if current_key is not None and current_list is not None:
75
+ data[current_key] = current_list
76
+ current_key = kv.group(1)
77
+ val = kv.group(2)
78
+ if val:
79
+ data[current_key] = val
80
+ current_list = None
81
+ else:
82
+ # Key with no value — might be followed by list items
83
+ current_list = []
84
+ continue
85
+
86
+ # Flush last list
87
+ if current_key is not None and current_list is not None:
88
+ data[current_key] = current_list
89
+
90
+ return data, body.lstrip("\n")
91
+
92
+
93
+ def _parse_list_field(value: Any) -> list[str]:
94
+ """Normalize a frontmatter field to a list of strings.
95
+
96
+ Accepts: list (already parsed), comma-separated string, single string, None.
97
+ """
98
+ if value is None:
99
+ return []
100
+ if isinstance(value, list):
101
+ return [item.strip() for item in value if item and item.strip()]
102
+ if isinstance(value, str):
103
+ return [p.strip() for p in value.split(",") if p.strip()]
104
+ return []
105
+
106
+
107
+ def _load_skill_from_file(skill_file: Path) -> SkillDefinition | None:
108
+ """Parse a single SKILL.md file and return a SkillDefinition, or None on error."""
109
+ try:
110
+ text = skill_file.read_text(encoding="utf-8")
111
+ except OSError:
112
+ return None
113
+
114
+ fm, body = _parse_frontmatter(text)
115
+ if not body.strip() and not fm:
116
+ return None
117
+
118
+ # Derive name: prefer frontmatter, fall back to parent dir name
119
+ name = fm.get("name") or skill_file.parent.name
120
+ description = fm.get("description", "")
121
+ when_to_use = fm.get("when_to_use") or None
122
+
123
+ raw_paths = fm.get("paths", "")
124
+ paths = _parse_list_field(raw_paths)
125
+
126
+ pathPatterns = _parse_list_field(fm.get("pathPatterns", ""))
127
+ bashPatterns = _parse_list_field(fm.get("bashPatterns", ""))
128
+ importPatterns = _parse_list_field(fm.get("importPatterns", ""))
129
+ chainTo = _parse_list_field(fm.get("chainTo", ""))
130
+
131
+ return SkillDefinition(
132
+ name=name,
133
+ description=description,
134
+ content=body.strip(),
135
+ source_path=str(skill_file),
136
+ when_to_use=when_to_use,
137
+ paths=paths,
138
+ pathPatterns=pathPatterns,
139
+ bashPatterns=bashPatterns,
140
+ importPatterns=importPatterns,
141
+ chainTo=chainTo,
142
+ )
143
+
144
+
145
+ def _scan_skills_dir(base: Path) -> dict[str, SkillDefinition]:
146
+ """Scan a skills directory for <skill-name>/SKILL.md entries."""
147
+ skills: dict[str, SkillDefinition] = {}
148
+ if not base.is_dir():
149
+ return skills
150
+
151
+ for entry in sorted(base.iterdir()):
152
+ if not (entry.is_dir() or entry.is_symlink()):
153
+ continue
154
+ skill_file = entry / "SKILL.md"
155
+ if not skill_file.is_file():
156
+ continue
157
+ skill = _load_skill_from_file(skill_file)
158
+ if skill:
159
+ skills[skill.name] = skill
160
+
161
+ return skills
162
+
163
+
164
+ def _dirs_up_to_home(cwd: str, config_dir: str) -> list[Path]:
165
+ """Return skill dirs from home down to cwd for a given config directory name.
166
+
167
+ Walking home → cwd means closer (more specific) directories override
168
+ farther ones when we merge.
169
+ """
170
+ home = Path.home()
171
+ current = Path(cwd).resolve()
172
+ dirs: list[Path] = []
173
+
174
+ path = current
175
+ while True:
176
+ # Skip the home directory itself — it's covered by the global scan
177
+ if path != home:
178
+ dirs.append(path / config_dir / "skills")
179
+ if path == home or path.parent == path:
180
+ break
181
+ path = path.parent
182
+
183
+ # Reverse so home-adjacent dirs come first (lower priority), cwd last (highest)
184
+ dirs.reverse()
185
+ return dirs
186
+
187
+
188
+ def load_skills(cwd: str) -> list[SkillDefinition]:
189
+ """Load all skills visible from *cwd*, merged by priority.
190
+
191
+ Skills with the same name loaded later override earlier ones.
192
+ """
193
+ merged: dict[str, SkillDefinition] = {}
194
+ home = Path.home()
195
+
196
+ # 1. ~/.claude/skills/ — lowest priority (Claude Code global compat)
197
+ for name, skill in _scan_skills_dir(home / ".claude" / "skills").items():
198
+ merged[name] = skill
199
+
200
+ # 2. CrabCode global skills — overrides claude global
201
+ for name, skill in _scan_skills_dir(get_config_home() / "skills").items():
202
+ merged[name] = skill
203
+
204
+ # 3. Project-level .claude/skills/ dirs (home-adjacent → cwd)
205
+ for skills_dir in _dirs_up_to_home(cwd, ".claude"):
206
+ for name, skill in _scan_skills_dir(skills_dir).items():
207
+ merged[name] = skill
208
+
209
+ # 4. Project-level .crabcode/skills/ dirs (highest priority)
210
+ for skills_dir in _dirs_up_to_home(cwd, ".crabcode"):
211
+ for name, skill in _scan_skills_dir(skills_dir).items():
212
+ merged[name] = skill
213
+
214
+ return list(merged.values())
@@ -0,0 +1,203 @@
1
+ """Skill auto-trigger matcher.
2
+
3
+ Matches user context (file paths, bash commands, import lines) against
4
+ skill patterns and returns a list of skills to auto-invoke, in order.
5
+
6
+ Pattern types:
7
+ - paths: glob patterns used as a gating filter — when declared, the skill
8
+ only activates if at least one user-mentioned file path matches; if no
9
+ other positive pattern (pathPatterns/bashPatterns/importPatterns) is
10
+ declared, paths alone also acts as the positive trigger
11
+ - pathPatterns: glob patterns matched against file paths the user is
12
+ working with (from conversation context, git status, etc.)
13
+ - bashPatterns: regex patterns matched against bash commands the user
14
+ has run or wants to run
15
+ - importPatterns: regex patterns matched against import/require lines
16
+ found in the project or mentioned by the user
17
+
18
+ Chain routing:
19
+ - chainTo: after a skill executes, automatically queue the listed
20
+ skill(s) as follow-ups
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import fnmatch
26
+ import re
27
+ from typing import TYPE_CHECKING, Any
28
+
29
+ if TYPE_CHECKING:
30
+ from crabcode_core.skills.loader import SkillDefinition
31
+
32
+
33
+ def _fnmatch_any(fpath: str, patterns: list[str]) -> bool:
34
+ """Return True if *fpath* matches any glob in *patterns* (case-insensitive)."""
35
+ for pattern in patterns:
36
+ if fnmatch.fnmatch(fpath, pattern) or fnmatch.fnmatch(fpath.lower(), pattern.lower()):
37
+ return True
38
+ return False
39
+
40
+
41
+ def match_path_patterns(
42
+ skills: list[SkillDefinition], file_paths: list[str],
43
+ ) -> list[SkillDefinition]:
44
+ """Return skills whose pathPatterns or paths match any of the given file paths.
45
+
46
+ ``pathPatterns`` is a positive match — the skill triggers when a user-mentioned
47
+ file path matches. ``paths`` is a gating filter — when declared, the skill
48
+ only triggers if at least one user-mentioned file path falls under one of the
49
+ declared paths. If a skill declares ``paths`` but no ``pathPatterns``, the
50
+ paths gate itself serves as the positive trigger (gate passed ⇒ match).
51
+ """
52
+ matched: list[SkillDefinition] = []
53
+ for skill in skills:
54
+ # If `paths` is declared, at least one user file must match it
55
+ paths_gate_passed = not skill.paths or any(
56
+ _fnmatch_any(fp, skill.paths) for fp in file_paths
57
+ )
58
+ if not paths_gate_passed:
59
+ continue
60
+ # pathPatterns positive match
61
+ if skill.pathPatterns:
62
+ if any(_fnmatch_any(fp, skill.pathPatterns) for fp in file_paths):
63
+ matched.append(skill)
64
+ elif skill.paths:
65
+ # paths alone acts as positive trigger when no pathPatterns declared
66
+ matched.append(skill)
67
+ return matched
68
+
69
+
70
+ def match_bash_patterns(
71
+ skills: list[SkillDefinition], bash_commands: list[str],
72
+ file_paths: list[str] | None = None,
73
+ ) -> list[SkillDefinition]:
74
+ """Return skills whose bashPatterns match any of the given bash commands.
75
+
76
+ If a skill declares ``paths``, at least one user-mentioned file path must
77
+ match (same gating logic as ``match_path_patterns``).
78
+ """
79
+ matched: list[SkillDefinition] = []
80
+ for skill in skills:
81
+ if skill.paths and not any(_fnmatch_any(fp, skill.paths) for fp in (file_paths or [])):
82
+ continue
83
+ if not skill.bashPatterns:
84
+ continue
85
+ for pattern in skill.bashPatterns:
86
+ try:
87
+ regex = re.compile(pattern)
88
+ except re.error:
89
+ continue
90
+ for cmd in bash_commands:
91
+ if regex.search(cmd):
92
+ matched.append(skill)
93
+ break
94
+ else:
95
+ continue
96
+ break
97
+ return matched
98
+
99
+
100
+ def match_import_patterns(
101
+ skills: list[SkillDefinition], import_lines: list[str],
102
+ file_paths: list[str] | None = None,
103
+ ) -> list[SkillDefinition]:
104
+ """Return skills whose importPatterns match any of the given import lines.
105
+
106
+ If a skill declares ``paths``, at least one user-mentioned file path must
107
+ match (same gating logic as ``match_path_patterns``).
108
+ """
109
+ matched: list[SkillDefinition] = []
110
+ for skill in skills:
111
+ if skill.paths and not any(_fnmatch_any(fp, skill.paths) for fp in (file_paths or [])):
112
+ continue
113
+ if not skill.importPatterns:
114
+ continue
115
+ for pattern in skill.importPatterns:
116
+ try:
117
+ regex = re.compile(pattern)
118
+ except re.error:
119
+ continue
120
+ for line in import_lines:
121
+ if regex.search(line):
122
+ matched.append(skill)
123
+ break
124
+ else:
125
+ continue
126
+ break
127
+ return matched
128
+
129
+
130
+ def resolve_chain(
131
+ skill: SkillDefinition,
132
+ skill_map: dict[str, SkillDefinition],
133
+ visited: set[str] | None = None,
134
+ ) -> list[SkillDefinition]:
135
+ """Resolve the chainTo routing for a skill, returning the full chain.
136
+
137
+ Detects circular references and skips unknown skill names.
138
+ The source skill's name is added to visited to prevent cycles.
139
+ """
140
+ if visited is None:
141
+ visited = set()
142
+ visited.add(skill.name)
143
+
144
+ chain: list[SkillDefinition] = []
145
+ if not skill.chainTo:
146
+ return chain
147
+
148
+ for target_name in skill.chainTo:
149
+ if target_name in visited:
150
+ continue # break circular chain
151
+ target = skill_map.get(target_name)
152
+ if not target:
153
+ continue
154
+ visited.add(target_name)
155
+ chain.append(target)
156
+ # Recursively resolve nested chains
157
+ chain.extend(resolve_chain(target, skill_map, visited))
158
+
159
+ return chain
160
+
161
+
162
+ def auto_match(
163
+ skills: list[SkillDefinition],
164
+ file_paths: list[str] | None = None,
165
+ bash_commands: list[str] | None = None,
166
+ import_lines: list[str] | None = None,
167
+ ) -> list[SkillDefinition]:
168
+ """Match skills against context, returning ordered results with chains.
169
+
170
+ Deduplicates and preserves insertion order. Chain-following skills
171
+ are appended after their trigger skill.
172
+ """
173
+ file_paths = file_paths or []
174
+ bash_commands = bash_commands or []
175
+ import_lines = import_lines or []
176
+
177
+ if not skills:
178
+ return []
179
+
180
+ skill_map = {s.name: s for s in skills}
181
+ seen: set[str] = set()
182
+ result: list[SkillDefinition] = []
183
+
184
+ # Collect direct matches
185
+ direct_matches: list[SkillDefinition] = []
186
+ direct_matches.extend(match_path_patterns(skills, file_paths))
187
+ direct_matches.extend(match_bash_patterns(skills, bash_commands, file_paths=file_paths))
188
+ direct_matches.extend(match_import_patterns(skills, import_lines, file_paths=file_paths))
189
+
190
+ # Add matched skills + their chains, deduplicating
191
+ for skill in direct_matches:
192
+ if skill.name in seen:
193
+ continue
194
+ seen.add(skill.name)
195
+ result.append(skill)
196
+
197
+ # Resolve chainTo
198
+ for chained in resolve_chain(skill, skill_map):
199
+ if chained.name not in seen:
200
+ seen.add(chained.name)
201
+ result.append(chained)
202
+
203
+ return result
@@ -0,0 +1,21 @@
1
+ from crabcode_core.snapshot.snapshot import (
2
+ FileDiff,
3
+ Patch,
4
+ SnapshotManager,
5
+ )
6
+ from crabcode_core.snapshot.tracker import (
7
+ get_session_snapshots,
8
+ pre_bash_snapshot,
9
+ restore_snapshot,
10
+ track_snapshot_for_file,
11
+ )
12
+
13
+ __all__ = [
14
+ "FileDiff",
15
+ "Patch",
16
+ "SnapshotManager",
17
+ "get_session_snapshots",
18
+ "pre_bash_snapshot",
19
+ "restore_snapshot",
20
+ "track_snapshot_for_file",
21
+ ]