damon-devtools 0.1.0__tar.gz

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.
Files changed (29) hide show
  1. damon_devtools-0.1.0/PKG-INFO +46 -0
  2. damon_devtools-0.1.0/README.md +35 -0
  3. damon_devtools-0.1.0/damon_devtools.egg-info/PKG-INFO +46 -0
  4. damon_devtools-0.1.0/damon_devtools.egg-info/SOURCES.txt +27 -0
  5. damon_devtools-0.1.0/damon_devtools.egg-info/dependency_links.txt +1 -0
  6. damon_devtools-0.1.0/damon_devtools.egg-info/entry_points.txt +2 -0
  7. damon_devtools-0.1.0/damon_devtools.egg-info/requires.txt +1 -0
  8. damon_devtools-0.1.0/damon_devtools.egg-info/top_level.txt +1 -0
  9. damon_devtools-0.1.0/devtools/__init__.py +2 -0
  10. damon_devtools-0.1.0/devtools/__main__.py +53 -0
  11. damon_devtools-0.1.0/devtools/launcher.py +43 -0
  12. damon_devtools-0.1.0/devtools/tools/__init__.py +29 -0
  13. damon_devtools-0.1.0/devtools/tools/apkpull/__init__.py +1 -0
  14. damon_devtools-0.1.0/devtools/tools/apkpull/__main__.py +5 -0
  15. damon_devtools-0.1.0/devtools/tools/apkpull/core.py +123 -0
  16. damon_devtools-0.1.0/devtools/tools/apkpull/models.py +6 -0
  17. damon_devtools-0.1.0/devtools/tools/apkpull/ui.py +171 -0
  18. damon_devtools-0.1.0/devtools/tools/portkill/__init__.py +1 -0
  19. damon_devtools-0.1.0/devtools/tools/portkill/__main__.py +6 -0
  20. damon_devtools-0.1.0/devtools/tools/portkill/core.py +97 -0
  21. damon_devtools-0.1.0/devtools/tools/portkill/models.py +17 -0
  22. damon_devtools-0.1.0/devtools/tools/portkill/ui.py +203 -0
  23. damon_devtools-0.1.0/devtools/tools/removebg/__init__.py +0 -0
  24. damon_devtools-0.1.0/devtools/tools/removebg/__main__.py +5 -0
  25. damon_devtools-0.1.0/devtools/tools/removebg/cli.py +80 -0
  26. damon_devtools-0.1.0/devtools/tools/removebg/core.py +66 -0
  27. damon_devtools-0.1.0/devtools/types.py +9 -0
  28. damon_devtools-0.1.0/pyproject.toml +24 -0
  29. damon_devtools-0.1.0/setup.cfg +4 -0
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.4
2
+ Name: damon-devtools
3
+ Version: 0.1.0
4
+ Summary: Developer CLI tools — portkill, removebg, apkpull
5
+ Author: damon
6
+ License: MIT
7
+ Keywords: cli,devtools,android,apk,adb
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: psutil>=5.9
11
+
12
+ # devtools
13
+
14
+ Developer CLI tools: portkill, removebg, apkpull.
15
+
16
+ ## Tools
17
+
18
+ ### portkill
19
+ Kill Node.js processes by port or scan all node processes.
20
+
21
+ ```bash
22
+ python -m devtools portkill
23
+ python -m devtools portkill --listen-only
24
+ ```
25
+
26
+ ### apkpull
27
+ Pull APK files from Android device via ADB.
28
+
29
+ ```bash
30
+ python -m devtools apkpull [target_dir]
31
+ ```
32
+
33
+ ### removebg
34
+ Remove image backgrounds using the remove.bg API.
35
+
36
+ ```bash
37
+ python -m devtools removebg input.jpg
38
+ ```
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install damon-devtools
44
+ ```
45
+
46
+ Requires: Python >=3.10
@@ -0,0 +1,35 @@
1
+ # devtools
2
+
3
+ Developer CLI tools: portkill, removebg, apkpull.
4
+
5
+ ## Tools
6
+
7
+ ### portkill
8
+ Kill Node.js processes by port or scan all node processes.
9
+
10
+ ```bash
11
+ python -m devtools portkill
12
+ python -m devtools portkill --listen-only
13
+ ```
14
+
15
+ ### apkpull
16
+ Pull APK files from Android device via ADB.
17
+
18
+ ```bash
19
+ python -m devtools apkpull [target_dir]
20
+ ```
21
+
22
+ ### removebg
23
+ Remove image backgrounds using the remove.bg API.
24
+
25
+ ```bash
26
+ python -m devtools removebg input.jpg
27
+ ```
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install damon-devtools
33
+ ```
34
+
35
+ Requires: Python >=3.10
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.4
2
+ Name: damon-devtools
3
+ Version: 0.1.0
4
+ Summary: Developer CLI tools — portkill, removebg, apkpull
5
+ Author: damon
6
+ License: MIT
7
+ Keywords: cli,devtools,android,apk,adb
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: psutil>=5.9
11
+
12
+ # devtools
13
+
14
+ Developer CLI tools: portkill, removebg, apkpull.
15
+
16
+ ## Tools
17
+
18
+ ### portkill
19
+ Kill Node.js processes by port or scan all node processes.
20
+
21
+ ```bash
22
+ python -m devtools portkill
23
+ python -m devtools portkill --listen-only
24
+ ```
25
+
26
+ ### apkpull
27
+ Pull APK files from Android device via ADB.
28
+
29
+ ```bash
30
+ python -m devtools apkpull [target_dir]
31
+ ```
32
+
33
+ ### removebg
34
+ Remove image backgrounds using the remove.bg API.
35
+
36
+ ```bash
37
+ python -m devtools removebg input.jpg
38
+ ```
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install damon-devtools
44
+ ```
45
+
46
+ Requires: Python >=3.10
@@ -0,0 +1,27 @@
1
+ README.md
2
+ pyproject.toml
3
+ damon_devtools.egg-info/PKG-INFO
4
+ damon_devtools.egg-info/SOURCES.txt
5
+ damon_devtools.egg-info/dependency_links.txt
6
+ damon_devtools.egg-info/entry_points.txt
7
+ damon_devtools.egg-info/requires.txt
8
+ damon_devtools.egg-info/top_level.txt
9
+ devtools/__init__.py
10
+ devtools/__main__.py
11
+ devtools/launcher.py
12
+ devtools/types.py
13
+ devtools/tools/__init__.py
14
+ devtools/tools/apkpull/__init__.py
15
+ devtools/tools/apkpull/__main__.py
16
+ devtools/tools/apkpull/core.py
17
+ devtools/tools/apkpull/models.py
18
+ devtools/tools/apkpull/ui.py
19
+ devtools/tools/portkill/__init__.py
20
+ devtools/tools/portkill/__main__.py
21
+ devtools/tools/portkill/core.py
22
+ devtools/tools/portkill/models.py
23
+ devtools/tools/portkill/ui.py
24
+ devtools/tools/removebg/__init__.py
25
+ devtools/tools/removebg/__main__.py
26
+ devtools/tools/removebg/cli.py
27
+ devtools/tools/removebg/core.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ devtools = devtools.__main__:main
@@ -0,0 +1 @@
1
+ psutil>=5.9
@@ -0,0 +1,2 @@
1
+ """devtools - Developer tools CLI launcher"""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,53 @@
1
+ import sys
2
+ from importlib import import_module
3
+ from devtools.tools import get_tool
4
+
5
+
6
+ def run_tool(tool_name: str, args: list[str]):
7
+ tool = get_tool(tool_name)
8
+ if tool is None:
9
+ print(f"Unknown tool: {tool_name}")
10
+ print("Run `devtools` to see available tools.")
11
+ sys.exit(1)
12
+
13
+ module_name = tool.module
14
+
15
+ # Try module.run directly (if tool exposes run at module root)
16
+ mod = import_module(module_name)
17
+ if hasattr(mod, "run"):
18
+ mod.run(args)
19
+ return
20
+
21
+ # Try module.cli.run (removebg pattern)
22
+ try:
23
+ cli_mod = import_module(f"{module_name}.cli")
24
+ if hasattr(cli_mod, "run"):
25
+ cli_mod.run(args)
26
+ return
27
+ except ImportError:
28
+ pass
29
+
30
+ # Try module.ui.run (portkill pattern)
31
+ try:
32
+ ui_mod = import_module(f"{module_name}.ui")
33
+ if hasattr(ui_mod, "run"):
34
+ ui_mod.run(args)
35
+ return
36
+ except ImportError:
37
+ pass
38
+
39
+ print(f"Tool module {module_name} has no run() function")
40
+ sys.exit(1)
41
+
42
+
43
+ def main():
44
+ if len(sys.argv) > 1:
45
+ run_tool(sys.argv[1], sys.argv[2:])
46
+ else:
47
+ from devtools.launcher import render_launcher
48
+ result = render_launcher()
49
+ if result:
50
+ run_tool(result, [])
51
+
52
+ if __name__ == "__main__":
53
+ main()
@@ -0,0 +1,43 @@
1
+ import sys
2
+ if sys.platform == "win32":
3
+ sys.stdout.reconfigure(encoding="utf-8")
4
+
5
+ from devtools.tools import TOOLS
6
+
7
+
8
+ def render_launcher():
9
+ """
10
+ CLI launcher — list all tools with numbers, read selection.
11
+ Returns: tool name string to launch, or None to quit
12
+ """
13
+ if not TOOLS:
14
+ print("No tools registered!")
15
+ return None
16
+
17
+ print()
18
+ print("=" * 50)
19
+ print(" devtools — 开发者工具箱")
20
+ print("=" * 50)
21
+ print()
22
+ for i, tool in enumerate(TOOLS):
23
+ print(f" {i + 1}. {tool.name:15s} — {tool.description}")
24
+ print()
25
+ print(f" [1-{len(TOOLS)}] 选择工具 | [Q] 退出")
26
+ print()
27
+
28
+ try:
29
+ choice = input("> ").strip()
30
+ except (EOFError, KeyboardInterrupt):
31
+ return None
32
+
33
+ if choice.lower() == "q":
34
+ return None
35
+
36
+ try:
37
+ idx = int(choice) - 1
38
+ if 0 <= idx < len(TOOLS):
39
+ return TOOLS[idx].name
40
+ except ValueError:
41
+ pass
42
+
43
+ return None
@@ -0,0 +1,29 @@
1
+ """Tools registry for devtools"""
2
+ from devtools.types import Tool
3
+
4
+ TOOLS = [
5
+ Tool(
6
+ name="portkill",
7
+ category="进程/端口",
8
+ description="按端口杀 Node.js 进程",
9
+ module="devtools.tools.portkill",
10
+ ),
11
+ Tool(
12
+ name="removebg",
13
+ category="图像处理",
14
+ description="批量删除图片背景",
15
+ module="devtools.tools.removebg",
16
+ ),
17
+ Tool(
18
+ name="apkpull",
19
+ category="设备管理",
20
+ description="提取手机 APK 文件",
21
+ module="devtools.tools.apkpull",
22
+ ),
23
+ ]
24
+
25
+ def get_tool(name: str):
26
+ for tool in TOOLS:
27
+ if tool.name == name:
28
+ return tool
29
+ return None
@@ -0,0 +1 @@
1
+ """apkpull - Extract APK files from Android device via ADB"""
@@ -0,0 +1,5 @@
1
+ from devtools.tools.apkpull.ui import run
2
+
3
+ if __name__ == "__main__":
4
+ import sys
5
+ run(sys.argv[1:])
@@ -0,0 +1,123 @@
1
+ import subprocess
2
+ import re
3
+ import os
4
+ import tempfile
5
+
6
+ def _find_aapt() -> str | None:
7
+ """Find aapt.exe — checks PATH first, then Android SDK build-tools"""
8
+ # 1. Check PATH
9
+ import shutil as _shutil
10
+ path_aapt = _shutil.which("aapt")
11
+ if path_aapt and os.path.isfile(path_aapt):
12
+ return path_aapt
13
+ path_aapt2 = _shutil.which("aapt2")
14
+ if path_aapt2 and os.path.isfile(path_aapt2):
15
+ return path_aapt2
16
+ # 2. Check Android SDK build-tools
17
+ sdk_base = os.environ.get("LOCALAPPDATA", "") + "/Android/Sdk"
18
+ build_tools = os.path.join(sdk_base, "build-tools")
19
+ if os.path.isdir(build_tools):
20
+ versions = sorted(os.listdir(build_tools), reverse=True)
21
+ for v in versions:
22
+ for binary in ("aapt.exe", "aapt2.exe"):
23
+ aapt = os.path.join(build_tools, v, binary)
24
+ if os.path.isfile(aapt):
25
+ return aapt
26
+ return None
27
+
28
+ AAPT_PATH = _find_aapt()
29
+
30
+ def check_adb() -> bool:
31
+ """检查 ADB 是否可用"""
32
+ try:
33
+ subprocess.run(["adb", "version"], capture_output=True, check=True)
34
+ return True
35
+ except (subprocess.CalledProcessError, FileNotFoundError):
36
+ return False
37
+
38
+ def check_device() -> bool:
39
+ """检查设备是否连接"""
40
+ try:
41
+ result = subprocess.run(["adb", "devices"], capture_output=True, text=True, encoding="utf-8", errors="replace", check=True)
42
+ lines = result.stdout.strip().split("\n")
43
+ for line in lines[1:]:
44
+ if line.strip() and not line.startswith("*"):
45
+ parts = line.split()
46
+ if len(parts) >= 2 and parts[1] == "device":
47
+ return True
48
+ return False
49
+ except (subprocess.CalledProcessError, FileNotFoundError):
50
+ return False
51
+
52
+ def scan_packages() -> list[str]:
53
+ """执行 adb shell pm list packages -3,返回包名列表"""
54
+ result = subprocess.run(
55
+ ["adb", "shell", "pm", "list", "packages", "-3"],
56
+ capture_output=True, text=True, encoding="utf-8", errors="replace", check=True
57
+ )
58
+ pkgs = []
59
+ for line in result.stdout.strip().split("\n"):
60
+ line = line.strip()
61
+ if line.startswith("package:"):
62
+ pkgs.append(line[8:])
63
+ return pkgs
64
+
65
+ def get_apk_path(pkg: str) -> str | None:
66
+ """执行 pm path,返回 APK 物理路径"""
67
+ try:
68
+ result = subprocess.run(
69
+ ["adb", "shell", "pm", "path", pkg],
70
+ capture_output=True, text=True, encoding="utf-8", errors="replace", check=True
71
+ )
72
+ for line in result.stdout.strip().split("\n"):
73
+ if line.startswith("package:"):
74
+ return line[8:].strip()
75
+ return None
76
+ except (subprocess.CalledProcessError, FileNotFoundError):
77
+ return None
78
+
79
+ def get_app_name(pkg: str) -> str:
80
+ """通过 aapt 提取 APK 的 application label"""
81
+ if not AAPT_PATH:
82
+ return pkg
83
+ apk_path = get_apk_path(pkg)
84
+ if not apk_path:
85
+ return pkg
86
+ tmp = os.path.join(tempfile.gettempdir(), f"apkpull_{os.getpid()}_{pkg.replace('.', '_')}.apk")
87
+ try:
88
+ # pull APK to temp file
89
+ pull_ok = subprocess.run(
90
+ ["adb", "pull", apk_path, tmp],
91
+ capture_output=True, encoding="utf-8", errors="replace"
92
+ ).returncode == 0
93
+ if not pull_ok or not os.path.exists(tmp):
94
+ return pkg
95
+ # extract label via aapt
96
+ result = subprocess.run(
97
+ [AAPT_PATH, "dump", "badging", tmp],
98
+ capture_output=True, encoding="utf-8", errors="replace"
99
+ )
100
+ for line in result.stdout.split("\n"):
101
+ m = re.search(r"application-label:'(.+?)'", line)
102
+ if m:
103
+ return m.group(1)
104
+ return pkg
105
+ except Exception:
106
+ return pkg
107
+ finally:
108
+ if os.path.exists(tmp):
109
+ try:
110
+ os.remove(tmp)
111
+ except OSError:
112
+ pass
113
+
114
+ def pull_apk(apk_path: str, dest_dir: str) -> bool:
115
+ """执行 adb pull,拉取 APK 到目标目录"""
116
+ try:
117
+ subprocess.run(
118
+ ["adb", "pull", apk_path, dest_dir],
119
+ capture_output=True, text=True, encoding="utf-8", errors="replace", check=True
120
+ )
121
+ return True
122
+ except (subprocess.CalledProcessError, FileNotFoundError):
123
+ return False
@@ -0,0 +1,6 @@
1
+ from dataclasses import dataclass
2
+
3
+ @dataclass
4
+ class PackageInfo:
5
+ pkg: str # 包名 (com.example.app)
6
+ label: str # 应用名 (My App)
@@ -0,0 +1,171 @@
1
+ import sys
2
+ if sys.platform == "win32":
3
+ sys.stdout.reconfigure(encoding="utf-8")
4
+
5
+ import os
6
+ import threading
7
+ from devtools.tools.apkpull.models import PackageInfo
8
+
9
+ def scan_packages_with_labels() -> list[PackageInfo]:
10
+ """Scan packages and fetch labels in parallel threads (max 10 concurrent)"""
11
+ from devtools.tools.apkpull.core import scan_packages, check_adb, check_device, get_app_name
12
+
13
+ if not check_adb():
14
+ print("错误: 未找到 adb 命令,请确保 Android SDK platform-tools 已安装并加入 PATH")
15
+ return []
16
+ if not check_device():
17
+ print("错误: 未检测到设备,请确保手机已通过 USB 连接并开启了 USB 调试模式")
18
+ return []
19
+
20
+ print("正在获取应用列表...")
21
+ pkgs = scan_packages()
22
+ if not pkgs:
23
+ print("未找到第三方应用")
24
+ return []
25
+
26
+ print(f"共 {len(pkgs)} 个应用,正在获取应用名称...")
27
+
28
+ packages: list[PackageInfo] = []
29
+ lock = threading.Lock()
30
+
31
+ def fetch(pkg: str):
32
+ try:
33
+ label = get_app_name(pkg)
34
+ except Exception:
35
+ label = pkg # fallback on error
36
+ with lock:
37
+ packages.append(PackageInfo(pkg=pkg, label=label))
38
+
39
+ threads: list[threading.Thread] = []
40
+ for pkg in pkgs:
41
+ t = threading.Thread(target=fetch, args=(pkg,))
42
+ t.start()
43
+ threads.append(t)
44
+ if len(threads) >= 10:
45
+ threads[0].join()
46
+ threads = threads[1:]
47
+
48
+ for t in threads:
49
+ t.join()
50
+
51
+ packages.sort(key=lambda x: x.label.lower())
52
+ return packages
53
+
54
+ def show_packages(packages: list[PackageInfo], short: bool = True) -> None:
55
+ print()
56
+ print(f"{'#':<5} {'应用名':<30} {'包名'}")
57
+ print("-" * 90)
58
+ for i, p in enumerate(packages):
59
+ label = p.label[:28] + ".." if short and len(p.label) > 30 else p.label
60
+ pkg = p.pkg[:50] if short and len(p.pkg) > 50 else p.pkg
61
+ print(f"{i+1:<5} {label:<30} {pkg}")
62
+
63
+ def select_packages(packages: list[PackageInfo], user_input: str) -> list[PackageInfo]:
64
+ """Parse user input: keyword search or numeric selection (1,3-5, 1,3,5)"""
65
+ if not user_input:
66
+ return []
67
+ # If input contains letters, treat as search keyword
68
+ if any(c.isalpha() for c in user_input):
69
+ keyword = user_input.lower()
70
+ return [p for p in packages if keyword in p.label.lower() or keyword in p.pkg.lower()]
71
+ # Numeric selection
72
+ indices: list[int] = []
73
+ for part in user_input.split(","):
74
+ part = part.strip()
75
+ if "-" in part:
76
+ start, end = part.split("-", 1)
77
+ try:
78
+ indices.extend(range(int(start) - 1, int(end) + 1)) # +1 because range end is exclusive
79
+ except ValueError:
80
+ print(f"无效输入: {part}")
81
+ return []
82
+ else:
83
+ try:
84
+ indices.append(int(part) - 1)
85
+ except ValueError:
86
+ print(f"无效输入: {part}")
87
+ return []
88
+ return [packages[i] for i in indices if 0 <= i < len(packages)]
89
+
90
+ def confirm_pull(packages: list[PackageInfo], dest_dir: str) -> bool:
91
+ print()
92
+ print("=" * 70)
93
+ for p in packages:
94
+ print(f" 应用名: {p.label}")
95
+ print(f" 包名: {p.pkg}")
96
+ print("-" * 70)
97
+ print(f"目标目录: {dest_dir}")
98
+ print(f"共 {len(packages)} 个 APK")
99
+ print()
100
+ try:
101
+ answer = input("确认拉取? (y/N): ").strip().lower()
102
+ except (EOFError, KeyboardInterrupt):
103
+ return False
104
+ return answer in ("y", "yes")
105
+
106
+ def run(args: list[str] | None = None):
107
+ """Main entry point"""
108
+ if args is None:
109
+ args = []
110
+
111
+ # Determine destination directory
112
+ if args:
113
+ dest_dir = args[0]
114
+ else:
115
+ dest_dir = os.getcwd()
116
+
117
+ packages = scan_packages_with_labels()
118
+ if not packages:
119
+ return
120
+
121
+ show_packages(packages)
122
+
123
+ while True:
124
+ print()
125
+ print("输入应用名/包名关键字搜索,或输入序号选择 (1,3-5, 1,3,5)")
126
+ print("回车退出")
127
+
128
+ try:
129
+ user_input = input("\n> ").strip()
130
+ except (EOFError, KeyboardInterrupt):
131
+ print()
132
+ return
133
+
134
+ if not user_input:
135
+ print("未选择,退出")
136
+ return
137
+
138
+ selected = select_packages(packages, user_input)
139
+ if not selected:
140
+ print("未匹配到应用")
141
+ continue
142
+
143
+ show_packages(selected, short=False)
144
+
145
+ if not confirm_pull(selected, dest_dir):
146
+ print("取消拉取")
147
+ continue
148
+
149
+ # Pull APKs
150
+ from devtools.tools.apkpull.core import get_apk_path, pull_apk
151
+ succeeded: list[str] = []
152
+ failed: list[tuple] = []
153
+
154
+ for p in selected:
155
+ apk_path = get_apk_path(p.pkg)
156
+ if not apk_path:
157
+ failed.append((p.label, "未找到 APK 路径"))
158
+ continue
159
+ if pull_apk(apk_path, dest_dir):
160
+ succeeded.append(p.label)
161
+ else:
162
+ failed.append((p.label, "拉取失败"))
163
+
164
+ print()
165
+ if succeeded:
166
+ print(f"成功: {len(succeeded)} 个")
167
+ if failed:
168
+ print(f"失败: {len(failed)} 个")
169
+ for label, reason in failed:
170
+ print(f" {label}: {reason}")
171
+ return
@@ -0,0 +1 @@
1
+ """portkill - kill Node.js processes by port"""
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from devtools.tools.portkill.ui import run
4
+
5
+ if __name__ == "__main__":
6
+ run(sys.argv[1:])
@@ -0,0 +1,97 @@
1
+ import os
2
+ import signal
3
+ import psutil
4
+ from typing import List, Dict
5
+ from devtools.tools.portkill.models import ProcessInfo
6
+
7
+
8
+ def scan_node_ports() -> List[ProcessInfo]:
9
+ """扫描所有 Node.js 进程,返回按 PID 升序排列的列表"""
10
+ results: List[ProcessInfo] = []
11
+ seen_pids: Dict[int, ProcessInfo] = {}
12
+
13
+ # 首先找出所有 Node.js 进程
14
+ node_processes = []
15
+ for proc in psutil.process_iter():
16
+ try:
17
+ name = proc.name().lower()
18
+ if "node" not in name:
19
+ continue
20
+ node_processes.append(proc)
21
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
22
+ continue
23
+
24
+ # 获取所有监听中的 TCP 连接(用于查端口)
25
+ connections_by_pid: Dict[int, List[int]] = {}
26
+ for conn in psutil.net_connections(kind="inet"):
27
+ if conn.status != "LISTEN":
28
+ continue
29
+ if not conn.laddr:
30
+ continue
31
+ pid = conn.pid
32
+ port = conn.laddr.port
33
+ if pid not in connections_by_pid:
34
+ connections_by_pid[pid] = []
35
+ connections_by_pid[pid].append(port)
36
+
37
+ # 为每个 Node.js 进程创建 ProcessInfo
38
+ for proc in node_processes:
39
+ pid = proc.pid
40
+ if pid in seen_pids:
41
+ continue
42
+
43
+ ports = connections_by_pid.get(pid, [])
44
+ port = ports[0] if ports else 0
45
+
46
+ try:
47
+ cmdline = " ".join(proc.cmdline())
48
+ cwd = proc.cwd()
49
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
50
+ cmdline = ""
51
+ cwd = ""
52
+
53
+ try:
54
+ children = [child.pid for child in proc.children(recursive=True)]
55
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
56
+ children = []
57
+
58
+ seen_pids[pid] = ProcessInfo(
59
+ pid=pid,
60
+ port=port,
61
+ name=proc.name(),
62
+ cmdline=cmdline,
63
+ cwd=cwd,
64
+ children=children,
65
+ )
66
+
67
+ results = list(seen_pids.values())
68
+ results.sort(key=lambda x: (-x.port, x.pid)) # 有端口优先,按端口降序;无端口按 PID 升序
69
+ return results
70
+
71
+
72
+ def get_process_tree(pid: int) -> List[int]:
73
+ """获取指定进程及其所有子进程的 PID 列表"""
74
+ try:
75
+ proc = psutil.Process(pid)
76
+ pids = [pid]
77
+ for child in proc.children(recursive=True):
78
+ pids.append(child.pid)
79
+ return pids
80
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
81
+ return [pid] # 进程不存在时只返回自身
82
+
83
+
84
+ def kill_process(pid: int, kill_children: bool = False) -> bool:
85
+ """向目标进程发送 SIGTERM 信号,成功返回 True"""
86
+ try:
87
+ proc = psutil.Process(pid)
88
+ if kill_children:
89
+ for child in proc.children(recursive=True):
90
+ try:
91
+ os.kill(child.pid, signal.SIGTERM)
92
+ except (OSError, psutil.NoSuchProcess):
93
+ pass
94
+ os.kill(pid, signal.SIGTERM)
95
+ return True
96
+ except (psutil.NoSuchProcess, psutil.AccessDenied, OSError):
97
+ return False
@@ -0,0 +1,17 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Optional
3
+
4
+
5
+ @dataclass
6
+ class ProcessInfo:
7
+ """Node.js 进程信息"""
8
+ pid: int
9
+ port: int
10
+ name: str
11
+ cmdline: str
12
+ cwd: str
13
+ children: list[int] = field(default_factory=list)
14
+ cmdline_short: Optional[str] = None # 截断后的命令摘要
15
+
16
+ def __post_init__(self):
17
+ self.cmdline_short = self.cmdline
@@ -0,0 +1,203 @@
1
+ import sys
2
+ if sys.platform == "win32":
3
+ sys.stdout.reconfigure(encoding="utf-8")
4
+
5
+ from devtools.tools.portkill.models import ProcessInfo
6
+
7
+
8
+ def show_processes(processes: list[ProcessInfo], short: bool = False) -> None:
9
+ """Display processes in a simple table"""
10
+ print()
11
+ print(f"{'#':<4} {'PORT':<6} {'PID':<8} {'NAME':<20} {'CMDLINE'}")
12
+ print("-" * 120)
13
+ for i, p in enumerate(processes):
14
+ port_str = str(p.port) if p.port > 0 else "-"
15
+ cmdline = p.cmdline
16
+ if short:
17
+ cmdline = (p.cmdline[:50] + "...") if len(p.cmdline) > 50 else p.cmdline
18
+ print(f"{i+1:<4} {port_str:<6} {p.pid:<8} {p.name:<20} {cmdline}")
19
+
20
+
21
+ def select_processes(processes: list[ProcessInfo], user_input: str) -> list[ProcessInfo]:
22
+ """
23
+ Parse user selection from input string.
24
+ Supports: 1,3-5 or 1,3,5 or 1-3 style selection.
25
+ Returns selected ProcessInfo list.
26
+ """
27
+ if not processes:
28
+ print("没有发现 Node.js 进程")
29
+ return []
30
+
31
+ if not user_input:
32
+ return []
33
+
34
+ # Parse selection
35
+ indices: list[int] = []
36
+ for part in user_input.split(","):
37
+ part = part.strip()
38
+ if not part:
39
+ continue
40
+ if "-" in part:
41
+ start, end = part.split("-", 1)
42
+ try:
43
+ indices.extend(range(int(start) - 1, int(end)))
44
+ except ValueError:
45
+ print(f"无效输入: {part}")
46
+ return []
47
+ else:
48
+ try:
49
+ indices.append(int(part) - 1)
50
+ except ValueError:
51
+ print(f"无效输入: {part}")
52
+ return []
53
+
54
+ selected = [processes[i] for i in indices if 0 <= i < len(processes)]
55
+ return selected
56
+
57
+
58
+ def confirm_kill(processes: list[ProcessInfo]) -> bool:
59
+ """Show confirmation with process details"""
60
+ print()
61
+ print("=" * 60)
62
+ for p in processes:
63
+ print(f" 端口: {p.port} | PID: {p.pid} | 进程名: {p.name}")
64
+ print(f" 命令: {p.cmdline}")
65
+ print(f" 目录: {p.cwd}")
66
+ if p.children:
67
+ print(f" 子进程: {p.children}")
68
+ print("-" * 60)
69
+ print()
70
+
71
+ try:
72
+ answer = input("确认关闭以上进程? (y/N): ").strip().lower()
73
+ except (EOFError, KeyboardInterrupt):
74
+ return False
75
+ return answer in ("y", "yes")
76
+
77
+
78
+ def execute_kill(pids: list[int], kill_children: bool = False) -> dict:
79
+ """
80
+ Execute kill on given PIDs.
81
+ Returns {"killed": [...], "failed": [...]}
82
+ """
83
+ from devtools.tools.portkill.core import kill_process, get_process_tree
84
+
85
+ killed: list[int] = []
86
+ failed: list[tuple] = []
87
+
88
+ all_pids: list[int] = []
89
+ for pid in pids:
90
+ if kill_children:
91
+ all_pids.extend(get_process_tree(pid))
92
+ else:
93
+ all_pids.append(pid)
94
+
95
+ seen = set()
96
+ unique_pids = []
97
+ for pid in all_pids:
98
+ if pid not in seen:
99
+ seen.add(pid)
100
+ unique_pids.append(pid)
101
+
102
+ for pid in unique_pids:
103
+ if kill_process(pid, kill_children=False):
104
+ killed.append(pid)
105
+ else:
106
+ failed.append((pid, "进程不存在或无权限"))
107
+
108
+ return {"killed": killed, "failed": failed}
109
+
110
+
111
+ def run(args: list[str] | None = None):
112
+ """Main entry point for portkill CLI"""
113
+ from devtools.tools.portkill.core import scan_node_ports
114
+
115
+ if args is None:
116
+ args = []
117
+
118
+ short = "--long" not in args # short mode by default
119
+ listen_only = "--listen-only" in args
120
+
121
+ print("扫描 Node.js 进程...")
122
+
123
+ try:
124
+ all_processes = scan_node_ports()
125
+ except Exception as e:
126
+ print(f"扫描失败: {e}")
127
+ return
128
+
129
+ if not all_processes:
130
+ print("未发现 Node.js 进程")
131
+ return
132
+
133
+ short_mode = short
134
+ listen_filter = listen_only
135
+
136
+ while True:
137
+ # Apply filters
138
+ processes = all_processes
139
+ if listen_filter:
140
+ processes = [p for p in processes if p.port > 0]
141
+
142
+ if not processes:
143
+ print("没有符合条件的进程")
144
+ print("提示: 按 l 切换显示模式,或 p 关闭端口筛选")
145
+ else:
146
+ show_processes(processes, short=short_mode)
147
+
148
+ print()
149
+ print("快捷: l=长短显示 p=端口筛选 r=重新扫描 回车=退出")
150
+
151
+ try:
152
+ user_input = input("\n> ").strip()
153
+ except (EOFError, KeyboardInterrupt):
154
+ print()
155
+ return
156
+
157
+ if not user_input:
158
+ print("未选择任何进程,退出")
159
+ return
160
+
161
+ # Interactive commands
162
+ if user_input == "l":
163
+ short_mode = not short_mode
164
+ continue
165
+ if user_input == "p":
166
+ listen_filter = not listen_filter
167
+ continue
168
+ if user_input == "r":
169
+ try:
170
+ all_processes = scan_node_ports()
171
+ except Exception as e:
172
+ print(f"扫描失败: {e}")
173
+ return
174
+ if not all_processes:
175
+ print("未发现 Node.js 进程")
176
+ return
177
+ continue
178
+
179
+ # Try to parse as selection
180
+ selected = select_processes(processes, user_input)
181
+ if not selected:
182
+ print("未选择任何进程,退出")
183
+ return
184
+
185
+ if not confirm_kill(selected):
186
+ print("取消关闭")
187
+ continue
188
+
189
+ try:
190
+ child_answer = input("是否同时关闭子进程? (y/N): ").strip().lower()
191
+ except (EOFError, KeyboardInterrupt):
192
+ child_answer = "n"
193
+ kill_children = child_answer in ("y", "yes")
194
+
195
+ pids = [p.pid for p in selected]
196
+ result = execute_kill(pids, kill_children=kill_children)
197
+
198
+ print()
199
+ if result["killed"]:
200
+ print(f"已关闭: {result['killed']}")
201
+ if result["failed"]:
202
+ print(f"失败: {result['failed']}")
203
+ return
@@ -0,0 +1,5 @@
1
+ import sys
2
+ from devtools.tools.removebg.cli import run
3
+
4
+ if __name__ == "__main__":
5
+ run(sys.argv[1:])
@@ -0,0 +1,80 @@
1
+ """CLI entry point for removebg"""
2
+ import argparse
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ from devtools.tools.removebg.core import process_images
7
+
8
+
9
+ MODELS = ["u2net", "u2netp", "u2net_human_seg"]
10
+
11
+
12
+ def run(args: list[str] | None = None):
13
+ if args is None:
14
+ args = sys.argv[1:]
15
+
16
+ parser = argparse.ArgumentParser(
17
+ prog="python -m devtools removebg",
18
+ description="Remove background from images using rembg",
19
+ )
20
+ parser.add_argument(
21
+ "-o", "--output",
22
+ type=Path,
23
+ default=None,
24
+ metavar="DIR",
25
+ help="Output directory (default: overwrite originals)",
26
+ )
27
+ parser.add_argument(
28
+ "-m", "--model",
29
+ choices=MODELS,
30
+ default="u2net",
31
+ help="Model to use (default: u2net)",
32
+ )
33
+ parser.add_argument(
34
+ "-a", "--alpha-matting",
35
+ action="store_true",
36
+ help="Enable alpha matting for better edge quality",
37
+ )
38
+ parser.add_argument(
39
+ "files",
40
+ nargs="+",
41
+ type=Path,
42
+ help="Image files to process",
43
+ )
44
+
45
+ ns = parser.parse_args(args)
46
+
47
+ # Validate files exist
48
+ missing = [f for f in ns.files if not f.exists()]
49
+ if missing:
50
+ for f in missing:
51
+ print(f"文件不存在: {f}", file=sys.stderr)
52
+ sys.exit(1)
53
+
54
+ # Create output dir if specified
55
+ if ns.output is not None:
56
+ ns.output.mkdir(parents=True, exist_ok=True)
57
+
58
+ print(f"开始处理 {len(ns.files)} 个文件...")
59
+
60
+ result = process_images(
61
+ input_paths=ns.files,
62
+ output_dir=ns.output,
63
+ model=ns.model,
64
+ alpha_matting=ns.alpha_matting,
65
+ )
66
+
67
+ # Report results
68
+ print()
69
+ for path in result["succeeded"]:
70
+ print(f" OK: {path}")
71
+
72
+ for path, err in result["failed"]:
73
+ print(f" FAIL: {path} — {err}")
74
+
75
+ total = len(result["succeeded"]) + len(result["failed"])
76
+ print()
77
+ print(f"完成: {total} 文件, {len(result['succeeded'])} 成功, {len(result['failed'])} 失败")
78
+
79
+ if result["failed"]:
80
+ sys.exit(1)
@@ -0,0 +1,66 @@
1
+ """rembg core wrapper"""
2
+ from pathlib import Path
3
+ from typing import Optional
4
+
5
+ from rembg import remove, new_session
6
+ from PIL import Image
7
+
8
+
9
+ def process_images(
10
+ input_paths: list[Path],
11
+ output_dir: Optional[Path],
12
+ model: str = "u2net",
13
+ alpha_matting: bool = False,
14
+ alpha_matting_foreground_threshold: int = 240,
15
+ alpha_matting_background_threshold: int = 10,
16
+ alpha_matting_erode_size: int = 0,
17
+ ) -> dict:
18
+ """
19
+ Process a list of image files with rembg.
20
+
21
+ Args:
22
+ input_paths: List of image file paths
23
+ output_dir: If set, write output files here. If None, overwrite originals.
24
+ model: u2net | u2netp | u2net_human_seg
25
+ alpha_matting: Enable alpha matting
26
+ alpha_matting_foreground_threshold: Foreground threshold for alpha matting
27
+ alpha_matting_background_threshold: Background threshold for alpha matting
28
+ alpha_matting_erode_size: Erode size for alpha matting
29
+
30
+ Returns:
31
+ {"succeeded": [...], "failed": [(path, error_message), ...]}
32
+ """
33
+ succeeded = []
34
+ failed = []
35
+
36
+ session = new_session(model_name=model)
37
+
38
+ for input_path in input_paths:
39
+ try:
40
+ with Image.open(input_path) as img:
41
+ # rembg returns RGBA
42
+ output = remove(
43
+ img,
44
+ session=session,
45
+ alpha_matting=alpha_matting,
46
+ alpha_matting_foreground_threshold=alpha_matting_foreground_threshold,
47
+ alpha_matting_background_threshold=alpha_matting_background_threshold,
48
+ alpha_matting_erode_size=alpha_matting_erode_size,
49
+ )
50
+
51
+ if output_dir is None:
52
+ # Overwrite original
53
+ output_path = input_path
54
+ else:
55
+ output_path = output_dir / input_path.name
56
+
57
+ # Ensure output directory exists
58
+ output_path.parent.mkdir(parents=True, exist_ok=True)
59
+
60
+ output.save(output_path, "PNG")
61
+ succeeded.append(str(output_path))
62
+
63
+ except Exception as e:
64
+ failed.append((str(input_path), str(e)))
65
+
66
+ return {"succeeded": succeeded, "failed": failed}
@@ -0,0 +1,9 @@
1
+ from dataclasses import dataclass
2
+
3
+ @dataclass
4
+ class Tool:
5
+ """A tool available in devtools"""
6
+ name: str
7
+ category: str
8
+ description: str
9
+ module: str
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "damon-devtools"
3
+ version = "0.1.0"
4
+ description = "Developer CLI tools — portkill, removebg, apkpull"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = ["psutil>=5.9"]
8
+ license = {text = "MIT"}
9
+ authors = [
10
+ {name = "damon"},
11
+ ]
12
+ keywords = ["cli", "devtools", "android", "apk", "adb"]
13
+
14
+ [project.scripts]
15
+ devtools = "devtools.__main__:main"
16
+
17
+ [tool.setuptools.packages.find]
18
+ where = ["."]
19
+ include = ["devtools*"]
20
+ exclude = ["dotfiles*", "tests*", "build*", "dist*"]
21
+
22
+ [build-system]
23
+ requires = ["setuptools>=61.0"]
24
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+