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.
- package/README.en.md +14 -7
- package/README.md +192 -126
- package/bingo-light +116 -0
- package/bingo_core/__init__.py +1 -1
- package/bingo_core/dep.py +652 -0
- package/bingo_core/dep_npm.py +113 -0
- package/bingo_core/dep_pip.py +178 -0
- package/bingo_core/setup.py +73 -17
- package/completions/bingo-light.bash +14 -1
- package/completions/bingo-light.fish +25 -1
- package/completions/bingo-light.zsh +20 -0
- package/mcp-server.py +106 -1
- package/package.json +1 -1
|
@@ -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"
|
package/bingo_core/setup.py
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
187
|
+
return (python, [candidate])
|
|
179
188
|
|
|
180
189
|
# Fallback
|
|
181
|
-
return (
|
|
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
|
-
|
|
607
|
-
|
|
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.
|
|
862
|
+
"version": "2.1.2",
|
|
758
863
|
},
|
|
759
864
|
}))
|
|
760
865
|
|