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 ADDED
@@ -0,0 +1,3 @@
1
+ """crucible: Code review orchestration MCP server."""
2
+
3
+ __version__ = "0.1.0"
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,5 @@
1
+ """Domain detection and routing."""
2
+
3
+ from crucible.domain.detection import detect_domain
4
+
5
+ __all__ = ["detect_domain"]
@@ -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."""