@kennethsolomon/shipkit 3.20.0 → 3.21.0

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.
package/README.md CHANGED
@@ -365,7 +365,9 @@ Rule files in `.claude/rules/` auto-activate in Claude Code when you edit matchi
365
365
 
366
366
  ## MCP Servers
367
367
 
368
- Installed optionally by `/sk:setup-claude` and `/sk:setup-optimizer`.
368
+ ### Global (opt-in, installed to `~/.mcp.json`)
369
+
370
+ Installed by `/sk:setup-claude` and `/sk:setup-optimizer` when you opt in.
369
371
 
370
372
  | Server | What it does | Best for |
371
373
  |---|---|---|
@@ -373,6 +375,14 @@ Installed optionally by `/sk:setup-claude` and `/sk:setup-optimizer`.
373
375
  | **context7** | Fetches current, version-accurate docs for libraries you're using — no stale API suggestions | React 19, Next.js 15, Tailwind v4, shadcn/ui |
374
376
  | **ccstatusline** | Persistent statusline: context window %, model, git branch, current task | Every session |
375
377
 
378
+ ### Project-level (stack-conditional, installed to `.mcp.json`)
379
+
380
+ Added/removed automatically based on detected stack. No opt-in required.
381
+
382
+ | Server | Stack | What it does |
383
+ |---|---|---|
384
+ | **laravel-boost** | Laravel | Database schema, read-only queries, docs search (version-matched), application logs, browser errors, last exception, Eloquent model list |
385
+
376
386
  ---
377
387
 
378
388
  ## On-Demand Tools
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kennethsolomon/shipkit",
3
- "version": "3.20.0",
3
+ "version": "3.21.0",
4
4
  "description": "A structured workflow toolkit for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",
@@ -12,13 +12,14 @@ Configure this Laravel project with production-ready conventions. Safe to run on
12
12
  Invokes the `setup-claude` skill to:
13
13
 
14
14
  1. **Detect stack** from `composer.json` + `package.json`
15
- 2. **Install missing dev tools**: PHPStan/Larastan, Rector, Pint, Pest
16
- 3. **Publish config files** (if missing): `phpstan.neon`, `rector.php`, `pint.json`
17
- 4. **Configure strict models** in `AppServiceProvider`
18
- 5. **Generate `CLAUDE.md`** stack-aware, with full workflow, conventions, and sub-agent patterns
19
- 6. **Bootstrap `tasks/`**findings, lessons, todo, progress, security-findings
20
- 7. **Generate `.claude/commands/`** — lint, test
21
- 8. **Pre-seed `tasks/findings.md`** with detected stack info
15
+ 2. **Install missing dev tools**: PHPStan/Larastan, Rector, Pint, Pest, Laravel Boost
16
+ 3. **Configure Laravel Boost MCP** in `.mcp.json` — database schema, queries, docs search, logs, browser errors
17
+ 4. **Publish config files** (if missing): `phpstan.neon`, `rector.php`, `pint.json`
18
+ 5. **Configure strict models** in `AppServiceProvider`
19
+ 6. **Generate `CLAUDE.md`**stack-aware, with full workflow, conventions, and sub-agent patterns
20
+ 7. **Bootstrap `tasks/`** — findings, lessons, todo, progress, security-findings
21
+ 8. **Generate `.claude/commands/`** lint, test
22
+ 9. **Pre-seed `tasks/findings.md`** with detected stack info
22
23
 
23
24
  ## Idempotent
24
25
 
@@ -54,6 +54,7 @@ Location: [path]
54
54
 
55
55
  Files created:
56
56
  CLAUDE.md
57
+ .mcp.json (laravel-boost MCP server)
57
58
  phpstan.neon
58
59
  rector.php
59
60
  pint.json
@@ -277,8 +277,59 @@ composer require --dev laravel/pint
277
277
  # Pest (if missing)
278
278
  composer require --dev pestphp/pest pestphp/pest-plugin-laravel
279
279
  ./vendor/bin/pest --init
280
+
281
+ # Laravel Boost MCP (if missing) — provides database schema, queries, docs search, logs, browser errors
282
+ composer require --dev laravel/boost
283
+ ```
284
+
285
+ ### Laravel Boost MCP Configuration
286
+
287
+ **Do NOT run `php artisan boost:install`** — it generates its own CLAUDE.md and skills that conflict with ShipKit-managed files. We configure the MCP server entry directly.
288
+
289
+ After installing `laravel/boost`, configure the MCP server in the project's `.mcp.json` (create-if-missing, merge additively):
290
+
291
+ ```json
292
+ {
293
+ "mcpServers": {
294
+ "laravel-boost": {
295
+ "command": "php",
296
+ "args": ["artisan", "boost:mcp"],
297
+ "env": {}
298
+ }
299
+ }
300
+ }
280
301
  ```
281
302
 
303
+ For **Laravel Sail** projects (detected by `vendor/bin/sail` existing), use:
304
+
305
+ ```json
306
+ {
307
+ "mcpServers": {
308
+ "laravel-boost": {
309
+ "command": "vendor/bin/sail",
310
+ "args": ["artisan", "boost:mcp"],
311
+ "env": {}
312
+ }
313
+ }
314
+ }
315
+ ```
316
+
317
+ **Idempotency:** If `laravel-boost` already exists in `.mcp.json`, check whether the command matches the current Sail status. If `vendor/bin/sail` exists but the entry uses `php` (or vice versa), update the entry to match. Otherwise skip.
318
+
319
+ **What Boost MCP provides (9 tools):**
320
+
321
+ | Tool | Purpose |
322
+ |------|---------|
323
+ | ApplicationInfo | PHP/Laravel version, DB engine, installed packages, Eloquent models |
324
+ | DatabaseSchema | Full schema — tables, columns, indexes, foreign keys (summary + filter modes) |
325
+ | DatabaseQuery | Read-only SQL queries (SELECT, SHOW, EXPLAIN, DESCRIBE only) |
326
+ | DatabaseConnections | All configured DB connections and the default |
327
+ | SearchDocs | Laravel ecosystem docs (Laravel, Pest, Livewire, Filament, Tailwind, Inertia) |
328
+ | ReadLogEntries | Last N log entries (PSR-3 and JSON format) |
329
+ | BrowserLogs | Browser console errors/warnings from `storage/logs/browser.log` |
330
+ | LastError | Last exception from cache or log file |
331
+ | GetAbsoluteUrl | Convert relative paths or named routes to absolute URLs |
332
+
282
333
  ### Laravel Official Plugins
283
334
 
284
335
  After tool installation, suggest the two official Laravel plugins from Taylor Otwell:
@@ -1,9 +1,9 @@
1
1
  # ShipKit Skill Profiles — Stack-Based Filtering
2
2
 
3
- **Last Updated:** 2026-03-29
4
- **Total Skills:** 44 | **Agents:** 13 | **Rules:** 6
3
+ **Last Updated:** 2026-03-30
4
+ **Total Skills:** 44 | **Agents:** 13 | **Rules:** 6 | **Project MCP:** 1
5
5
 
6
- This file is the source of truth for which skills, agents, and rules get installed per project. Read by `sk:setup-claude` (initial setup) and `sk:setup-optimizer` (ongoing sync).
6
+ This file is the source of truth for which skills, agents, rules, and project-level MCP servers get installed per project. Read by `sk:setup-claude` (initial setup) and `sk:setup-optimizer` (ongoing sync).
7
7
 
8
8
  ---
9
9
 
@@ -179,6 +179,24 @@ These are installed universally because they run inside `sk:gates`. Perf auto-sk
179
179
 
180
180
  ---
181
181
 
182
+ ## Project-Level MCP Server → Stack Mapping
183
+
184
+ MCP servers configured in the project's `.mcp.json` (not global `~/.mcp.json`). Managed by `sk:setup-claude` (initial setup) and `sk:setup-optimizer` (ongoing sync).
185
+
186
+ | MCP Server | Stack | Command | Purpose |
187
+ |-----------|-------|---------|---------|
188
+ | laravel-boost | laravel | `php artisan boost:mcp` (or `vendor/bin/sail artisan boost:mcp` for Sail) | Database schema, queries, docs search, logs, browser errors |
189
+
190
+ **Sync rules:**
191
+ - **Add** to `.mcp.json` when stack matches and entry is missing
192
+ - **Remove** from `.mcp.json` when stack no longer matches (e.g., project switched from Laravel to Next.js)
193
+ - **Update** existing entry if command is stale (e.g., Sail added/removed — switch between `php` and `vendor/bin/sail`)
194
+ - Never touch MCP entries not in this table (user-added entries are preserved)
195
+ - Sail detection: use `vendor/bin/sail` command variant if `vendor/bin/sail` exists
196
+ - **Ownership:** Project-level MCP is managed by `sk:setup-claude` (initial) and `sk:setup-optimizer` Step 0.5 (ongoing). Step 1.7 handles only global MCP.
197
+
198
+ ---
199
+
182
200
  ## Installation Formula
183
201
 
184
202
  For a given project with detected `stack` and `capabilities`:
@@ -196,6 +214,10 @@ installed_agents = universal_agents
196
214
 
197
215
  installed_rules = universal_rules (tests.md)
198
216
  + stack_matching_rules(stack, capabilities)
217
+
218
+ project_mcp = stack_matching_mcp(stack)
219
+ # Add matching entries to .mcp.json
220
+ # Remove non-matching entries from .mcp.json (only managed entries — never touch user-added)
199
221
  ```
200
222
 
201
223
  User overrides (`extra`, `disabled`) are never touched by auto-detection.
@@ -58,6 +58,14 @@ def _has_dep(pkg: dict, name: str) -> bool:
58
58
  return name in deps
59
59
 
60
60
 
61
+ def _has_composer_dep(composer: dict, name: str) -> bool:
62
+ """Check if a Composer package is in require or require-dev."""
63
+ deps = {}
64
+ for key in ("require", "require-dev"):
65
+ deps.update(composer.get(key, {}) or {})
66
+ return name in deps
67
+
68
+
61
69
  def _any_dep_prefix(pkg: dict, prefix: str) -> bool:
62
70
  deps = {}
63
71
  for key in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
@@ -107,13 +115,16 @@ def _write_cached_detection(repo_root: Path, detection: Detection) -> None:
107
115
 
108
116
  def detect(repo_root: Path) -> Detection:
109
117
  package_json = _read_json(repo_root / "package.json") or {}
118
+ composer_json = _read_json(repo_root / "composer.json") or {}
110
119
  scripts = (package_json.get("scripts") or {}) if isinstance(package_json, dict) else {}
111
120
 
112
121
  project_name = str(package_json.get("name") or repo_root.name)
113
122
  description = str(package_json.get("description") or "Project instructions for Claude Code.")
114
123
 
115
- # Language
116
- if (repo_root / "tsconfig.json").exists() or _has_dep(package_json, "typescript"):
124
+ # Language — composer.json with laravel/framework takes priority over package.json
125
+ if _has_composer_dep(composer_json, "laravel/framework"):
126
+ language = "PHP"
127
+ elif (repo_root / "tsconfig.json").exists() or _has_dep(package_json, "typescript"):
117
128
  language = "TypeScript"
118
129
  elif (repo_root / "package.json").exists():
119
130
  language = "JavaScript"
@@ -129,7 +140,21 @@ def detect(repo_root: Path) -> Detection:
129
140
  language = "Unknown"
130
141
 
131
142
  # Framework (keep simple; expand later)
132
- if _has_dep(package_json, "next"):
143
+ is_laravel = _has_composer_dep(composer_json, "laravel/framework")
144
+ if is_laravel:
145
+ # Sub-detect Laravel stack flavor
146
+ if _has_composer_dep(composer_json, "inertiajs/inertia-laravel"):
147
+ if _has_dep(package_json, "react"):
148
+ framework = "Laravel (Inertia + React)"
149
+ elif _has_dep(package_json, "vue"):
150
+ framework = "Laravel (Inertia + Vue)"
151
+ else:
152
+ framework = "Laravel (Inertia)"
153
+ elif _has_composer_dep(composer_json, "livewire/livewire"):
154
+ framework = "Laravel (Livewire)"
155
+ else:
156
+ framework = "Laravel (API)"
157
+ elif _has_dep(package_json, "next"):
133
158
  framework = "Next.js (App Router)"
134
159
  elif _has_dep(package_json, "react"):
135
160
  framework = "React"
@@ -139,7 +164,9 @@ def detect(repo_root: Path) -> Detection:
139
164
  framework = "Unknown"
140
165
 
141
166
  # Database / ORM
142
- if _has_dep(package_json, "drizzle-orm"):
167
+ if is_laravel and (repo_root / "database" / "migrations").exists():
168
+ database = "Eloquent ORM"
169
+ elif _has_dep(package_json, "drizzle-orm"):
143
170
  if _has_dep(package_json, "better-sqlite3"):
144
171
  database = "Drizzle ORM + SQLite"
145
172
  elif _has_dep(package_json, "pg"):
@@ -165,7 +192,9 @@ def detect(repo_root: Path) -> Detection:
165
192
  ui = "Unknown"
166
193
 
167
194
  # Testing
168
- if _has_dep(package_json, "vitest"):
195
+ if _has_composer_dep(composer_json, "pestphp/pest"):
196
+ testing = "Pest"
197
+ elif _has_dep(package_json, "vitest"):
169
198
  testing = "Vitest"
170
199
  elif _has_dep(package_json, "jest"):
171
200
  testing = "Jest"
@@ -190,10 +219,16 @@ def detect(repo_root: Path) -> Detection:
190
219
  else:
191
220
  browser_automation = "None"
192
221
 
193
- dev_cmd = scripts.get("dev") and "npm run dev" or "npm run dev"
194
- build_cmd = scripts.get("build") and "npm run build" or "npm run build"
195
- lint_cmd = scripts.get("lint") and "npm run lint" or "npm run lint"
196
- test_cmd = scripts.get("test") and "npm test" or "npm test"
222
+ if is_laravel:
223
+ dev_cmd = "php artisan serve"
224
+ build_cmd = "npm run build"
225
+ lint_cmd = "vendor/bin/pint"
226
+ test_cmd = "vendor/bin/pest"
227
+ else:
228
+ dev_cmd = scripts.get("dev") and "npm run dev" or "npm run dev"
229
+ build_cmd = scripts.get("build") and "npm run build" or "npm run build"
230
+ lint_cmd = scripts.get("lint") and "npm run lint" or "npm run lint"
231
+ test_cmd = scripts.get("test") and "npm test" or "npm test"
197
232
 
198
233
  typo_dir = repo_root / ".claude" / "docs" / "achritectural_change_log"
199
234
  correct_dir = repo_root / ".claude" / "docs" / "architectural_change_log"
@@ -413,7 +448,7 @@ def _rules_filter(detection: Detection):
413
448
  def _filter(filename: str) -> bool:
414
449
  if filename in always:
415
450
  return True
416
- if filename == "laravel.md.template" and "Laravel" in detection.framework:
451
+ if filename == "laravel.md.template" and detection.framework.startswith("Laravel"):
417
452
  return True
418
453
  if filename == "react.md.template" and (
419
454
  "React" in detection.framework or detection.framework == "Next.js (App Router)"
@@ -424,6 +459,67 @@ def _rules_filter(detection: Detection):
424
459
  return _filter
425
460
 
426
461
 
462
+ # Stack-conditional MCP server mapping (managed entries only)
463
+ _MANAGED_MCP_SERVERS: Dict[str, dict] = {
464
+ "laravel-boost": {
465
+ "stack_prefix": "Laravel",
466
+ "command": "php",
467
+ "args": ["artisan", "boost:mcp"],
468
+ "sail_command": "vendor/bin/sail",
469
+ },
470
+ }
471
+
472
+
473
+ def _deploy_project_mcp(
474
+ repo_root: Path,
475
+ detection: Detection,
476
+ *,
477
+ dry_run: bool,
478
+ ) -> List[tuple]:
479
+ """Add/remove/update managed MCP entries in .mcp.json based on detected stack."""
480
+ mcp_path = repo_root / ".mcp.json"
481
+ results: List[tuple] = []
482
+
483
+ existing = _read_json(mcp_path) or {}
484
+ servers = existing.get("mcpServers", {})
485
+ changed = False
486
+
487
+ for name, config in _MANAGED_MCP_SERVERS.items():
488
+ stack_matches = detection.framework.startswith(config["stack_prefix"])
489
+ use_sail = (repo_root / "vendor" / "bin" / "sail").exists()
490
+ command = config["sail_command"] if use_sail else config["command"]
491
+ expected_entry = {"command": command, "args": config["args"], "env": {}}
492
+
493
+ if stack_matches:
494
+ if name not in servers:
495
+ servers[name] = expected_entry
496
+ changed = True
497
+ elif servers[name].get("command") != command:
498
+ # Sail status changed — update command
499
+ servers[name]["command"] = command
500
+ changed = True
501
+ else:
502
+ if name in servers:
503
+ del servers[name]
504
+ changed = True
505
+
506
+ if not changed:
507
+ results.append(("skipped", mcp_path))
508
+ return results
509
+
510
+ if dry_run:
511
+ action = "updated" if mcp_path.exists() else "created"
512
+ results.append((action, mcp_path))
513
+ return results
514
+
515
+ existing["mcpServers"] = servers
516
+ mcp_path.parent.mkdir(parents=True, exist_ok=True)
517
+ mcp_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
518
+ action = "updated" if mcp_path.exists() else "created"
519
+ results.append((action, mcp_path))
520
+ return results
521
+
522
+
427
523
  def apply(
428
524
  repo_root: Path,
429
525
  skill_root: Path,
@@ -571,6 +667,10 @@ def apply(
571
667
  action_sl = "created"
572
668
  _collect_results([(action_sl, statusline_dest)], repo_root, created, updated, skipped)
573
669
 
670
+ # --- Deploy project-level .mcp.json (stack-conditional) ---
671
+ mcp_results = _deploy_project_mcp(repo_root, detection, dry_run=dry_run)
672
+ _collect_results(mcp_results, repo_root, created, updated, skipped)
673
+
574
674
  if dry_run:
575
675
  print("setup-claude dry-run complete (no files written)")
576
676
  else:
@@ -19,6 +19,20 @@ paths:
19
19
  - **Config**: Access config via `config()` helper, never `env()` outside config files.
20
20
  - **Strict mode**: Models use strict mode (prevent lazy loading, silently discarding attributes, accessing missing attributes).
21
21
 
22
+ ## Laravel Boost MCP
23
+
24
+ Project MCP server auto-configured in `.mcp.json` by `/sk:laravel-init`. Provides 9 tools Claude can call directly:
25
+
26
+ | Tool | When to use |
27
+ |------|-------------|
28
+ | `DatabaseSchema` | Before writing migrations or queries — get exact column types, indexes, FKs |
29
+ | `DatabaseQuery` | Run read-only SELECTs to validate data assumptions |
30
+ | `SearchDocs` | Look up Laravel/Livewire/Filament/Pest docs matched to your installed versions |
31
+ | `ReadLogEntries` | Check recent errors without leaving Claude |
32
+ | `BrowserLogs` | Inspect browser console errors captured server-side |
33
+ | `LastError` | Get the last exception instantly |
34
+ | `ApplicationInfo` | Check installed packages and Eloquent models at session start |
35
+
22
36
  ## Code Refinement
23
37
 
24
38
  After running `/sk:execute-plan`, use the `laravel-simplifier` agent to refine recently modified PHP/Laravel code:
@@ -158,5 +158,272 @@ class TestApplySetupClaude(unittest.TestCase):
158
158
  self.assertIn("Notes:", buf.getvalue())
159
159
 
160
160
 
161
+ def test_laravel_detection_inertia_react(self):
162
+ mod = _load_apply_module()
163
+
164
+ with tempfile.TemporaryDirectory() as td:
165
+ repo_root = Path(td)
166
+ (repo_root / "composer.json").write_text(
167
+ json.dumps({
168
+ "require": {"laravel/framework": "^12.0"},
169
+ "require-dev": {
170
+ "pestphp/pest": "^3.0",
171
+ "inertiajs/inertia-laravel": "^2.0",
172
+ },
173
+ }),
174
+ encoding="utf-8",
175
+ )
176
+ (repo_root / "package.json").write_text(
177
+ json.dumps({"dependencies": {"react": "^19.0"}}),
178
+ encoding="utf-8",
179
+ )
180
+ (repo_root / "database" / "migrations").mkdir(parents=True)
181
+
182
+ detection = mod.detect(repo_root)
183
+ self.assertEqual(detection.framework, "Laravel (Inertia + React)")
184
+ self.assertEqual(detection.language, "PHP")
185
+ self.assertEqual(detection.database, "Eloquent ORM")
186
+ self.assertEqual(detection.testing, "Pest")
187
+ self.assertEqual(detection.dev_cmd, "php artisan serve")
188
+ self.assertEqual(detection.lint_cmd, "vendor/bin/pint")
189
+ self.assertEqual(detection.test_cmd, "vendor/bin/pest")
190
+
191
+ def test_laravel_detection_livewire(self):
192
+ mod = _load_apply_module()
193
+
194
+ with tempfile.TemporaryDirectory() as td:
195
+ repo_root = Path(td)
196
+ (repo_root / "composer.json").write_text(
197
+ json.dumps({
198
+ "require": {
199
+ "laravel/framework": "^12.0",
200
+ "livewire/livewire": "^3.0",
201
+ },
202
+ }),
203
+ encoding="utf-8",
204
+ )
205
+
206
+ detection = mod.detect(repo_root)
207
+ self.assertEqual(detection.framework, "Laravel (Livewire)")
208
+
209
+ def test_laravel_detection_api_only(self):
210
+ mod = _load_apply_module()
211
+
212
+ with tempfile.TemporaryDirectory() as td:
213
+ repo_root = Path(td)
214
+ (repo_root / "composer.json").write_text(
215
+ json.dumps({"require": {"laravel/framework": "^12.0"}}),
216
+ encoding="utf-8",
217
+ )
218
+
219
+ detection = mod.detect(repo_root)
220
+ self.assertEqual(detection.framework, "Laravel (API)")
221
+
222
+ def test_mcp_json_created_for_laravel(self):
223
+ mod = _load_apply_module()
224
+ skill_root = Path(__file__).resolve().parents[1]
225
+
226
+ with tempfile.TemporaryDirectory() as td:
227
+ repo_root = Path(td)
228
+ (repo_root / "composer.json").write_text(
229
+ json.dumps({"require": {"laravel/framework": "^12.0"}}),
230
+ encoding="utf-8",
231
+ )
232
+
233
+ buf = io.StringIO()
234
+ with contextlib.redirect_stdout(buf):
235
+ mod.apply(
236
+ repo_root,
237
+ skill_root,
238
+ update_generated=False,
239
+ dry_run=False,
240
+ detection=mod.detect(repo_root),
241
+ )
242
+
243
+ mcp_path = repo_root / ".mcp.json"
244
+ self.assertTrue(mcp_path.exists())
245
+ mcp_data = json.loads(mcp_path.read_text(encoding="utf-8"))
246
+ self.assertIn("laravel-boost", mcp_data["mcpServers"])
247
+ self.assertEqual(mcp_data["mcpServers"]["laravel-boost"]["command"], "php")
248
+
249
+ def test_mcp_json_not_created_for_nextjs(self):
250
+ mod = _load_apply_module()
251
+ skill_root = Path(__file__).resolve().parents[1]
252
+
253
+ with tempfile.TemporaryDirectory() as td:
254
+ repo_root = Path(td)
255
+ (repo_root / "package.json").write_text(
256
+ json.dumps({
257
+ "name": "demo",
258
+ "dependencies": {"next": "15.0.0", "react": "19.0.0"},
259
+ }),
260
+ encoding="utf-8",
261
+ )
262
+
263
+ buf = io.StringIO()
264
+ with contextlib.redirect_stdout(buf):
265
+ mod.apply(
266
+ repo_root,
267
+ skill_root,
268
+ update_generated=False,
269
+ dry_run=False,
270
+ detection=mod.detect(repo_root),
271
+ )
272
+
273
+ mcp_path = repo_root / ".mcp.json"
274
+ if mcp_path.exists():
275
+ mcp_data = json.loads(mcp_path.read_text(encoding="utf-8"))
276
+ self.assertNotIn("laravel-boost", mcp_data.get("mcpServers", {}))
277
+
278
+ def test_mcp_json_removed_when_stack_changes(self):
279
+ mod = _load_apply_module()
280
+ skill_root = Path(__file__).resolve().parents[1]
281
+
282
+ with tempfile.TemporaryDirectory() as td:
283
+ repo_root = Path(td)
284
+
285
+ # Start with Laravel — creates .mcp.json with laravel-boost
286
+ (repo_root / "composer.json").write_text(
287
+ json.dumps({"require": {"laravel/framework": "^12.0"}}),
288
+ encoding="utf-8",
289
+ )
290
+ laravel_detection = mod.detect(repo_root)
291
+
292
+ buf = io.StringIO()
293
+ with contextlib.redirect_stdout(buf):
294
+ mod.apply(
295
+ repo_root,
296
+ skill_root,
297
+ update_generated=False,
298
+ dry_run=False,
299
+ detection=laravel_detection,
300
+ )
301
+
302
+ mcp_path = repo_root / ".mcp.json"
303
+ self.assertTrue(mcp_path.exists())
304
+ mcp_data = json.loads(mcp_path.read_text(encoding="utf-8"))
305
+ self.assertIn("laravel-boost", mcp_data["mcpServers"])
306
+
307
+ # Switch to Next.js — laravel-boost should be removed
308
+ (repo_root / "composer.json").unlink()
309
+ (repo_root / "package.json").write_text(
310
+ json.dumps({
311
+ "name": "demo",
312
+ "dependencies": {"next": "15.0.0", "react": "19.0.0"},
313
+ }),
314
+ encoding="utf-8",
315
+ )
316
+ nextjs_detection = mod.detect(repo_root)
317
+
318
+ buf = io.StringIO()
319
+ with contextlib.redirect_stdout(buf):
320
+ mod.apply(
321
+ repo_root,
322
+ skill_root,
323
+ update_generated=False,
324
+ dry_run=False,
325
+ detection=nextjs_detection,
326
+ )
327
+
328
+ mcp_data = json.loads(mcp_path.read_text(encoding="utf-8"))
329
+ self.assertNotIn("laravel-boost", mcp_data.get("mcpServers", {}))
330
+
331
+ def test_mcp_json_sail_detection(self):
332
+ mod = _load_apply_module()
333
+ skill_root = Path(__file__).resolve().parents[1]
334
+
335
+ with tempfile.TemporaryDirectory() as td:
336
+ repo_root = Path(td)
337
+ (repo_root / "composer.json").write_text(
338
+ json.dumps({"require": {"laravel/framework": "^12.0"}}),
339
+ encoding="utf-8",
340
+ )
341
+ # Simulate Sail being present
342
+ (repo_root / "vendor" / "bin").mkdir(parents=True)
343
+ (repo_root / "vendor" / "bin" / "sail").write_text("#!/bin/sh\n", encoding="utf-8")
344
+
345
+ buf = io.StringIO()
346
+ with contextlib.redirect_stdout(buf):
347
+ mod.apply(
348
+ repo_root,
349
+ skill_root,
350
+ update_generated=False,
351
+ dry_run=False,
352
+ detection=mod.detect(repo_root),
353
+ )
354
+
355
+ mcp_path = repo_root / ".mcp.json"
356
+ mcp_data = json.loads(mcp_path.read_text(encoding="utf-8"))
357
+ self.assertEqual(
358
+ mcp_data["mcpServers"]["laravel-boost"]["command"],
359
+ "vendor/bin/sail",
360
+ )
361
+
362
+ def test_mcp_json_preserves_user_entries(self):
363
+ mod = _load_apply_module()
364
+ skill_root = Path(__file__).resolve().parents[1]
365
+
366
+ with tempfile.TemporaryDirectory() as td:
367
+ repo_root = Path(td)
368
+ (repo_root / "composer.json").write_text(
369
+ json.dumps({"require": {"laravel/framework": "^12.0"}}),
370
+ encoding="utf-8",
371
+ )
372
+
373
+ # Pre-existing user MCP entry
374
+ (repo_root / ".mcp.json").write_text(
375
+ json.dumps({
376
+ "mcpServers": {
377
+ "my-custom-server": {"command": "node", "args": ["server.js"]},
378
+ },
379
+ }),
380
+ encoding="utf-8",
381
+ )
382
+
383
+ buf = io.StringIO()
384
+ with contextlib.redirect_stdout(buf):
385
+ mod.apply(
386
+ repo_root,
387
+ skill_root,
388
+ update_generated=False,
389
+ dry_run=False,
390
+ detection=mod.detect(repo_root),
391
+ )
392
+
393
+ mcp_data = json.loads((repo_root / ".mcp.json").read_text(encoding="utf-8"))
394
+ self.assertIn("my-custom-server", mcp_data["mcpServers"])
395
+ self.assertIn("laravel-boost", mcp_data["mcpServers"])
396
+
397
+ def test_laravel_rules_filter(self):
398
+ mod = _load_apply_module()
399
+
400
+ with tempfile.TemporaryDirectory() as td:
401
+ repo_root = Path(td)
402
+ (repo_root / "composer.json").write_text(
403
+ json.dumps({"require": {"laravel/framework": "^12.0"}}),
404
+ encoding="utf-8",
405
+ )
406
+ detection = mod.detect(repo_root)
407
+ rule_filter = mod._rules_filter(detection)
408
+ self.assertTrue(rule_filter("laravel.md.template"))
409
+ self.assertTrue(rule_filter("tests.md.template"))
410
+
411
+ def test_nextjs_rules_filter_excludes_laravel(self):
412
+ mod = _load_apply_module()
413
+
414
+ with tempfile.TemporaryDirectory() as td:
415
+ repo_root = Path(td)
416
+ (repo_root / "package.json").write_text(
417
+ json.dumps({
418
+ "dependencies": {"next": "15.0.0", "react": "19.0.0"},
419
+ }),
420
+ encoding="utf-8",
421
+ )
422
+ detection = mod.detect(repo_root)
423
+ rule_filter = mod._rules_filter(detection)
424
+ self.assertFalse(rule_filter("laravel.md.template"))
425
+ self.assertTrue(rule_filter("react.md.template"))
426
+
427
+
161
428
  if __name__ == "__main__":
162
429
  unittest.main()
@@ -89,6 +89,10 @@ Rule changes:
89
89
  + migrations.md (database paths)
90
90
  No removals.
91
91
 
92
+ Project MCP changes:
93
+ - laravel-boost (stack is no longer laravel)
94
+ No additions.
95
+
92
96
  Apply changes? (y/n)
93
97
  ```
94
98
 
@@ -114,6 +118,13 @@ If the user confirms:
114
118
  - Remove stale rules: delete from `.claude/rules/` in the project if they no longer match
115
119
  - Never remove user-customized rules (same detection as agents)
116
120
 
121
+ **Project-level MCP sync** (sole owner of `.mcp.json` managed entries — Step 1.7 only handles global MCP):
122
+ - Read the MCP Server → Stack Mapping from `skill-profiles.md`
123
+ - **Add:** MCP entries to `.mcp.json` when stack matches and entry is missing
124
+ - **Remove:** MCP entries from `.mcp.json` when stack no longer matches (e.g., `laravel-boost` removed if stack changed from Laravel to Next.js). Only remove entries whose key matches the mapping table — never touch other entries.
125
+ - **Update:** If entry exists but command is stale (e.g., Sail added/removed since last setup — `vendor/bin/sail` exists but entry uses `php`, or vice versa), update the command to match current state
126
+ - For Laravel Boost Sail detection: use `vendor/bin/sail` command variant if `vendor/bin/sail` exists in the project
127
+
117
128
  **Config update:**
118
129
  - Update `.shipkit/config.json` with new `stack.detected`, `stack.detected_at`, `stack.capabilities`
119
130
 
@@ -245,9 +256,11 @@ After hooks deployment, check and configure LSP tooling:
245
256
 
246
257
  **Idempotency:** Never overwrite existing hook files — the user may have customized them. Only deploy hooks that don't exist yet. For settings.json, merge additively.
247
258
 
248
- ### Step 1.7: MCP Servers & Plugin Check
259
+ ### Step 1.7: Global MCP Servers & Plugin Check
260
+
261
+ After LSP check, verify the three recommended **global** tools are configured.
249
262
 
250
- After LSP check, verify the three recommended tools are configured:
263
+ > **Note:** Project-level MCP (`.mcp.json`) is managed exclusively by Step 0.5 during stack sync. This step only handles global MCP/plugins.
251
264
 
252
265
  1. **Sequential Thinking MCP** — grep `~/.mcp.json` for `sequential-thinking`
253
266
  2. **Context7 plugin** — grep `~/.claude/settings.json` for `context7@claude-plugins-official` in `enabledPlugins`
@@ -255,7 +268,7 @@ After LSP check, verify the three recommended tools are configured:
255
268
 
256
269
  **Report status and prompt:**
257
270
 
258
- > "MCP/Plugins: [X/3] configured
271
+ > "Global MCP/Plugins: [X/3] configured
259
272
  > sequential-thinking: [✓ configured / ✗ missing]
260
273
  > context7: [✓ configured / ✗ missing]
261
274
  > ccstatusline: [✓ configured / ✗ missing]