bingo-light 2.1.1 → 2.1.2

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.
@@ -0,0 +1,113 @@
1
+ """
2
+ bingo_core.dep_npm — npm/pnpm/yarn backend for dependency patching.
3
+
4
+ Detects npm projects, fetches original packages from registry,
5
+ resolves install paths in node_modules/.
6
+
7
+ Python 3.8+ stdlib only.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import tarfile
15
+ from typing import List, Optional
16
+ from urllib.request import urlopen
17
+
18
+ from bingo_core.dep import DepBackend
19
+
20
+
21
+ class NpmBackend(DepBackend):
22
+ """Backend for npm/pnpm/yarn packages."""
23
+
24
+ name = "npm"
25
+
26
+ def detect(self, cwd: str) -> bool:
27
+ """Detect npm project by presence of package.json or node_modules/."""
28
+ return (
29
+ os.path.isfile(os.path.join(cwd, "package.json"))
30
+ or os.path.isdir(os.path.join(cwd, "node_modules"))
31
+ )
32
+
33
+ def get_installed_version(self, package: str, cwd: str) -> Optional[str]:
34
+ """Get installed version from node_modules/<package>/package.json."""
35
+ pkg_json = os.path.join(cwd, "node_modules", package, "package.json")
36
+ if not os.path.isfile(pkg_json):
37
+ # Try scoped package
38
+ if "/" in package:
39
+ pkg_json = os.path.join(cwd, "node_modules", package, "package.json")
40
+ if not os.path.isfile(pkg_json):
41
+ return None
42
+ try:
43
+ with open(pkg_json) as f:
44
+ data = json.load(f)
45
+ return data.get("version")
46
+ except (json.JSONDecodeError, OSError):
47
+ return None
48
+
49
+ def get_install_path(self, package: str, cwd: str) -> Optional[str]:
50
+ """Get the filesystem path of the installed package."""
51
+ path = os.path.join(cwd, "node_modules", package)
52
+ if os.path.isdir(path):
53
+ return path
54
+ return None
55
+
56
+ def fetch_original(self, package: str, version: str, dest: str) -> bool:
57
+ """Download original package from npm registry and extract to dest.
58
+
59
+ Uses the npm registry API to get the tarball URL, downloads and
60
+ extracts it. The tarball contains a 'package/' prefix which we strip.
61
+ """
62
+ try:
63
+ # Fetch package metadata from registry
64
+ # Handle scoped packages: @scope/name -> @scope%2Fname
65
+ encoded = package.replace("/", "%2F")
66
+ url = f"https://registry.npmjs.org/{encoded}/{version}"
67
+ with urlopen(url, timeout=30) as resp:
68
+ meta = json.loads(resp.read().decode())
69
+
70
+ tarball_url = meta.get("dist", {}).get("tarball")
71
+ if not tarball_url:
72
+ return False
73
+
74
+ # Download tarball
75
+ tmptar = os.path.join(dest, "_pkg.tgz")
76
+ with urlopen(tarball_url, timeout=60) as resp:
77
+ with open(tmptar, "wb") as f:
78
+ f.write(resp.read())
79
+
80
+ # Extract (npm tarballs have a 'package/' prefix)
81
+ with tarfile.open(tmptar, "r:gz") as tar:
82
+ for member in tar.getmembers():
83
+ # Strip the 'package/' prefix
84
+ if member.name.startswith("package/"):
85
+ member.name = member.name[len("package/"):]
86
+ elif member.name == "package":
87
+ continue
88
+ # Security: skip absolute paths and ..
89
+ if member.name.startswith("/") or ".." in member.name:
90
+ continue
91
+ tar.extract(member, dest, filter="data" if hasattr(tarfile, 'data_filter') else None)
92
+
93
+ os.remove(tmptar)
94
+ return True
95
+
96
+ except Exception:
97
+ return False
98
+
99
+ def list_files(self, package: str, cwd: str) -> List[str]:
100
+ """List all files in the installed package."""
101
+ install_path = self.get_install_path(package, cwd)
102
+ if not install_path:
103
+ return []
104
+ result = []
105
+ for root, _dirs, files in os.walk(install_path):
106
+ for f in files:
107
+ full = os.path.join(root, f)
108
+ rel = os.path.relpath(full, install_path)
109
+ result.append(rel)
110
+ return sorted(result)
111
+
112
+ def install_hook_command(self) -> str:
113
+ return "bingo-light dep apply"
@@ -0,0 +1,178 @@
1
+ """
2
+ bingo_core.dep_pip — pip/pipx backend for dependency patching.
3
+
4
+ Detects Python projects, fetches original packages from PyPI,
5
+ resolves install paths in site-packages/.
6
+
7
+ Python 3.8+ stdlib only.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import site
15
+ import zipfile
16
+ from typing import List, Optional
17
+ from urllib.request import urlopen
18
+
19
+ from bingo_core.dep import DepBackend
20
+
21
+
22
+ class PipBackend(DepBackend):
23
+ """Backend for pip/pipx Python packages."""
24
+
25
+ name = "pip"
26
+
27
+ def detect(self, cwd: str) -> bool:
28
+ """Detect Python project by presence of requirements.txt, pyproject.toml, or venv."""
29
+ indicators = [
30
+ "requirements.txt",
31
+ "pyproject.toml",
32
+ "setup.py",
33
+ "setup.cfg",
34
+ "Pipfile",
35
+ ".venv",
36
+ "venv",
37
+ ]
38
+ return any(
39
+ os.path.exists(os.path.join(cwd, f)) for f in indicators
40
+ )
41
+
42
+ def _find_site_packages(self, cwd: str) -> List[str]:
43
+ """Find site-packages directories, preferring project venvs."""
44
+ dirs = []
45
+
46
+ # Check for project venv
47
+ for venv_name in (".venv", "venv"):
48
+ venv_path = os.path.join(cwd, venv_name)
49
+ if os.path.isdir(venv_path):
50
+ # Find site-packages inside venv
51
+ for root, dirnames, _files in os.walk(venv_path):
52
+ if "site-packages" in dirnames:
53
+ dirs.append(os.path.join(root, "site-packages"))
54
+ break
55
+
56
+ # System/user site-packages
57
+ dirs.extend(site.getsitepackages())
58
+ user_site = site.getusersitepackages()
59
+ if isinstance(user_site, str):
60
+ dirs.append(user_site)
61
+
62
+ return [d for d in dirs if os.path.isdir(d)]
63
+
64
+ def get_installed_version(self, package: str, cwd: str) -> Optional[str]:
65
+ """Get installed version via importlib.metadata or dist-info."""
66
+ # Normalize package name
67
+ normalized = package.replace("-", "_").lower()
68
+
69
+ for sp_dir in self._find_site_packages(cwd):
70
+ # Check dist-info directories
71
+ for entry in os.listdir(sp_dir):
72
+ if entry.endswith(".dist-info"):
73
+ dist_name = entry.rsplit("-", 1)[0].replace("-", "_").lower()
74
+ if dist_name == normalized:
75
+ metadata_path = os.path.join(sp_dir, entry, "METADATA")
76
+ if os.path.isfile(metadata_path):
77
+ with open(metadata_path) as f:
78
+ for line in f:
79
+ if line.startswith("Version:"):
80
+ return line.split(":", 1)[1].strip()
81
+ return None
82
+
83
+ def get_install_path(self, package: str, cwd: str) -> Optional[str]:
84
+ """Get the filesystem path of the installed package."""
85
+ normalized = package.replace("-", "_").lower()
86
+
87
+ for sp_dir in self._find_site_packages(cwd):
88
+ # Direct package directory
89
+ for entry in os.listdir(sp_dir):
90
+ if entry.replace("-", "_").lower() == normalized:
91
+ full = os.path.join(sp_dir, entry)
92
+ if os.path.isdir(full):
93
+ return full
94
+ return None
95
+
96
+ def fetch_original(self, package: str, version: str, dest: str) -> bool:
97
+ """Download original package from PyPI and extract to dest.
98
+
99
+ Prefers wheel (.whl) for consistency, falls back to sdist.
100
+ """
101
+ try:
102
+ # Fetch package metadata from PyPI JSON API
103
+ url = f"https://pypi.org/pypi/{package}/{version}/json"
104
+ with urlopen(url, timeout=30) as resp:
105
+ meta = json.loads(resp.read().decode())
106
+
107
+ # Find wheel URL (prefer), then sdist
108
+ download_url = None
109
+ is_wheel = False
110
+ for file_info in meta.get("urls", []):
111
+ if file_info.get("packagetype") == "bdist_wheel":
112
+ download_url = file_info["url"]
113
+ is_wheel = True
114
+ break
115
+ if not download_url:
116
+ for file_info in meta.get("urls", []):
117
+ if file_info.get("packagetype") == "sdist":
118
+ download_url = file_info["url"]
119
+ break
120
+
121
+ if not download_url:
122
+ return False
123
+
124
+ # Download
125
+ tmp_file = os.path.join(dest, "_pkg.tmp")
126
+ with urlopen(download_url, timeout=60) as resp:
127
+ with open(tmp_file, "wb") as f:
128
+ f.write(resp.read())
129
+
130
+ if is_wheel:
131
+ # Wheel is a zip file
132
+ normalized = package.replace("-", "_").lower()
133
+ with zipfile.ZipFile(tmp_file) as zf:
134
+ for member in zf.namelist():
135
+ # Extract only the package directory
136
+ parts = member.split("/")
137
+ if parts[0].replace("-", "_").lower() == normalized:
138
+ zf.extract(member, dest)
139
+ elif parts[0].endswith(".dist-info") or parts[0].endswith(".data"):
140
+ continue # Skip metadata
141
+ else:
142
+ # Single-file module
143
+ if member.endswith(".py"):
144
+ zf.extract(member, dest)
145
+ # Move extracted package dir to dest root
146
+ extracted = os.path.join(dest, normalized)
147
+ if os.path.isdir(extracted):
148
+ # Already in right place
149
+ pass
150
+ else:
151
+ # Sdist: tar.gz — extract and find the package dir
152
+ import tarfile
153
+ with tarfile.open(tmp_file, "r:gz") as tar:
154
+ tar.extractall(dest)
155
+ # Find the actual package inside the extracted tree
156
+ # Sdist typically has project-version/ at top level
157
+
158
+ os.remove(tmp_file)
159
+ return True
160
+
161
+ except Exception:
162
+ return False
163
+
164
+ def list_files(self, package: str, cwd: str) -> List[str]:
165
+ """List all files in the installed package."""
166
+ install_path = self.get_install_path(package, cwd)
167
+ if not install_path:
168
+ return []
169
+ result = []
170
+ for root, _dirs, files in os.walk(install_path):
171
+ for f in files:
172
+ full = os.path.join(root, f)
173
+ rel = os.path.relpath(full, install_path)
174
+ result.append(rel)
175
+ return sorted(result)
176
+
177
+ def install_hook_command(self) -> str:
178
+ return "bingo-light dep apply"
@@ -151,39 +151,85 @@ def _get_tools() -> List[AITool]:
151
151
  # ─── MCP Server Path Detection ──────────────────────────────────────────────
152
152
 
153
153
 
154
+ def _find_python_for_mcp() -> str:
155
+ """Find the correct python3 for running the MCP server.
156
+
157
+ When installed via pipx, system python3 can't import bingo_core.
158
+ We need to use the same python that runs bingo-light itself.
159
+ """
160
+ # Use the same python interpreter that's running this code
161
+ return sys.executable
162
+
163
+
154
164
  def find_mcp_server() -> Tuple[str, List[str]]:
155
165
  """Find the MCP server command and args.
156
166
 
157
- Returns (command, args) tuple. Tries:
158
- 1. Installed `bingo-light-mcp` on PATH
159
- 2. mcp-server.py next to the running script
160
- 3. mcp-server.py next to bingo_core package
167
+ Returns (command, args) tuple.
161
168
  """
162
- # 1. Installed binary on PATH
169
+ python = _find_python_for_mcp()
170
+
171
+ # 1. Installed binary on PATH (npm installs bingo-light-mcp)
163
172
  for name in ("bingo-light-mcp", "mcp-server.py"):
164
173
  mcp_bin = shutil.which(name)
165
174
  if mcp_bin:
166
- return ("python3", [mcp_bin])
175
+ return (python, [mcp_bin])
167
176
 
168
177
  # 2. Relative to running script
169
178
  script_dir = os.path.dirname(os.path.abspath(sys.argv[0]))
170
179
  candidate = os.path.join(script_dir, "mcp-server.py")
171
180
  if os.path.isfile(candidate):
172
- return ("python3", [candidate])
181
+ return (python, [candidate])
173
182
 
174
183
  # 3. Relative to bingo_core package
175
184
  pkg_dir = os.path.dirname(os.path.abspath(__file__))
176
185
  candidate = os.path.join(os.path.dirname(pkg_dir), "mcp-server.py")
177
186
  if os.path.isfile(candidate):
178
- return ("python3", [candidate])
187
+ return (python, [candidate])
179
188
 
180
189
  # Fallback
181
- return ("python3", ["bingo-light-mcp"])
190
+ return (python, ["bingo-light-mcp"])
182
191
 
183
192
 
184
193
  # ─── Config Writer ───────────────────────────────────────────────────────────
185
194
 
186
195
 
196
+ def _configure_claude_code_via_cli(
197
+ command: str,
198
+ args: List[str],
199
+ server_name: str = "bingo-light",
200
+ ) -> Optional[Dict[str, Any]]:
201
+ """Try to configure Claude Code using `claude mcp add` CLI.
202
+
203
+ Returns result dict if successful, None if claude CLI not available.
204
+ """
205
+ claude_bin = shutil.which("claude")
206
+ if not claude_bin:
207
+ return None
208
+
209
+ import subprocess
210
+ # Remove existing entry first (ignore errors if not present)
211
+ subprocess.run(
212
+ [claude_bin, "mcp", "remove", server_name],
213
+ capture_output=True, text=True,
214
+ )
215
+ # Add new entry: claude mcp add <name> -- <command> <args...>
216
+ cmd = [claude_bin, "mcp", "add", server_name, "--", command] + args
217
+ result = subprocess.run(cmd, capture_output=True, text=True)
218
+ if result.returncode == 0:
219
+ return {
220
+ "ok": True,
221
+ "tool": "claude-code",
222
+ "name": "Claude Code",
223
+ "config_path": "~/.claude.json (via claude mcp add)",
224
+ "action": "created",
225
+ }
226
+ return {
227
+ "ok": False,
228
+ "tool": "claude-code",
229
+ "error": result.stderr.strip() or "claude mcp add failed",
230
+ }
231
+
232
+
187
233
  def write_mcp_config(
188
234
  tool: AITool,
189
235
  command: str,
@@ -194,6 +240,13 @@ def write_mcp_config(
194
240
 
195
241
  Returns dict with ok, tool, config_path, action (created|updated|error).
196
242
  """
243
+ # Claude Code: prefer `claude mcp add` CLI (writes to correct location)
244
+ if tool.id == "claude-code":
245
+ cli_result = _configure_claude_code_via_cli(command, args, server_name)
246
+ if cli_result:
247
+ return cli_result
248
+ # Fall through to JSON file method if claude CLI not available
249
+
197
250
  config_path = tool.expand_path()
198
251
  config_dir = os.path.dirname(config_path)
199
252
 
@@ -594,17 +647,20 @@ def _get_skill_targets() -> List[SkillTarget]:
594
647
 
595
648
  def find_skill_file() -> Optional[str]:
596
649
  """Find the bingo.md skill file in common locations."""
597
- candidates = []
598
-
599
- script_dir = os.path.dirname(os.path.abspath(sys.argv[0]))
600
- candidates.append(os.path.join(script_dir, ".claude", "commands", "bingo.md"))
601
-
602
650
  pkg_dir = os.path.dirname(os.path.abspath(__file__))
651
+ script_dir = os.path.dirname(os.path.abspath(sys.argv[0]))
603
652
  repo_dir = os.path.dirname(pkg_dir)
604
- candidates.append(os.path.join(repo_dir, ".claude", "commands", "bingo.md"))
605
653
 
606
- # npm package layout
607
- candidates.append(os.path.join(script_dir, "..", ".claude", "commands", "bingo.md"))
654
+ candidates = [
655
+ # Bundled inside bingo_core/ (pip/npm installs)
656
+ os.path.join(pkg_dir, "_skill.md"),
657
+ # Repo layout
658
+ os.path.join(repo_dir, ".claude", "commands", "bingo.md"),
659
+ # Script-relative (install.sh)
660
+ os.path.join(script_dir, ".claude", "commands", "bingo.md"),
661
+ # npm package layout
662
+ os.path.join(script_dir, "..", ".claude", "commands", "bingo.md"),
663
+ ]
608
664
 
609
665
  for c in candidates:
610
666
  if os.path.isfile(c):
@@ -10,7 +10,7 @@ _bingo_light() {
10
10
  local cur prev words cword
11
11
  _init_completion || return
12
12
 
13
- local -r toplevel_commands="init setup patch sync status doctor auto-sync log undo diff version help conflict-analyze conflict-resolve config history test workspace smart-sync session"
13
+ local -r toplevel_commands="init setup patch dep sync status doctor auto-sync log undo diff version help conflict-analyze conflict-resolve config history test workspace smart-sync session"
14
14
  local -r toplevel_aliases="p s st d ws"
15
15
  local -r all_toplevel="${toplevel_commands} ${toplevel_aliases}"
16
16
 
@@ -36,6 +36,9 @@ _bingo_light() {
36
36
  diff|d)
37
37
  cmd="diff"
38
38
  ;;
39
+ dep)
40
+ cmd="dep"
41
+ ;;
39
42
  init|setup|doctor|auto-sync|log|undo|version|help|conflict-analyze|conflict-resolve|config|history|test|workspace|ws|smart-sync|session)
40
43
  cmd="${words[i]}"
41
44
  ;;
@@ -91,6 +94,16 @@ _bingo_light() {
91
94
  return
92
95
  fi
93
96
 
97
+ # Inside "dep" -- complete subcommands
98
+ if [[ "$cmd" == "dep" ]]; then
99
+ if [[ -z "$subcmd" ]]; then
100
+ COMPREPLY=( $(compgen -W "patch apply sync status list drop" -- "$cur") )
101
+ else
102
+ COMPREPLY=( $(compgen -W "--help -h" -- "$cur") )
103
+ fi
104
+ return
105
+ fi
106
+
94
107
  # Inside "workspace" (or alias "ws") -- complete subcommands
95
108
  if [[ "$cmd" == "workspace" || "$cmd" == "ws" ]]; then
96
109
  if [[ -z "$subcmd" ]]; then
@@ -68,6 +68,7 @@ complete -c bingo-light -f
68
68
  complete -c bingo-light -n __bingo_light_needs_command -a init -d 'Initialize a new bingo-light project'
69
69
  complete -c bingo-light -n __bingo_light_needs_command -a setup -d 'Configure MCP for AI tools (interactive)'
70
70
  complete -c bingo-light -n __bingo_light_needs_command -a patch -d 'Manage patches'
71
+ complete -c bingo-light -n __bingo_light_needs_command -a dep -d 'Patch npm/pip dependencies'
71
72
  complete -c bingo-light -n __bingo_light_needs_command -a sync -d 'Synchronize changes with upstream'
72
73
  complete -c bingo-light -n __bingo_light_needs_command -a status -d 'Show current status'
73
74
  complete -c bingo-light -n __bingo_light_needs_command -a doctor -d 'Diagnose and fix common problems'
@@ -124,7 +125,7 @@ complete -c bingo-light -n '__bingo_light_using_command version' -s h -l help
124
125
  complete -c bingo-light -n '__bingo_light_using_command conflict-resolve' -s h -l help -d 'Show help'
125
126
 
126
127
  # ---- help: complete with command names ----
127
- complete -c bingo-light -n '__bingo_light_using_command help' -a 'init setup patch sync status doctor auto-sync log undo diff version conflict-analyze conflict-resolve config history test workspace smart-sync session' -d 'Command'
128
+ complete -c bingo-light -n '__bingo_light_using_command help' -a 'init setup patch dep sync status doctor auto-sync log undo diff version conflict-analyze conflict-resolve config history test workspace smart-sync session' -d 'Command'
128
129
 
129
130
  # ---- patch subcommands (also alias "p") ----
130
131
  complete -c bingo-light -n __bingo_light_patch_needs_subcommand -a new -d 'Create a new patch'
@@ -163,6 +164,29 @@ complete -c bingo-light -n '__bingo_light_patch_using_subcommand reorder'
163
164
  complete -c bingo-light -n '__bingo_light_patch_using_subcommand squash' -s h -l help -d 'Show help'
164
165
  complete -c bingo-light -n '__bingo_light_patch_using_subcommand meta' -s h -l help -d 'Show help'
165
166
 
167
+ # ---- dep subcommands ----
168
+
169
+ function __bingo_light_dep_needs_subcommand
170
+ set -l cmd (commandline -opc)
171
+ if test (count $cmd) -lt 2
172
+ return 1
173
+ end
174
+ if test "$cmd[2]" != dep
175
+ return 1
176
+ end
177
+ if test (count $cmd) -eq 2
178
+ return 0
179
+ end
180
+ return 1
181
+ end
182
+
183
+ complete -c bingo-light -n __bingo_light_dep_needs_subcommand -a patch -d 'Patch a modified dependency'
184
+ complete -c bingo-light -n __bingo_light_dep_needs_subcommand -a apply -d 'Re-apply patches after install'
185
+ complete -c bingo-light -n __bingo_light_dep_needs_subcommand -a sync -d 'Re-apply after update, detect conflicts'
186
+ complete -c bingo-light -n __bingo_light_dep_needs_subcommand -a status -d 'Show patch health'
187
+ complete -c bingo-light -n __bingo_light_dep_needs_subcommand -a list -d 'List all dependency patches'
188
+ complete -c bingo-light -n __bingo_light_dep_needs_subcommand -a drop -d 'Remove a dependency patch'
189
+
166
190
  # ---- workspace subcommands (also alias "ws") ----
167
191
 
168
192
  # Returns true when we are inside "workspace" (or "ws") and need a workspace subcommand.
@@ -11,6 +11,7 @@ _bingo-light() {
11
11
  local -a toplevel_commands=(
12
12
  'init:Initialize a new bingo-light project'
13
13
  'patch:Manage patches'
14
+ 'dep:Patch npm/pip dependencies'
14
15
  'setup:Configure MCP for AI tools (interactive)'
15
16
  'sync:Synchronize changes with upstream'
16
17
  'status:Show current status'
@@ -136,6 +137,25 @@ _bingo-light() {
136
137
  diff|d)
137
138
  _arguments $help_flag
138
139
  ;;
140
+ dep)
141
+ local -a dep_subcommands=(
142
+ 'patch:Patch a modified dependency'
143
+ 'apply:Re-apply patches after install'
144
+ 'sync:Re-apply after update, detect conflicts'
145
+ 'status:Show patch health'
146
+ 'list:List all dependency patches'
147
+ 'drop:Remove a dependency patch'
148
+ )
149
+ _arguments -C \
150
+ '(- *)'{-h,--help}'[Show help]' \
151
+ '1:subcommand:->dep_subcmd' && return
152
+
153
+ case $state in
154
+ dep_subcmd)
155
+ _describe -t subcommands 'dep subcommand' dep_subcommands
156
+ ;;
157
+ esac
158
+ ;;
139
159
  workspace|ws)
140
160
  local -a ws_subcommands=(
141
161
  'init:Initialize workspace'
package/mcp-server.py CHANGED
@@ -469,6 +469,86 @@ TOOLS = [
469
469
  "required": ["cwd"]
470
470
  }
471
471
  },
472
+ # ── Dependency Patching Tools ─────────────────────────────────────────
473
+ {
474
+ "name": "bingo_dep_patch",
475
+ "description": (
476
+ "Create a patch for a modified npm/pip dependency. After modifying files in "
477
+ "node_modules/ or site-packages/, call this to generate a .patch file. "
478
+ "The patch survives npm install / pip install via `bingo_dep_apply`."
479
+ ),
480
+ "inputSchema": {
481
+ "type": "object",
482
+ "properties": {
483
+ "cwd": {"type": "string", "description": "Project directory"},
484
+ "package": {"type": "string", "description": "Package name (e.g. 'lodash', 'requests')"},
485
+ "patch_name": {"type": "string", "description": "Optional patch name"},
486
+ "description": {"type": "string", "description": "What this patch fixes"}
487
+ },
488
+ "required": ["cwd", "package"]
489
+ }
490
+ },
491
+ {
492
+ "name": "bingo_dep_apply",
493
+ "description": (
494
+ "Re-apply all dependency patches after npm install / pip install. "
495
+ "Call this as a postinstall hook or after any package manager update."
496
+ ),
497
+ "inputSchema": {
498
+ "type": "object",
499
+ "properties": {
500
+ "cwd": {"type": "string", "description": "Project directory"},
501
+ "package": {"type": "string", "description": "Optional: apply only this package's patches"}
502
+ },
503
+ "required": ["cwd"]
504
+ }
505
+ },
506
+ {
507
+ "name": "bingo_dep_status",
508
+ "description": (
509
+ "Show health of all dependency patches. Reports version mismatches "
510
+ "(upstream updated but patches were generated against old version)."
511
+ ),
512
+ "inputSchema": {
513
+ "type": "object",
514
+ "properties": {"cwd": {"type": "string", "description": "Project directory"}},
515
+ "required": ["cwd"]
516
+ }
517
+ },
518
+ {
519
+ "name": "bingo_dep_sync",
520
+ "description": (
521
+ "After npm update / pip install --upgrade, re-apply patches and detect conflicts. "
522
+ "Returns ok if all patches apply cleanly, or conflict details if patches broke."
523
+ ),
524
+ "inputSchema": {
525
+ "type": "object",
526
+ "properties": {"cwd": {"type": "string", "description": "Project directory"}},
527
+ "required": ["cwd"]
528
+ }
529
+ },
530
+ {
531
+ "name": "bingo_dep_list",
532
+ "description": "List all dependency patches across all tracked packages.",
533
+ "inputSchema": {
534
+ "type": "object",
535
+ "properties": {"cwd": {"type": "string", "description": "Project directory"}},
536
+ "required": ["cwd"]
537
+ }
538
+ },
539
+ {
540
+ "name": "bingo_dep_drop",
541
+ "description": "Remove a dependency patch or all patches for a package.",
542
+ "inputSchema": {
543
+ "type": "object",
544
+ "properties": {
545
+ "cwd": {"type": "string", "description": "Project directory"},
546
+ "package": {"type": "string", "description": "Package name"},
547
+ "patch_name": {"type": "string", "description": "Specific patch to drop (omit for all)"}
548
+ },
549
+ "required": ["cwd", "package"]
550
+ }
551
+ },
472
552
  ]
473
553
 
474
554
  # ─── Command Mapping ──────────────────────────────────────────────────────────
@@ -629,6 +709,31 @@ def handle_tool_call(name: str, arguments: dict) -> dict:
629
709
  elif name == "bingo_workspace_status":
630
710
  return _result(repo.workspace_status())
631
711
 
712
+ # ── Dependency patching tools ────────────────────────────────────
713
+ elif name.startswith("bingo_dep_"):
714
+ from bingo_core.dep import DepManager
715
+ dm = DepManager(cwd)
716
+
717
+ if name == "bingo_dep_patch":
718
+ return _result(dm.patch(
719
+ arguments["package"],
720
+ arguments.get("patch_name", ""),
721
+ arguments.get("description", ""),
722
+ ))
723
+ elif name == "bingo_dep_apply":
724
+ return _result(dm.apply(arguments.get("package", "")))
725
+ elif name == "bingo_dep_status":
726
+ return _result(dm.status())
727
+ elif name == "bingo_dep_sync":
728
+ return _result(dm.sync())
729
+ elif name == "bingo_dep_list":
730
+ return _result(dm.list_patches())
731
+ elif name == "bingo_dep_drop":
732
+ return _result(dm.drop(
733
+ arguments["package"],
734
+ arguments.get("patch_name", ""),
735
+ ))
736
+
632
737
  else:
633
738
  return {
634
739
  "content": [{"type": "text", "text": f"Unknown tool: {name}"}],
@@ -754,7 +859,7 @@ def main():
754
859
  "capabilities": {"tools": {}},
755
860
  "serverInfo": {
756
861
  "name": "bingo-light",
757
- "version": "2.1.1",
862
+ "version": "2.1.2",
758
863
  },
759
864
  }))
760
865