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.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- 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
|
+
]
|