crucible-mcp 0.1.0__py3-none-any.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.
- crucible/__init__.py +3 -0
- crucible/cli.py +523 -0
- crucible/domain/__init__.py +5 -0
- crucible/domain/detection.py +67 -0
- crucible/errors.py +50 -0
- crucible/knowledge/__init__.py +1 -0
- crucible/knowledge/loader.py +141 -0
- crucible/models.py +61 -0
- crucible/server.py +376 -0
- crucible/synthesis/__init__.py +1 -0
- crucible/tools/__init__.py +21 -0
- crucible/tools/delegation.py +326 -0
- crucible_mcp-0.1.0.dist-info/METADATA +158 -0
- crucible_mcp-0.1.0.dist-info/RECORD +17 -0
- crucible_mcp-0.1.0.dist-info/WHEEL +5 -0
- crucible_mcp-0.1.0.dist-info/entry_points.txt +3 -0
- crucible_mcp-0.1.0.dist-info/top_level.txt +1 -0
crucible/__init__.py
ADDED
crucible/cli.py
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
"""crucible CLI."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
# Skills directories
|
|
9
|
+
SKILLS_BUNDLED = Path(__file__).parent / "skills"
|
|
10
|
+
SKILLS_USER = Path.home() / ".claude" / "crucible" / "skills"
|
|
11
|
+
SKILLS_PROJECT = Path(".crucible") / "skills"
|
|
12
|
+
|
|
13
|
+
# Knowledge directories
|
|
14
|
+
KNOWLEDGE_BUNDLED = Path(__file__).parent / "knowledge" / "principles"
|
|
15
|
+
KNOWLEDGE_USER = Path.home() / ".claude" / "crucible" / "knowledge"
|
|
16
|
+
KNOWLEDGE_PROJECT = Path(".crucible") / "knowledge"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resolve_skill(skill_name: str) -> tuple[Path | None, str]:
|
|
20
|
+
"""Find skill with cascade priority.
|
|
21
|
+
|
|
22
|
+
Returns (path, source) where source is 'project', 'user', or 'bundled'.
|
|
23
|
+
"""
|
|
24
|
+
# 1. Project-level (highest priority)
|
|
25
|
+
project_path = SKILLS_PROJECT / skill_name / "SKILL.md"
|
|
26
|
+
if project_path.exists():
|
|
27
|
+
return project_path, "project"
|
|
28
|
+
|
|
29
|
+
# 2. User-level
|
|
30
|
+
user_path = SKILLS_USER / skill_name / "SKILL.md"
|
|
31
|
+
if user_path.exists():
|
|
32
|
+
return user_path, "user"
|
|
33
|
+
|
|
34
|
+
# 3. Bundled (lowest priority)
|
|
35
|
+
bundled_path = SKILLS_BUNDLED / skill_name / "SKILL.md"
|
|
36
|
+
if bundled_path.exists():
|
|
37
|
+
return bundled_path, "bundled"
|
|
38
|
+
|
|
39
|
+
return None, ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_all_skill_names() -> set[str]:
|
|
43
|
+
"""Get all available skill names from all sources."""
|
|
44
|
+
names: set[str] = set()
|
|
45
|
+
|
|
46
|
+
for source_dir in [SKILLS_BUNDLED, SKILLS_USER, SKILLS_PROJECT]:
|
|
47
|
+
if source_dir.exists():
|
|
48
|
+
for skill_dir in source_dir.iterdir():
|
|
49
|
+
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
|
|
50
|
+
names.add(skill_dir.name)
|
|
51
|
+
|
|
52
|
+
return names
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def resolve_knowledge(filename: str) -> tuple[Path | None, str]:
|
|
56
|
+
"""Find knowledge file with cascade priority.
|
|
57
|
+
|
|
58
|
+
Returns (path, source) where source is 'project', 'user', or 'bundled'.
|
|
59
|
+
"""
|
|
60
|
+
# 1. Project-level (highest priority)
|
|
61
|
+
project_path = KNOWLEDGE_PROJECT / filename
|
|
62
|
+
if project_path.exists():
|
|
63
|
+
return project_path, "project"
|
|
64
|
+
|
|
65
|
+
# 2. User-level
|
|
66
|
+
user_path = KNOWLEDGE_USER / filename
|
|
67
|
+
if user_path.exists():
|
|
68
|
+
return user_path, "user"
|
|
69
|
+
|
|
70
|
+
# 3. Bundled (lowest priority)
|
|
71
|
+
bundled_path = KNOWLEDGE_BUNDLED / filename
|
|
72
|
+
if bundled_path.exists():
|
|
73
|
+
return bundled_path, "bundled"
|
|
74
|
+
|
|
75
|
+
return None, ""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_all_knowledge_files() -> set[str]:
|
|
79
|
+
"""Get all available knowledge file names from all sources."""
|
|
80
|
+
files: set[str] = set()
|
|
81
|
+
|
|
82
|
+
for source_dir in [KNOWLEDGE_BUNDLED, KNOWLEDGE_USER, KNOWLEDGE_PROJECT]:
|
|
83
|
+
if source_dir.exists():
|
|
84
|
+
for file_path in source_dir.iterdir():
|
|
85
|
+
if file_path.is_file() and file_path.suffix == ".md":
|
|
86
|
+
files.add(file_path.name)
|
|
87
|
+
|
|
88
|
+
return files
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# --- Skills commands ---
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def cmd_skills_install(args: argparse.Namespace) -> int:
|
|
95
|
+
"""Install crucible skills to ~/.claude/crucible/skills/."""
|
|
96
|
+
if not SKILLS_BUNDLED.exists():
|
|
97
|
+
print(f"Error: Skills source not found at {SKILLS_BUNDLED}")
|
|
98
|
+
return 1
|
|
99
|
+
|
|
100
|
+
# Create destination directory
|
|
101
|
+
SKILLS_USER.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
|
|
103
|
+
installed = []
|
|
104
|
+
for skill_dir in SKILLS_BUNDLED.iterdir():
|
|
105
|
+
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
|
|
106
|
+
dest = SKILLS_USER / skill_dir.name
|
|
107
|
+
if dest.exists() and not args.force:
|
|
108
|
+
print(f" Skip: {skill_dir.name} (exists, use --force to overwrite)")
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
if dest.exists():
|
|
112
|
+
shutil.rmtree(dest)
|
|
113
|
+
shutil.copytree(skill_dir, dest)
|
|
114
|
+
installed.append(skill_dir.name)
|
|
115
|
+
print(f" Installed: {skill_dir.name}")
|
|
116
|
+
|
|
117
|
+
if installed:
|
|
118
|
+
print(f"\n✓ Installed {len(installed)} skill(s) to {SKILLS_USER}")
|
|
119
|
+
else:
|
|
120
|
+
print("\nNo skills to install.")
|
|
121
|
+
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def cmd_skills_list(args: argparse.Namespace) -> int:
|
|
126
|
+
"""List available and installed skills."""
|
|
127
|
+
print("Bundled skills:")
|
|
128
|
+
if SKILLS_BUNDLED.exists():
|
|
129
|
+
for skill_dir in sorted(SKILLS_BUNDLED.iterdir()):
|
|
130
|
+
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
|
|
131
|
+
print(f" - {skill_dir.name}")
|
|
132
|
+
else:
|
|
133
|
+
print(" (none)")
|
|
134
|
+
|
|
135
|
+
print("\nUser skills (~/.claude/crucible/skills/):")
|
|
136
|
+
if SKILLS_USER.exists():
|
|
137
|
+
found = False
|
|
138
|
+
for skill_dir in sorted(SKILLS_USER.iterdir()):
|
|
139
|
+
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
|
|
140
|
+
print(f" - {skill_dir.name}")
|
|
141
|
+
found = True
|
|
142
|
+
if not found:
|
|
143
|
+
print(" (none)")
|
|
144
|
+
else:
|
|
145
|
+
print(" (none)")
|
|
146
|
+
|
|
147
|
+
print("\nProject skills (.crucible/skills/):")
|
|
148
|
+
if SKILLS_PROJECT.exists():
|
|
149
|
+
found = False
|
|
150
|
+
for skill_dir in sorted(SKILLS_PROJECT.iterdir()):
|
|
151
|
+
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
|
|
152
|
+
print(f" - {skill_dir.name}")
|
|
153
|
+
found = True
|
|
154
|
+
if not found:
|
|
155
|
+
print(" (none)")
|
|
156
|
+
else:
|
|
157
|
+
print(" (none)")
|
|
158
|
+
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def cmd_skills_init(args: argparse.Namespace) -> int:
|
|
163
|
+
"""Copy a skill to .crucible/skills/ for project-level customization."""
|
|
164
|
+
skill_name = args.skill
|
|
165
|
+
|
|
166
|
+
# Find source skill (user or bundled, not project)
|
|
167
|
+
user_path = SKILLS_USER / skill_name / "SKILL.md"
|
|
168
|
+
bundled_path = SKILLS_BUNDLED / skill_name / "SKILL.md"
|
|
169
|
+
|
|
170
|
+
if user_path.exists():
|
|
171
|
+
source_path = user_path.parent
|
|
172
|
+
source_label = "user"
|
|
173
|
+
elif bundled_path.exists():
|
|
174
|
+
source_path = bundled_path.parent
|
|
175
|
+
source_label = "bundled"
|
|
176
|
+
else:
|
|
177
|
+
print(f"Error: Skill '{skill_name}' not found")
|
|
178
|
+
print(f" Checked: {SKILLS_USER / skill_name}")
|
|
179
|
+
print(f" Checked: {SKILLS_BUNDLED / skill_name}")
|
|
180
|
+
return 1
|
|
181
|
+
|
|
182
|
+
# Destination
|
|
183
|
+
dest_path = SKILLS_PROJECT / skill_name
|
|
184
|
+
|
|
185
|
+
if dest_path.exists() and not args.force:
|
|
186
|
+
print(f"Error: {dest_path} already exists")
|
|
187
|
+
print(" Use --force to overwrite")
|
|
188
|
+
return 1
|
|
189
|
+
|
|
190
|
+
# Create and copy
|
|
191
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
if dest_path.exists():
|
|
193
|
+
shutil.rmtree(dest_path)
|
|
194
|
+
shutil.copytree(source_path, dest_path)
|
|
195
|
+
|
|
196
|
+
print(f"✓ Initialized {skill_name} from {source_label}")
|
|
197
|
+
print(f" → {dest_path}/SKILL.md")
|
|
198
|
+
print("\nEdit this file to customize the skill for your project.")
|
|
199
|
+
|
|
200
|
+
return 0
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def cmd_skills_show(args: argparse.Namespace) -> int:
|
|
204
|
+
"""Show skill resolution - which file is active."""
|
|
205
|
+
skill_name = args.skill
|
|
206
|
+
|
|
207
|
+
active_path, active_source = resolve_skill(skill_name)
|
|
208
|
+
|
|
209
|
+
if not active_path:
|
|
210
|
+
print(f"Skill '{skill_name}' not found")
|
|
211
|
+
return 1
|
|
212
|
+
|
|
213
|
+
print(f"{skill_name}")
|
|
214
|
+
|
|
215
|
+
# Project-level
|
|
216
|
+
project_path = SKILLS_PROJECT / skill_name / "SKILL.md"
|
|
217
|
+
if project_path.exists():
|
|
218
|
+
marker = " ← active" if active_source == "project" else ""
|
|
219
|
+
print(f" Project: {project_path}{marker}")
|
|
220
|
+
else:
|
|
221
|
+
print(" Project: (not set)")
|
|
222
|
+
|
|
223
|
+
# User-level
|
|
224
|
+
user_path = SKILLS_USER / skill_name / "SKILL.md"
|
|
225
|
+
if user_path.exists():
|
|
226
|
+
marker = " ← active" if active_source == "user" else ""
|
|
227
|
+
print(f" User: {user_path}{marker}")
|
|
228
|
+
else:
|
|
229
|
+
print(" User: (not installed)")
|
|
230
|
+
|
|
231
|
+
# Bundled
|
|
232
|
+
bundled_path = SKILLS_BUNDLED / skill_name / "SKILL.md"
|
|
233
|
+
if bundled_path.exists():
|
|
234
|
+
marker = " ← active" if active_source == "bundled" else ""
|
|
235
|
+
print(f" Bundled: {bundled_path}{marker}")
|
|
236
|
+
else:
|
|
237
|
+
print(" Bundled: (not available)")
|
|
238
|
+
|
|
239
|
+
return 0
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# --- Knowledge commands ---
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def cmd_knowledge_list(args: argparse.Namespace) -> int:
|
|
246
|
+
"""List available knowledge files."""
|
|
247
|
+
print("Bundled knowledge (templates):")
|
|
248
|
+
if KNOWLEDGE_BUNDLED.exists():
|
|
249
|
+
for file_path in sorted(KNOWLEDGE_BUNDLED.iterdir()):
|
|
250
|
+
if file_path.is_file() and file_path.suffix == ".md":
|
|
251
|
+
print(f" - {file_path.name}")
|
|
252
|
+
else:
|
|
253
|
+
print(" (none)")
|
|
254
|
+
|
|
255
|
+
print("\nUser knowledge (~/.claude/crucible/knowledge/):")
|
|
256
|
+
if KNOWLEDGE_USER.exists():
|
|
257
|
+
found = False
|
|
258
|
+
for file_path in sorted(KNOWLEDGE_USER.iterdir()):
|
|
259
|
+
if file_path.is_file() and file_path.suffix == ".md":
|
|
260
|
+
print(f" - {file_path.name}")
|
|
261
|
+
found = True
|
|
262
|
+
if not found:
|
|
263
|
+
print(" (none)")
|
|
264
|
+
else:
|
|
265
|
+
print(" (none)")
|
|
266
|
+
|
|
267
|
+
print("\nProject knowledge (.crucible/knowledge/):")
|
|
268
|
+
if KNOWLEDGE_PROJECT.exists():
|
|
269
|
+
found = False
|
|
270
|
+
for file_path in sorted(KNOWLEDGE_PROJECT.iterdir()):
|
|
271
|
+
if file_path.is_file() and file_path.suffix == ".md":
|
|
272
|
+
print(f" - {file_path.name}")
|
|
273
|
+
found = True
|
|
274
|
+
if not found:
|
|
275
|
+
print(" (none)")
|
|
276
|
+
else:
|
|
277
|
+
print(" (none)")
|
|
278
|
+
|
|
279
|
+
return 0
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def cmd_knowledge_init(args: argparse.Namespace) -> int:
|
|
283
|
+
"""Copy a knowledge file to .crucible/knowledge/ for project customization."""
|
|
284
|
+
filename = args.file
|
|
285
|
+
if not filename.endswith(".md"):
|
|
286
|
+
filename = f"{filename}.md"
|
|
287
|
+
|
|
288
|
+
# Find source (user or bundled, not project)
|
|
289
|
+
user_path = KNOWLEDGE_USER / filename
|
|
290
|
+
bundled_path = KNOWLEDGE_BUNDLED / filename
|
|
291
|
+
|
|
292
|
+
if user_path.exists():
|
|
293
|
+
source_path = user_path
|
|
294
|
+
source_label = "user"
|
|
295
|
+
elif bundled_path.exists():
|
|
296
|
+
source_path = bundled_path
|
|
297
|
+
source_label = "bundled"
|
|
298
|
+
else:
|
|
299
|
+
print(f"Error: Knowledge file '{filename}' not found")
|
|
300
|
+
print(f" Checked: {KNOWLEDGE_USER / filename}")
|
|
301
|
+
print(f" Checked: {KNOWLEDGE_BUNDLED / filename}")
|
|
302
|
+
print("\nAvailable files:")
|
|
303
|
+
for f in sorted(get_all_knowledge_files()):
|
|
304
|
+
print(f" - {f}")
|
|
305
|
+
return 1
|
|
306
|
+
|
|
307
|
+
# Destination
|
|
308
|
+
dest_path = KNOWLEDGE_PROJECT / filename
|
|
309
|
+
|
|
310
|
+
if dest_path.exists() and not args.force:
|
|
311
|
+
print(f"Error: {dest_path} already exists")
|
|
312
|
+
print(" Use --force to overwrite")
|
|
313
|
+
return 1
|
|
314
|
+
|
|
315
|
+
# Create and copy
|
|
316
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
317
|
+
shutil.copy2(source_path, dest_path)
|
|
318
|
+
|
|
319
|
+
print(f"✓ Initialized {filename} from {source_label}")
|
|
320
|
+
print(f" → {dest_path}")
|
|
321
|
+
print("\nEdit this file to customize for your project.")
|
|
322
|
+
|
|
323
|
+
return 0
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def cmd_knowledge_show(args: argparse.Namespace) -> int:
|
|
327
|
+
"""Show knowledge resolution - which file is active."""
|
|
328
|
+
filename = args.file
|
|
329
|
+
if not filename.endswith(".md"):
|
|
330
|
+
filename = f"{filename}.md"
|
|
331
|
+
|
|
332
|
+
active_path, active_source = resolve_knowledge(filename)
|
|
333
|
+
|
|
334
|
+
if not active_path:
|
|
335
|
+
print(f"Knowledge file '{filename}' not found")
|
|
336
|
+
return 1
|
|
337
|
+
|
|
338
|
+
print(f"{filename}")
|
|
339
|
+
|
|
340
|
+
# Project-level
|
|
341
|
+
project_path = KNOWLEDGE_PROJECT / filename
|
|
342
|
+
if project_path.exists():
|
|
343
|
+
marker = " ← active" if active_source == "project" else ""
|
|
344
|
+
print(f" Project: {project_path}{marker}")
|
|
345
|
+
else:
|
|
346
|
+
print(" Project: (not set)")
|
|
347
|
+
|
|
348
|
+
# User-level
|
|
349
|
+
user_path = KNOWLEDGE_USER / filename
|
|
350
|
+
if user_path.exists():
|
|
351
|
+
marker = " ← active" if active_source == "user" else ""
|
|
352
|
+
print(f" User: {user_path}{marker}")
|
|
353
|
+
else:
|
|
354
|
+
print(" User: (not installed)")
|
|
355
|
+
|
|
356
|
+
# Bundled
|
|
357
|
+
bundled_path = KNOWLEDGE_BUNDLED / filename
|
|
358
|
+
if bundled_path.exists():
|
|
359
|
+
marker = " ← active" if active_source == "bundled" else ""
|
|
360
|
+
print(f" Bundled: {bundled_path}{marker}")
|
|
361
|
+
else:
|
|
362
|
+
print(" Bundled: (not available)")
|
|
363
|
+
|
|
364
|
+
return 0
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def cmd_knowledge_install(args: argparse.Namespace) -> int:
|
|
368
|
+
"""Install knowledge files to ~/.claude/crucible/knowledge/."""
|
|
369
|
+
if not KNOWLEDGE_BUNDLED.exists():
|
|
370
|
+
print(f"Error: Knowledge source not found at {KNOWLEDGE_BUNDLED}")
|
|
371
|
+
return 1
|
|
372
|
+
|
|
373
|
+
# Create destination directory
|
|
374
|
+
KNOWLEDGE_USER.mkdir(parents=True, exist_ok=True)
|
|
375
|
+
|
|
376
|
+
installed = []
|
|
377
|
+
for file_path in KNOWLEDGE_BUNDLED.iterdir():
|
|
378
|
+
if file_path.is_file() and file_path.suffix == ".md":
|
|
379
|
+
dest = KNOWLEDGE_USER / file_path.name
|
|
380
|
+
if dest.exists() and not args.force:
|
|
381
|
+
print(f" Skip: {file_path.name} (exists, use --force to overwrite)")
|
|
382
|
+
continue
|
|
383
|
+
|
|
384
|
+
shutil.copy2(file_path, dest)
|
|
385
|
+
installed.append(file_path.name)
|
|
386
|
+
print(f" Installed: {file_path.name}")
|
|
387
|
+
|
|
388
|
+
if installed:
|
|
389
|
+
print(f"\n✓ Installed {len(installed)} file(s) to {KNOWLEDGE_USER}")
|
|
390
|
+
else:
|
|
391
|
+
print("\nNo files to install.")
|
|
392
|
+
|
|
393
|
+
return 0
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# --- Main ---
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def main() -> int:
|
|
400
|
+
"""CLI entry point."""
|
|
401
|
+
parser = argparse.ArgumentParser(
|
|
402
|
+
prog="crucible",
|
|
403
|
+
description="Code review orchestration",
|
|
404
|
+
)
|
|
405
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
406
|
+
|
|
407
|
+
# === skills command ===
|
|
408
|
+
skills_parser = subparsers.add_parser("skills", help="Manage review skills")
|
|
409
|
+
skills_sub = skills_parser.add_subparsers(dest="skills_command")
|
|
410
|
+
|
|
411
|
+
# skills install
|
|
412
|
+
install_parser = skills_sub.add_parser(
|
|
413
|
+
"install",
|
|
414
|
+
help="Install skills to ~/.claude/crucible/skills/"
|
|
415
|
+
)
|
|
416
|
+
install_parser.add_argument(
|
|
417
|
+
"--force", "-f", action="store_true", help="Overwrite existing skills"
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
# skills list
|
|
421
|
+
skills_sub.add_parser("list", help="List skills from all sources")
|
|
422
|
+
|
|
423
|
+
# skills init
|
|
424
|
+
init_parser = skills_sub.add_parser(
|
|
425
|
+
"init",
|
|
426
|
+
help="Copy a skill to .crucible/skills/ for project customization"
|
|
427
|
+
)
|
|
428
|
+
init_parser.add_argument("skill", help="Name of the skill to initialize")
|
|
429
|
+
init_parser.add_argument(
|
|
430
|
+
"--force", "-f", action="store_true", help="Overwrite existing project skill"
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
# skills show
|
|
434
|
+
show_parser = skills_sub.add_parser(
|
|
435
|
+
"show",
|
|
436
|
+
help="Show skill resolution (which file is active)"
|
|
437
|
+
)
|
|
438
|
+
show_parser.add_argument("skill", help="Name of the skill to show")
|
|
439
|
+
|
|
440
|
+
# === knowledge command ===
|
|
441
|
+
knowledge_parser = subparsers.add_parser("knowledge", help="Manage engineering knowledge")
|
|
442
|
+
knowledge_sub = knowledge_parser.add_subparsers(dest="knowledge_command")
|
|
443
|
+
|
|
444
|
+
# knowledge install
|
|
445
|
+
knowledge_install_parser = knowledge_sub.add_parser(
|
|
446
|
+
"install",
|
|
447
|
+
help="Install knowledge to ~/.claude/crucible/knowledge/"
|
|
448
|
+
)
|
|
449
|
+
knowledge_install_parser.add_argument(
|
|
450
|
+
"--force", "-f", action="store_true", help="Overwrite existing files"
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
# knowledge list
|
|
454
|
+
knowledge_sub.add_parser("list", help="List knowledge from all sources")
|
|
455
|
+
|
|
456
|
+
# knowledge init
|
|
457
|
+
knowledge_init_parser = knowledge_sub.add_parser(
|
|
458
|
+
"init",
|
|
459
|
+
help="Copy knowledge to .crucible/knowledge/ for project customization"
|
|
460
|
+
)
|
|
461
|
+
knowledge_init_parser.add_argument("file", help="Name of the file to initialize")
|
|
462
|
+
knowledge_init_parser.add_argument(
|
|
463
|
+
"--force", "-f", action="store_true", help="Overwrite existing project file"
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
# knowledge show
|
|
467
|
+
knowledge_show_parser = knowledge_sub.add_parser(
|
|
468
|
+
"show",
|
|
469
|
+
help="Show knowledge resolution (which file is active)"
|
|
470
|
+
)
|
|
471
|
+
knowledge_show_parser.add_argument("file", help="Name of the file to show")
|
|
472
|
+
|
|
473
|
+
args = parser.parse_args()
|
|
474
|
+
|
|
475
|
+
if args.command == "skills":
|
|
476
|
+
if args.skills_command == "install":
|
|
477
|
+
return cmd_skills_install(args)
|
|
478
|
+
elif args.skills_command == "list":
|
|
479
|
+
return cmd_skills_list(args)
|
|
480
|
+
elif args.skills_command == "init":
|
|
481
|
+
return cmd_skills_init(args)
|
|
482
|
+
elif args.skills_command == "show":
|
|
483
|
+
return cmd_skills_show(args)
|
|
484
|
+
else:
|
|
485
|
+
skills_parser.print_help()
|
|
486
|
+
return 0
|
|
487
|
+
elif args.command == "knowledge":
|
|
488
|
+
if args.knowledge_command == "install":
|
|
489
|
+
return cmd_knowledge_install(args)
|
|
490
|
+
elif args.knowledge_command == "list":
|
|
491
|
+
return cmd_knowledge_list(args)
|
|
492
|
+
elif args.knowledge_command == "init":
|
|
493
|
+
return cmd_knowledge_init(args)
|
|
494
|
+
elif args.knowledge_command == "show":
|
|
495
|
+
return cmd_knowledge_show(args)
|
|
496
|
+
else:
|
|
497
|
+
knowledge_parser.print_help()
|
|
498
|
+
return 0
|
|
499
|
+
else:
|
|
500
|
+
# Default help
|
|
501
|
+
print("crucible - Code review orchestration\n")
|
|
502
|
+
print("Commands:")
|
|
503
|
+
print(" crucible skills list List skills from all sources")
|
|
504
|
+
print(" crucible skills install Install skills to ~/.claude/crucible/")
|
|
505
|
+
print(" crucible skills init <skill> Copy skill for project customization")
|
|
506
|
+
print(" crucible skills show <skill> Show skill resolution")
|
|
507
|
+
print()
|
|
508
|
+
print(" crucible knowledge list List knowledge from all sources")
|
|
509
|
+
print(" crucible knowledge install Install knowledge to ~/.claude/crucible/")
|
|
510
|
+
print(" crucible knowledge init <file> Copy knowledge for project customization")
|
|
511
|
+
print(" crucible knowledge show <file> Show knowledge resolution")
|
|
512
|
+
print()
|
|
513
|
+
print(" crucible-mcp Run as MCP server\n")
|
|
514
|
+
print("MCP Tools:")
|
|
515
|
+
print(" quick_review Run static analysis, returns findings + domains")
|
|
516
|
+
print(" get_principles Load engineering checklists")
|
|
517
|
+
print(" delegate_* Direct tool access (semgrep, ruff, slither, bandit)")
|
|
518
|
+
print(" check_tools Show installed analysis tools")
|
|
519
|
+
return 0
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
if __name__ == "__main__":
|
|
523
|
+
sys.exit(main())
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Domain detection from code content and file paths."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from crucible.errors import Result, ok
|
|
6
|
+
from crucible.models import DOMAIN_HEURISTICS, Domain
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def detect_domain_from_extension(file_path: str) -> Domain | None:
|
|
10
|
+
"""Detect domain from file extension."""
|
|
11
|
+
ext = Path(file_path).suffix.lower()
|
|
12
|
+
for domain, heuristics in DOMAIN_HEURISTICS.items():
|
|
13
|
+
if ext in heuristics.get("extensions", []):
|
|
14
|
+
return domain
|
|
15
|
+
return None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def detect_domain_from_content(content: str) -> Domain | None:
|
|
19
|
+
"""Detect domain from code content markers and imports."""
|
|
20
|
+
content_lower = content.lower()
|
|
21
|
+
|
|
22
|
+
for domain, heuristics in DOMAIN_HEURISTICS.items():
|
|
23
|
+
# Check imports
|
|
24
|
+
for imp in heuristics.get("imports", []):
|
|
25
|
+
if imp.lower() in content_lower:
|
|
26
|
+
return domain
|
|
27
|
+
|
|
28
|
+
# Check markers
|
|
29
|
+
for marker in heuristics.get("markers", []):
|
|
30
|
+
if marker.lower() in content_lower:
|
|
31
|
+
return domain
|
|
32
|
+
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def detect_domain(
|
|
37
|
+
code: str,
|
|
38
|
+
file_path: str | None = None,
|
|
39
|
+
) -> Result[Domain, str]:
|
|
40
|
+
"""
|
|
41
|
+
Detect the domain of code.
|
|
42
|
+
|
|
43
|
+
Priority:
|
|
44
|
+
1. File extension (most reliable)
|
|
45
|
+
2. Content markers and imports
|
|
46
|
+
3. Unknown (fallback)
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
code: The source code content
|
|
50
|
+
file_path: Optional file path for extension-based detection
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Result containing detected Domain or error message
|
|
54
|
+
"""
|
|
55
|
+
# Try extension first
|
|
56
|
+
if file_path:
|
|
57
|
+
domain = detect_domain_from_extension(file_path)
|
|
58
|
+
if domain:
|
|
59
|
+
return ok(domain)
|
|
60
|
+
|
|
61
|
+
# Try content detection
|
|
62
|
+
domain = detect_domain_from_content(code)
|
|
63
|
+
if domain:
|
|
64
|
+
return ok(domain)
|
|
65
|
+
|
|
66
|
+
# Fallback to unknown
|
|
67
|
+
return ok(Domain.UNKNOWN)
|
crucible/errors.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Result types for errors as values."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Generic, TypeVar
|
|
5
|
+
|
|
6
|
+
T = TypeVar("T")
|
|
7
|
+
E = TypeVar("E")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Ok(Generic[T]):
|
|
12
|
+
"""Success result containing a value."""
|
|
13
|
+
|
|
14
|
+
value: T
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def is_ok(self) -> bool:
|
|
18
|
+
return True
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def is_err(self) -> bool:
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Err(Generic[E]):
|
|
27
|
+
"""Error result containing an error."""
|
|
28
|
+
|
|
29
|
+
error: E
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def is_ok(self) -> bool:
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def is_err(self) -> bool:
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
Result = Ok[T] | Err[E]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def ok(value: T) -> Ok[T]:
|
|
44
|
+
"""Create a success result."""
|
|
45
|
+
return Ok(value)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def err(error: E) -> Err[E]:
|
|
49
|
+
"""Create an error result."""
|
|
50
|
+
return Err(error)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Principles and knowledge loading."""
|