tbtools-cli 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tbtools_cli/__init__.py +18 -0
- tbtools_cli/auto_commands.py +1149 -0
- tbtools_cli/cli.py +528 -0
- tbtools_cli/cli_load.py +352 -0
- tbtools_cli/cli_rpc.py +303 -0
- tbtools_cli/cli_tools_registry.py +93 -0
- tbtools_cli/cli_top.py +1160 -0
- tbtools_cli/command_metadata.json +4606 -0
- tbtools_cli/command_spec.py +382 -0
- tbtools_cli/config.example.toml +19 -0
- tbtools_cli/config.py +43 -0
- tbtools_cli/core.py +508 -0
- tbtools_cli/errors.py +38 -0
- tbtools_cli/presets.py +100 -0
- tbtools_cli/scenarios.py +92 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/AdmixtureCli.java +91 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/AmazingMetaCli.java +78 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/AssemblyRecommandCli.java +47 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/BamIndexCli.java +31 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/BamSortCli.java +43 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/BamStateCli.java +49 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/BarPlotterCli.java +18 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/BarplotCli.java +168 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/BatchVizMotifsCli.java +47 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/BlastXmlConvertCli.java +45 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/BlatExecutorCli.java +66 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/CalcRepeatCli.java +62 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/CalculateSimilarityCli.java +34 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/CddMotifCli.java +46 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/CircleGeneViewerCli.java +81 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/CircosCli.java +76 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/ColorSchemeCli.java +29 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/CtgGroupCli.java +29 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/CubeHeatmapCli.java +70 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/DeHistCli.java +46 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/DegramdomCli.java +33 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/DiffExpCli.java +44 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/DistanceCli.java +53 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/DualSynCli.java +147 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/EggnogCli.java +39 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/ExprCorrCli.java +27 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/FastaMergerCli.java +36 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/FastaTableConvertCli.java +38 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/FileCleanerCli.java +31 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/FileSplitCli.java +15 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/FindBlockDualCli.java +71 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/FindBlockMultipleCli.java +67 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/FindPathCli.java +15 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/GSEAWrapperCli.java +33 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/GenBank2FastaCli.java +32 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/GeneDensityCli.java +38 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/GeneLocGffCli.java +140 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/GeneStructureCli.java +90 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/GenericCli.java +177 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/GetENALinksCli.java +36 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/GetSubNewickTreeCli.java +50 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/GoEnrichCli.java +42 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/GoLevelCli.java +59 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/GroupedBarCli.java +95 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/GsaDiagCli.java +45 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/GxfFilterCli.java +39 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/GxfSortCli.java +23 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/HclustCli.java +43 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/HeatmapCli.java +89 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/HmmerSuiteCli.java +47 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/JgblocksCli.java +65 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/KeggEnrichCli.java +31 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/LayoutHeatmapCli.java +90 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MCScanXCli.java +80 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MCScanXFastCli.java +41 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MSACli.java +46 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MakeMotifCli.java +39 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MarkerDesignCli.java +62 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MarkerToolsCli.java +57 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/Mast2TabCli.java +27 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MastCli.java +54 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MastRunCli.java +39 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/Meme2TabCli.java +26 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MemeCli.java +74 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MemeRunCli.java +40 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MgGxfCli.java +35 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MicroSynCli.java +118 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MirIdentifyCli.java +55 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MotifCli.java +75 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MotifPatternCli.java +67 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MotifShiftCli.java +30 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MountainPlotCli.java +44 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MultiSuperHeatCli.java +64 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/NeedlemanWunschCli.java +76 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/PafGC.java +15 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/PafVizCli.java +67 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/PeakDistCli.java +58 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/Pep2CodonCli.java +26 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/PfamMotifCli.java +43 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/PhyloTreeCli.java +92 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/PileUpCli.java +55 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/PlantCAREResultClassifyCli.java +36 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/PubmedSearchCli.java +25 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/QpcrCli.java +69 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/QpcrDdctCli.java +28 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/QpcrProcCli.java +28 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/QuickGenomeDotCli.java +66 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/QuickProteinAnnoCli.java +34 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/QuickRunIQtreeCli.java +69 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/QuickTrimALCli.java +67 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/RNAplotCli.java +129 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/RegionBedToGFF3Cli.java +43 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/RegionDepthCli.java +29 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/SamBamCovCli.java +33 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/SeqConverterCli.java +17 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/SeqLenTrackCli.java +41 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/SeveralSpeciesCli.java +132 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/SimpleEnricherCli.java +44 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/SimpleHmmscanCli.java +32 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/SixFrameTranlaterCli.java +35 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/StructAnnoCompareCli.java +66 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/SubmitSMARTCli.java +30 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/SuperCircosCli.java +330 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/TableColManipCli.java +56 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/TableCollapseCli.java +33 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/TargetScoreCli.java +47 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/TauCalcCli.java +28 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/TaxonomyBatchCli.java +72 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/TreeCli.java +75 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/TreeRootingCli.java +30 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/TrimMSACli.java +33 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/UnrootedTreeCli.java +44 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/UpSetCli.java +90 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/Venn5Cli.java +60 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/Venn6Cli.java +65 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/ViolinCli.java +66 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/VisualizeCli.java +57 -0
- tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/VizGFACli.java +39 -0
- tbtools_cli-1.2.0.dist-info/METADATA +504 -0
- tbtools_cli-1.2.0.dist-info/RECORD +139 -0
- tbtools_cli-1.2.0.dist-info/WHEEL +5 -0
- tbtools_cli-1.2.0.dist-info/entry_points.txt +2 -0
- tbtools_cli-1.2.0.dist-info/licenses/LICENSE +21 -0
- tbtools_cli-1.2.0.dist-info/top_level.txt +1 -0
tbtools_cli/cli_top.py
ADDED
|
@@ -0,0 +1,1160 @@
|
|
|
1
|
+
"""cli_top.py — 顶层命令(架构重构批次 B:从 cli.py 拆分)
|
|
2
|
+
|
|
3
|
+
version/doctor/setup/fetch_jar/help/examples/completion/list/presets/new/check。
|
|
4
|
+
由 cli.py 以 register_top(cli, cli_load) 注册。
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from tbtools_cli import auto_commands as _ac
|
|
13
|
+
from tbtools_cli.cli_tools_registry import CLI_TOOLS
|
|
14
|
+
from tbtools_cli.core import (
|
|
15
|
+
JAR,
|
|
16
|
+
ROOT,
|
|
17
|
+
c,
|
|
18
|
+
check_input_format,
|
|
19
|
+
detect_format,
|
|
20
|
+
get_pitfall_hint,
|
|
21
|
+
safe_temp,
|
|
22
|
+
validate_file,
|
|
23
|
+
)
|
|
24
|
+
from tbtools_cli.presets import PRESETS, list_presets
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _detect_java_ver():
|
|
29
|
+
"""检测 Java 版本(无 java 返回 None;不崩)"""
|
|
30
|
+
try:
|
|
31
|
+
r = subprocess.run(["java", "-version"], capture_output=True, text=True, timeout=5)
|
|
32
|
+
return r.stderr.splitlines()[0] if r.stderr else "unknown"
|
|
33
|
+
except FileNotFoundError:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
def register_top(cli, _LG):
|
|
37
|
+
"""注册顶层命令。cli=主 CLI group;_LG=cli_load 模块(提供 _groups/GROUPS/CATEGORY_MAP)。"""
|
|
38
|
+
# ---- 通用命令 ----
|
|
39
|
+
@cli.command()
|
|
40
|
+
@click.option("--json", "as_json", is_flag=True, help="输出结构化统计(JSON, 供脚本/Agent 使用)")
|
|
41
|
+
def version(as_json):
|
|
42
|
+
"""显示版本与命令统计(权威数字; --json 输出结构化)"""
|
|
43
|
+
import json as _json
|
|
44
|
+
# 动态统计(数量哲学统一: 唯一统计 API, README/badge/文档口径均以此为准)
|
|
45
|
+
plot_count = sum(len(g.commands) for g in _LG._groups.values())
|
|
46
|
+
auto_count = sum(1 for n in dir(_ac) if n.startswith('_') and n.endswith('_impl') and not n.startswith('__'))
|
|
47
|
+
from tbtools_cli.core import BRIDGES_DIR, PITFALL_HINTS
|
|
48
|
+
bridge_count = len([f for f in os.listdir(BRIDGES_DIR) if f.endswith('.java')]) if os.path.isdir(BRIDGES_DIR) else 80
|
|
49
|
+
from tbtools_cli import __version__ as _pkg_ver
|
|
50
|
+
if as_json:
|
|
51
|
+
click.echo(_json.dumps({
|
|
52
|
+
"version": _pkg_ver,
|
|
53
|
+
"cli_commands": plot_count,
|
|
54
|
+
"auto_commands": auto_count,
|
|
55
|
+
"rpc_methods": 188,
|
|
56
|
+
"tools": len(CLI_TOOLS),
|
|
57
|
+
"bridges": bridge_count,
|
|
58
|
+
"pitfall_hints": len(PITFALL_HINTS),
|
|
59
|
+
"metadata_commands": len(_json.load(open(os.path.join(ROOT, "tbtools_cli", "command_metadata.json"), encoding="utf-8"))) if os.path.isfile(os.path.join(ROOT, "tbtools_cli", "command_metadata.json")) else 0,
|
|
60
|
+
}, ensure_ascii=False, indent=1))
|
|
61
|
+
return
|
|
62
|
+
click.echo(f"tbtools-cli v{_pkg_ver}")
|
|
63
|
+
click.echo(f" {plot_count} 绘图/分析命令 + {auto_count} auto_commands + 188 RPC 方法")
|
|
64
|
+
click.echo(f" bridges: {bridge_count} | pitfall hints: {len(PITFALL_HINTS)}")
|
|
65
|
+
# P0(第七轮评审实测): 无 Java 环境不能崩——version 是新人第一条命令
|
|
66
|
+
try:
|
|
67
|
+
r = subprocess.run(["java", "-version"], capture_output=True, text=True, timeout=5)
|
|
68
|
+
java_ver = r.stderr.splitlines()[0] if r.stderr else "unknown"
|
|
69
|
+
except FileNotFoundError:
|
|
70
|
+
java_ver = "❌ 未安装(apt install openjdk-17-jre-headless)"
|
|
71
|
+
click.echo(f" Java: {java_ver}")
|
|
72
|
+
click.echo(f" JAR: {JAR}" if JAR else " JAR: ⚠️ 未配置")
|
|
73
|
+
|
|
74
|
+
@cli.command()
|
|
75
|
+
def doctor():
|
|
76
|
+
"""环境诊断"""
|
|
77
|
+
import shutil
|
|
78
|
+
ok = warn = err = 0
|
|
79
|
+
checks = [
|
|
80
|
+
("java", "Java", True),
|
|
81
|
+
("javac", "javac", False),
|
|
82
|
+
("xvfb-run", "xvfb-run(Linux 绘图必需)", os.name != "nt"), # N14: Windows 无 xvfb 且不需要
|
|
83
|
+
]
|
|
84
|
+
fix_cmds = {
|
|
85
|
+
"java": "sudo apt install -y openjdk-17-jre-headless",
|
|
86
|
+
"javac": "sudo apt install -y openjdk-17-jdk-headless",
|
|
87
|
+
"xvfb-run": "sudo apt install -y xvfb",
|
|
88
|
+
}
|
|
89
|
+
for cmd_name, desc, required in checks:
|
|
90
|
+
if shutil.which(cmd_name):
|
|
91
|
+
click.echo(f" ✅ {desc}: 可用")
|
|
92
|
+
ok += 1
|
|
93
|
+
elif required:
|
|
94
|
+
click.echo(f" ❌ {desc}: 未安装")
|
|
95
|
+
if cmd_name in fix_cmds:
|
|
96
|
+
click.echo(f" 📋 复制即用: {fix_cmds[cmd_name]}")
|
|
97
|
+
err += 1
|
|
98
|
+
else:
|
|
99
|
+
click.echo(f" ⚠️ {desc}: 未安装")
|
|
100
|
+
warn += 1
|
|
101
|
+
if JAR and os.path.isfile(JAR):
|
|
102
|
+
size_mb = os.path.getsize(JAR) / 1024 / 1024
|
|
103
|
+
click.echo(f" ✅ JAR: {JAR} ({size_mb:.0f}MB)")
|
|
104
|
+
# 供应链校验(fetch-jar 记录 sha256; hashlib 流式, 300MB 约 1s)
|
|
105
|
+
import hashlib as _hl
|
|
106
|
+
import tomllib as _tl
|
|
107
|
+
_cfg_p = os.path.join(os.path.expanduser("~/.config/tbtools-cli"), "config.toml")
|
|
108
|
+
_exp = None
|
|
109
|
+
if os.path.isfile(_cfg_p):
|
|
110
|
+
try:
|
|
111
|
+
_exp = _tl.load(open(_cfg_p, "rb")).get("jar_sha256")
|
|
112
|
+
except Exception:
|
|
113
|
+
pass
|
|
114
|
+
if _exp:
|
|
115
|
+
h = _hl.sha256()
|
|
116
|
+
with open(JAR, "rb") as _f:
|
|
117
|
+
for _c in iter(lambda: _f.read(1 << 20), b""):
|
|
118
|
+
h.update(_c)
|
|
119
|
+
if h.hexdigest() == _exp:
|
|
120
|
+
click.echo(f" ✅ sha256 校验通过 ({_exp[:16]}...)")
|
|
121
|
+
else:
|
|
122
|
+
click.echo(f" ❌ sha256 不匹配! 期望 {_exp[:16]}... 实际 {h.hexdigest()[:16]}...", err=True)
|
|
123
|
+
ok += 1
|
|
124
|
+
# G5: 死命令探测(jar 版本与 CLI 注册类不匹配预警,外部测试 P1-1)
|
|
125
|
+
from tbtools_cli.core import probe_dead_engines
|
|
126
|
+
dead = probe_dead_engines()
|
|
127
|
+
if dead:
|
|
128
|
+
click.echo(f" ⚠️ 死命令探测: {len(dead)} 个注册引擎类在当前 jar 中不存在(版本不匹配):")
|
|
129
|
+
for cls, src in dead[:5]:
|
|
130
|
+
click.echo(f" - {cls} (注册于 {src})")
|
|
131
|
+
if len(dead) > 5:
|
|
132
|
+
click.echo(f" ... 及其他 {len(dead)-5} 个")
|
|
133
|
+
click.echo(" 💡 这些命令会 ClassNotFound;升级 jar 到 2.535+ 或忽略对应命令")
|
|
134
|
+
warn += 1
|
|
135
|
+
else:
|
|
136
|
+
click.echo(" ✅ 引擎类完整性: 注册类全部存在于 jar")
|
|
137
|
+
ok += 1
|
|
138
|
+
else:
|
|
139
|
+
click.echo(" ❌ JAR: 未找到")
|
|
140
|
+
err += 1
|
|
141
|
+
click.echo("")
|
|
142
|
+
click.echo(" ── 解决方法(AI 可直接执行)──")
|
|
143
|
+
click.echo(" ① 本机已有 TBtools jar?自动搜索并配置:")
|
|
144
|
+
click.echo(" tbtools setup --auto")
|
|
145
|
+
click.echo(" ② 知道 jar 路径,直接指定:")
|
|
146
|
+
click.echo(" tbtools setup /path/to/TBtools_JRE1.6.jar")
|
|
147
|
+
click.echo(" ③ 还没有 jar,自动下载+提取+配置(官方只发 portable zip):")
|
|
148
|
+
click.echo(" tbtools fetch-jar")
|
|
149
|
+
optional = {"samtools": "SAM/BAM", "blastp": "BLAST", "muscle": "MSA",
|
|
150
|
+
"iqtree2": "建树", "meme": "Motif", "RNAfold": "RNA"}
|
|
151
|
+
avail = [f"{d}({v})" for d, v in optional.items() if shutil.which(d)]
|
|
152
|
+
if avail:
|
|
153
|
+
click.echo(f" ✅ 可选依赖: {', '.join(avail[:5])}")
|
|
154
|
+
ok += 1
|
|
155
|
+
click.echo(f"\n 汇总: ✅ {ok} ⚠️ {warn} ❌ {err}")
|
|
156
|
+
|
|
157
|
+
# 平台能力矩阵(第三轮审查:让用户第一分钟知道能力边界)
|
|
158
|
+
click.echo("\n ── 平台能力矩阵 ──")
|
|
159
|
+
is_win = sys.platform.startswith("win")
|
|
160
|
+
have_xvfb = shutil.which("xvfb-run") is not None
|
|
161
|
+
jar_ready = bool(JAR) and os.path.isfile(JAR)
|
|
162
|
+
click.echo(f" 工具类命令 {'✅' if jar_ready else '❌'}")
|
|
163
|
+
click.echo(f" RPC 数据工具 {'✅' if jar_ready else '❌'}")
|
|
164
|
+
click.echo(f" 绘图(SVG) {'✅' if (not is_win or have_xvfb) and jar_ready else '⚠️ Windows 无 xvfb 部分受限 / JAR 缺失'}")
|
|
165
|
+
click.echo(f" 管道 stdin/stdout {'⚠️ 仅部分 tool 层'}")
|
|
166
|
+
click.echo(" (Linux 绘图需 xvfb;Windows 限制见 README Known Limitations)")
|
|
167
|
+
|
|
168
|
+
if err:
|
|
169
|
+
sys.exit(1)
|
|
170
|
+
|
|
171
|
+
@cli.command()
|
|
172
|
+
@click.argument('jar_path', required=False)
|
|
173
|
+
@click.option('--auto', is_flag=True, help="自动搜索本机 TBtools jar 并配置")
|
|
174
|
+
def setup(jar_path, auto):
|
|
175
|
+
"""配置 TBtools jar 路径(AI 友好:一条命令接入本机 TBtools)"""
|
|
176
|
+
import glob as _glob
|
|
177
|
+
found = None
|
|
178
|
+
if jar_path:
|
|
179
|
+
if not os.path.isfile(jar_path):
|
|
180
|
+
click.echo(f"❌ 文件不存在: {jar_path}", err=True)
|
|
181
|
+
sys.exit(1)
|
|
182
|
+
found = jar_path
|
|
183
|
+
elif auto:
|
|
184
|
+
click.echo("🔍 自动搜索 TBtools jar ...")
|
|
185
|
+
# 复用 core 的搜索逻辑 + glob 深层搜索
|
|
186
|
+
candidates = []
|
|
187
|
+
for pat in [
|
|
188
|
+
os.path.expanduser("~/TBtools*/**/TBtools_JRE1.6.jar"),
|
|
189
|
+
os.path.expanduser("~/Downloads/TBtools*.jar"),
|
|
190
|
+
os.path.expanduser("~/下载/TBtools*.jar"),
|
|
191
|
+
os.path.expanduser("~/Desktop/TBtools*.jar"),
|
|
192
|
+
"/opt/TBtools*/**/TBtools_JRE1.6.jar",
|
|
193
|
+
"/mnt/*/TBtools*/**/TBtools_JRE1.6.jar",
|
|
194
|
+
"/mnt/*/Users/*/Downloads/TBtools*.jar",
|
|
195
|
+
"/mnt/*/Users/*/Desktop/TBtools*.jar",
|
|
196
|
+
"/Applications/TBtools*/**/TBtools_JRE1.6.jar",
|
|
197
|
+
]:
|
|
198
|
+
try:
|
|
199
|
+
candidates.extend(_glob.glob(pat, recursive=True))
|
|
200
|
+
except Exception:
|
|
201
|
+
pass
|
|
202
|
+
candidates = sorted(set(c for c in candidates if os.path.isfile(c)))
|
|
203
|
+
if candidates:
|
|
204
|
+
found = candidates[0]
|
|
205
|
+
click.echo(f"✅ 找到: {found}")
|
|
206
|
+
if len(candidates) > 1:
|
|
207
|
+
click.echo(f" (还有 {len(candidates)-1} 个候选,用 tbtools setup <路径> 指定其他)")
|
|
208
|
+
else:
|
|
209
|
+
click.echo("❌ 未找到。请先下载 TBtools_JRE1.6.jar:")
|
|
210
|
+
click.echo(" https://github.com/CJ-Chen/TBtools/releases")
|
|
211
|
+
click.echo(" 下载后运行: tbtools setup /path/to/TBtools_JRE1.6.jar")
|
|
212
|
+
sys.exit(1)
|
|
213
|
+
else:
|
|
214
|
+
click.echo("用法:")
|
|
215
|
+
click.echo(" tbtools setup --auto # 自动搜索本机 jar")
|
|
216
|
+
click.echo(" tbtools setup /path/to/TBtools.jar # 指定路径")
|
|
217
|
+
sys.exit(0)
|
|
218
|
+
# 写入配置
|
|
219
|
+
cfg_dir = os.path.expanduser("~/.config/tbtools-cli")
|
|
220
|
+
os.makedirs(cfg_dir, exist_ok=True)
|
|
221
|
+
with open(os.path.join(cfg_dir, "config.sh"), "w") as f:
|
|
222
|
+
f.write(f'export TBTOOLS_JAR="{found}"\n')
|
|
223
|
+
with open(os.path.join(cfg_dir, "config.toml"), "w") as f:
|
|
224
|
+
f.write(f'jar = "{found}"\n\n[defaults]\nthreads = 4\nformat = "svg"\n')
|
|
225
|
+
click.echo(f"✅ 已配置: {found}")
|
|
226
|
+
click.echo(" 写入 ~/.config/tbtools-cli/{config.sh,config.toml}")
|
|
227
|
+
click.echo(" 运行 tbtools doctor 验证")
|
|
228
|
+
|
|
229
|
+
@cli.command()
|
|
230
|
+
@click.option('--version', 'ver', default=None, help="TBtools 版本 tag(默认最新含 portable 的 release)")
|
|
231
|
+
@click.option('--yes', is_flag=True, help="跳过确认")
|
|
232
|
+
def fetch_jar(ver, yes):
|
|
233
|
+
"""自动下载并提取 TBtools_JRE1.6.jar(官方只发 portable zip,需解包)"""
|
|
234
|
+
import urllib.request
|
|
235
|
+
import zipfile
|
|
236
|
+
# 确定版本
|
|
237
|
+
tag = ver
|
|
238
|
+
if not tag:
|
|
239
|
+
click.echo("🔍 查询最新 portable release ...")
|
|
240
|
+
try:
|
|
241
|
+
req = urllib.request.Request("https://api.github.com/repos/CJ-Chen/TBtools-II/releases",
|
|
242
|
+
headers={"Accept": "application/vnd.github+json", "User-Agent": "tbtools-cli"})
|
|
243
|
+
import json
|
|
244
|
+
rels = json.loads(urllib.request.urlopen(req, timeout=30).read())
|
|
245
|
+
tag = next((r["tag_name"] for r in rels
|
|
246
|
+
if any("portable" in a["name"] for a in r.get("assets", []))), None)
|
|
247
|
+
if not tag:
|
|
248
|
+
click.echo("❌ 未找到含 portable 的 release", err=True)
|
|
249
|
+
sys.exit(1)
|
|
250
|
+
click.echo(f" 最新: {tag}")
|
|
251
|
+
except Exception as e:
|
|
252
|
+
click.echo(f"❌ 查询失败: {e}", err=True)
|
|
253
|
+
click.echo(" 手动: tbtools fetch-jar --version 2.475", err=True)
|
|
254
|
+
sys.exit(1)
|
|
255
|
+
# 找资产
|
|
256
|
+
zip_name = None
|
|
257
|
+
try:
|
|
258
|
+
req = urllib.request.Request(f"https://api.github.com/repos/CJ-Chen/TBtools-II/releases/tags/{tag}",
|
|
259
|
+
headers={"Accept": "application/vnd.github+json", "User-Agent": "tbtools-cli"})
|
|
260
|
+
import json
|
|
261
|
+
rel = json.loads(urllib.request.urlopen(req, timeout=30).read())
|
|
262
|
+
zip_name = next((a["name"] for a in rel.get("assets", []) if "portable" in a["name"] and a["name"].endswith(".zip")), None)
|
|
263
|
+
except Exception as e:
|
|
264
|
+
click.echo(f"❌ 查询资产失败: {e}", err=True)
|
|
265
|
+
sys.exit(1)
|
|
266
|
+
if not zip_name:
|
|
267
|
+
click.echo(f"❌ {tag} 无 portable zip 资产", err=True)
|
|
268
|
+
sys.exit(1)
|
|
269
|
+
url = f"https://github.com/CJ-Chen/TBtools-II/releases/download/{tag}/{zip_name}"
|
|
270
|
+
size_mb = None
|
|
271
|
+
try:
|
|
272
|
+
req = urllib.request.Request(url, method="HEAD", headers={"User-Agent": "tbtools-cli"})
|
|
273
|
+
resp = urllib.request.urlopen(req, timeout=30)
|
|
274
|
+
size_mb = int(resp.headers.get("Content-Length", 0)) / 1024 / 1024
|
|
275
|
+
except Exception:
|
|
276
|
+
pass
|
|
277
|
+
click.echo(f"📦 下载 {zip_name}" + (f"(~{size_mb:.0f}MB)" if size_mb else ""))
|
|
278
|
+
if not yes:
|
|
279
|
+
if not click.confirm(" 继续下载?"):
|
|
280
|
+
click.echo("已取消")
|
|
281
|
+
sys.exit(0)
|
|
282
|
+
# 下载
|
|
283
|
+
tmp = safe_temp(suffix=".zip")
|
|
284
|
+
click.echo(" ⏳ 下载中(约 300MB,请稍候)...")
|
|
285
|
+
try:
|
|
286
|
+
req = urllib.request.Request(url, headers={"User-Agent": "tbtools-cli"})
|
|
287
|
+
with urllib.request.urlopen(req, timeout=600) as r, open(tmp, "wb") as f:
|
|
288
|
+
import shutil as _sh
|
|
289
|
+
_sh.copyfileobj(r, f)
|
|
290
|
+
except Exception as e:
|
|
291
|
+
click.echo(f"❌ 下载失败: {e}", err=True)
|
|
292
|
+
sys.exit(1)
|
|
293
|
+
# 供应链校验(GPT 评审 #9): 下载 zip 与提取 jar 均计算 SHA256, 记录进 config.toml 供复现校验
|
|
294
|
+
import hashlib as _hl
|
|
295
|
+
zip_sha = _hl.sha256()
|
|
296
|
+
with open(tmp, "rb") as _f:
|
|
297
|
+
for _chunk in iter(lambda: _f.read(1 << 20), b""):
|
|
298
|
+
zip_sha.update(_chunk)
|
|
299
|
+
# 提取 jar
|
|
300
|
+
jar_target = os.path.expanduser("~/tbtools-cli/lib/TBtools_JRE1.6.jar")
|
|
301
|
+
os.makedirs(os.path.dirname(jar_target), exist_ok=True)
|
|
302
|
+
found = False
|
|
303
|
+
try:
|
|
304
|
+
with zipfile.ZipFile(tmp) as z:
|
|
305
|
+
for n in z.namelist():
|
|
306
|
+
if n.endswith("TBtools_JRE1.6.jar"):
|
|
307
|
+
click.echo(f" 📂 提取 {n}")
|
|
308
|
+
with z.open(n) as src, open(jar_target, "wb") as dst:
|
|
309
|
+
_sh = __import__("shutil")
|
|
310
|
+
_sh.copyfileobj(src, dst)
|
|
311
|
+
found = True
|
|
312
|
+
break
|
|
313
|
+
except Exception as e:
|
|
314
|
+
click.echo(f"❌ 解压失败: {e}", err=True)
|
|
315
|
+
sys.exit(1)
|
|
316
|
+
finally:
|
|
317
|
+
os.unlink(tmp)
|
|
318
|
+
if not found:
|
|
319
|
+
click.echo("❌ zip 中未找到 TBtools_JRE1.6.jar", err=True)
|
|
320
|
+
sys.exit(1)
|
|
321
|
+
h = _hl.sha256()
|
|
322
|
+
with open(jar_target, "rb") as _f:
|
|
323
|
+
for _chunk in iter(lambda: _f.read(1 << 20), b""):
|
|
324
|
+
h.update(_chunk)
|
|
325
|
+
jar_sha_hex = h.hexdigest()
|
|
326
|
+
click.echo(f"✅ 已提取: {jar_target}")
|
|
327
|
+
click.echo(f" sha256(zip) = {zip_sha.hexdigest()}")
|
|
328
|
+
click.echo(f" sha256(jar) = {jar_sha_hex}")
|
|
329
|
+
# 配置(含 checksum 与版本, 供验证/审计)
|
|
330
|
+
cfg_dir = os.path.expanduser("~/.config/tbtools-cli")
|
|
331
|
+
os.makedirs(cfg_dir, exist_ok=True)
|
|
332
|
+
with open(os.path.join(cfg_dir, "config.sh"), "w") as f:
|
|
333
|
+
f.write(f'export TBTOOLS_JAR="{jar_target}"\n')
|
|
334
|
+
with open(os.path.join(cfg_dir, "config.toml"), "w") as f:
|
|
335
|
+
f.write(f'jar = "{jar_target}"\njar_sha256 = "{jar_sha_hex}"\njar_version = "{tag}"\n\n[defaults]\nthreads = 4\nformat = "svg"\n')
|
|
336
|
+
click.echo("✅ 已配置(sha256 已记录, 可用 doctor 验证)。运行 tbtools doctor 验证")
|
|
337
|
+
|
|
338
|
+
@cli.command()
|
|
339
|
+
@click.argument('name')
|
|
340
|
+
def help(name):
|
|
341
|
+
"""快捷帮助: tbtools help <命令名> 自动定位 + 坑位提示"""
|
|
342
|
+
# 尝试在所有分组中查找
|
|
343
|
+
for gname, g in _LG._groups.items():
|
|
344
|
+
if name in g.commands:
|
|
345
|
+
cmd = g.commands[name]
|
|
346
|
+
click.echo(f"\n 命令: {gname} {name} (分组 {gname})")
|
|
347
|
+
if cmd.help:
|
|
348
|
+
click.echo(f"\n{cmd.help}")
|
|
349
|
+
# 坑位提示
|
|
350
|
+
pit = get_pitfall_hint(name)
|
|
351
|
+
if pit:
|
|
352
|
+
click.echo(f"\n ⚠️ 坑位: {pit}")
|
|
353
|
+
# 示例(若存在)
|
|
354
|
+
try:
|
|
355
|
+
from tbtools_cli.auto_commands import EXAMPLES # type: ignore[attr-defined] # 可选属性,hasattr 兜底
|
|
356
|
+
ex = EXAMPLES.get(name)
|
|
357
|
+
if ex:
|
|
358
|
+
click.echo(f"\n 示例: {ex}")
|
|
359
|
+
except Exception:
|
|
360
|
+
pass
|
|
361
|
+
click.echo(f"\n 完整帮助: tbtools {gname} {name} --help")
|
|
362
|
+
return
|
|
363
|
+
# 顶层命令
|
|
364
|
+
if name in cli.commands:
|
|
365
|
+
cmd = cli.commands[name]
|
|
366
|
+
if cmd.help:
|
|
367
|
+
click.echo(f"\n{cmd.help}")
|
|
368
|
+
return
|
|
369
|
+
# 共享注册表工具(P0-3:tbtools tool <name> 动态解析,不在静态 commands 里)
|
|
370
|
+
if name in CLI_TOOLS:
|
|
371
|
+
click.echo(f"\n 命令: tool {name} (分组 tool · 注册表工具)")
|
|
372
|
+
click.echo(f"\n {name} [引擎命名参数...]")
|
|
373
|
+
click.echo(f" 引擎类: {CLI_TOOLS[name]}")
|
|
374
|
+
click.echo("\n ⚠️ ArgsParser 系引擎一律 --key value 空格分隔,--key=value 会被拒绝")
|
|
375
|
+
click.echo(f" 💡 查看引擎真实参数: java -cp $TBTOOLS_JAR {CLI_TOOLS[name]} --bogus x(逼出 Usage)")
|
|
376
|
+
click.echo(f"\n 完整帮助: tbtools tool {name} --help")
|
|
377
|
+
return
|
|
378
|
+
click.echo(f"❌ 未找到命令: {name}")
|
|
379
|
+
click.echo(" 查看: tbtools list")
|
|
380
|
+
sys.exit(1)
|
|
381
|
+
|
|
382
|
+
@cli.command()
|
|
383
|
+
@click.argument("command", required=False)
|
|
384
|
+
def examples(command):
|
|
385
|
+
"""显示命令示例"""
|
|
386
|
+
EX = {
|
|
387
|
+
"seqlogo": ("序列 LOGO", "tbtools seq logo examples/data/phylogeny/msa.fa logo.svg"),
|
|
388
|
+
"volcano": ("火山图", "tbtools expr volcano examples/data/deg.txt volcano.svg"),
|
|
389
|
+
"heatmap": ("热图", "tbtools expr heatmap examples/data/expr/expr.tsv heatmap.svg --log2 --cluster-row"),
|
|
390
|
+
"tree": ("系统发育树", "tbtools tree draw test_reports/data_b5/tree.config tree.svg"),
|
|
391
|
+
"stat-fasta": ("FASTA 统计", "tbtools tool stat-fasta examples/data/rpc/gras6_pep.fa stat.xls"),
|
|
392
|
+
}
|
|
393
|
+
if command and command in EX:
|
|
394
|
+
click.echo(f"\n{command} 示例:")
|
|
395
|
+
click.echo(f" {EX[command][0]}:")
|
|
396
|
+
click.echo(f" {EX[command][1]}")
|
|
397
|
+
elif command:
|
|
398
|
+
click.echo(f"暂无 {command} 的示例。查看帮助: tbtools {command} --help")
|
|
399
|
+
else:
|
|
400
|
+
click.echo("tbtools-cli 命令示例")
|
|
401
|
+
for name, (desc, cmd) in EX.items():
|
|
402
|
+
click.echo(f" {name}: {desc}")
|
|
403
|
+
click.echo(f" {cmd}")
|
|
404
|
+
click.echo("\n用法: tbtools examples <命令>")
|
|
405
|
+
|
|
406
|
+
@cli.command(name="completion")
|
|
407
|
+
@click.pass_context
|
|
408
|
+
@click.argument("shell", type=click.Choice(["bash", "zsh", "fish"]), required=False)
|
|
409
|
+
def completion_cmd(ctx, shell):
|
|
410
|
+
"""生成 shell 补全脚本(tbtools completion bash)"""
|
|
411
|
+
comp_path = os.path.join(ROOT, "scripts", "tbtools-completion.bash")
|
|
412
|
+
if not os.path.isfile(comp_path):
|
|
413
|
+
click.echo(f"❌ 补全脚本不存在: {comp_path}", err=True)
|
|
414
|
+
sys.exit(1)
|
|
415
|
+
if shell == "bash":
|
|
416
|
+
click.echo(open(comp_path, encoding="utf-8").read())
|
|
417
|
+
elif shell == "zsh":
|
|
418
|
+
click.echo("autoload -U +X bashcompinit && bashcompinit")
|
|
419
|
+
click.echo(open(comp_path, encoding="utf-8").read())
|
|
420
|
+
elif shell == "fish":
|
|
421
|
+
try:
|
|
422
|
+
lines = []
|
|
423
|
+
for gname in _LG._groups:
|
|
424
|
+
for cname in _LG._groups[gname].commands:
|
|
425
|
+
lines.append(f"complete -c tbtools -n '__fish_use_subcommand' -a '{cname}'")
|
|
426
|
+
click.echo("# tbtools-cli fish 补全")
|
|
427
|
+
click.echo("\n".join(sorted(set(lines))))
|
|
428
|
+
except Exception as e:
|
|
429
|
+
click.echo(f"❌ fish 补全生成失败: {e}", err=True)
|
|
430
|
+
sys.exit(1)
|
|
431
|
+
else:
|
|
432
|
+
click.echo("生成 shell 补全脚本")
|
|
433
|
+
click.echo("用法: tbtools completion bash|zsh|fish")
|
|
434
|
+
for s, inst in {"bash": "source <(tbtools completion bash)",
|
|
435
|
+
"zsh": "tbtools completion zsh > ~/.zshrc",
|
|
436
|
+
"fish": "tbtools completion fish > ~/.config/fish/completions/tbtools.fish"}.items():
|
|
437
|
+
click.echo(f" {s:6s} {inst}")
|
|
438
|
+
|
|
439
|
+
@cli.command(name="env")
|
|
440
|
+
@click.option("--json", "as_json", is_flag=True, help="输出结构化(JSON)")
|
|
441
|
+
@click.option("--lock", is_flag=True, help="固化环境到 tbtools.lock(可复现性)")
|
|
442
|
+
def env(as_json, lock):
|
|
443
|
+
"""环境快照: 版本/依赖/可选工具(科研可复现, 第八轮评审建议)"""
|
|
444
|
+
import json as _json
|
|
445
|
+
import shutil as _sh
|
|
446
|
+
from tbtools_cli import __version__ as _pkg_ver
|
|
447
|
+
snap = {
|
|
448
|
+
"tbtools_cli": _pkg_ver,
|
|
449
|
+
"jar": JAR or "",
|
|
450
|
+
"java": _detect_java_ver(),
|
|
451
|
+
"xvfb": bool(_sh.which("xvfb-run")),
|
|
452
|
+
"deps": {},
|
|
453
|
+
}
|
|
454
|
+
for tool in ("blastp", "muscle", "iqtree2", "trimal", "jellyfish", "hmmsearch", "mafft", "diamond"):
|
|
455
|
+
snap["deps"][tool] = _sh.which(tool) or None
|
|
456
|
+
if lock:
|
|
457
|
+
path = "tbtools.lock"
|
|
458
|
+
_json.dump(snap, open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
|
459
|
+
click.echo(f"✅ 环境已固化: {path}(运行复现:tbtools env --json 对比)")
|
|
460
|
+
elif as_json:
|
|
461
|
+
click.echo(_json.dumps(snap, ensure_ascii=False, indent=1))
|
|
462
|
+
else:
|
|
463
|
+
click.echo(f" tbtools-cli: {snap['tbtools_cli']}")
|
|
464
|
+
click.echo(f" TBtools JAR: {snap['jar'] or '⚠️ 未配置'}")
|
|
465
|
+
click.echo(f" Java: {snap['java']}")
|
|
466
|
+
click.echo(f" xvfb: {'✅' if snap['xvfb'] else '❌ 未安装(Linux 绘图必需)'}")
|
|
467
|
+
for k, v in snap["deps"].items():
|
|
468
|
+
click.echo(f" {k}: {'✅' if v else '—'}")
|
|
469
|
+
|
|
470
|
+
@cli.command(name="tool-describe")
|
|
471
|
+
@click.argument("command")
|
|
472
|
+
@click.option("--json", "as_json", is_flag=True, help="输出机器 schema(JSON, 供 Agent)")
|
|
473
|
+
def tool_describe(command, as_json):
|
|
474
|
+
"""命令机器描述(schema): tbtools tool-describe <命令> [--json](Agent 能力)"""
|
|
475
|
+
import json as _json
|
|
476
|
+
meta_path = os.path.join(ROOT, "tbtools_cli", "command_metadata.json")
|
|
477
|
+
meta = _json.load(open(meta_path, encoding="utf-8")) if os.path.isfile(meta_path) else {}
|
|
478
|
+
if command not in meta:
|
|
479
|
+
click.echo(f"❌ 未知命令: {command}(见 tbtools list / search)", err=True)
|
|
480
|
+
sys.exit(1)
|
|
481
|
+
v = meta[command]
|
|
482
|
+
# 分组: metadata group 字段优先(gen_metadata 注入), 运行时 _groups 兜底
|
|
483
|
+
group = v.get("group") or next((g for g, grp in _LG._groups.items() if command in grp.commands),
|
|
484
|
+
_LG.CATEGORY_MAP.get(command, "engine"))
|
|
485
|
+
# help: metadata 优先, 回退点击命令对象(手动命令)
|
|
486
|
+
help_txt = (v.get("help", "") or "").split("#")[-1].strip()
|
|
487
|
+
if not help_txt:
|
|
488
|
+
grp = _LG._groups.get(group)
|
|
489
|
+
cmd_obj = grp.commands.get(command) if grp else None
|
|
490
|
+
help_txt = (cmd_obj.help or "") if cmd_obj else ""
|
|
491
|
+
cls = v.get("class") or ("" if command not in _LG._groups.get("engine", type("x", (), {"commands": {}})).commands else "")
|
|
492
|
+
desc = {
|
|
493
|
+
"schema_version": "1.0",
|
|
494
|
+
"id": f"tbtools.{group}.{command}",
|
|
495
|
+
"name": command,
|
|
496
|
+
"group": group,
|
|
497
|
+
"kind": v.get("kind", "?"),
|
|
498
|
+
"help": help_txt,
|
|
499
|
+
"invoke": f"tbtools {group} {command} <args...>" if group != "engine" else f"tbtools engine {cls} key=value",
|
|
500
|
+
"class": cls,
|
|
501
|
+
"pitfall": get_pitfall_hint(command),
|
|
502
|
+
}
|
|
503
|
+
if v.get("alias_of"):
|
|
504
|
+
desc["alias_of"] = v["alias_of"]
|
|
505
|
+
try:
|
|
506
|
+
from tbtools_cli.command_spec import KNOWN_RELATIONS
|
|
507
|
+
if command in KNOWN_RELATIONS:
|
|
508
|
+
desc["relations"] = KNOWN_RELATIONS[command]
|
|
509
|
+
except Exception:
|
|
510
|
+
pass
|
|
511
|
+
if v.get("capabilities"):
|
|
512
|
+
desc["capabilities"] = v["capabilities"]
|
|
513
|
+
if v.get("dependencies"):
|
|
514
|
+
import shutil as _sh
|
|
515
|
+
desc["dependencies"] = v["dependencies"]
|
|
516
|
+
desc["availability"] = {
|
|
517
|
+
"status": "ready" if all(_sh.which(d) for d in v["dependencies"]) else "missing_dependencies",
|
|
518
|
+
"missing": [d for d in v["dependencies"] if not _sh.which(d)],
|
|
519
|
+
}
|
|
520
|
+
if v.get("inputs"):
|
|
521
|
+
desc["inputs"] = v["inputs"]
|
|
522
|
+
if v.get("outputs"):
|
|
523
|
+
desc["outputs"] = v["outputs"]
|
|
524
|
+
if as_json:
|
|
525
|
+
click.echo(_json.dumps(desc, ensure_ascii=False, indent=1))
|
|
526
|
+
else:
|
|
527
|
+
click.echo(f" {desc['id']}")
|
|
528
|
+
click.echo(f" 描述: {desc['help']}")
|
|
529
|
+
click.echo(f" 调用: {desc['invoke']}")
|
|
530
|
+
click.echo(f" 坑位: {desc['pitfall'] or '无'}")
|
|
531
|
+
|
|
532
|
+
@cli.command(name="tool-validate")
|
|
533
|
+
@click.argument("command")
|
|
534
|
+
@click.argument("inputs", nargs=-1, required=True)
|
|
535
|
+
@click.option("--json", "as_json", is_flag=True, help="结构化输出(JSON, 供 Agent)")
|
|
536
|
+
@click.option("--force", is_flag=True, help="忽略预检问题(结果 valid=True, 保留 warnings)")
|
|
537
|
+
def tool_validate(command, inputs, as_json, force):
|
|
538
|
+
"""执行前预检: 输入存在性/格式/列数(Agent 能力3;不运行命令)"""
|
|
539
|
+
import json as _json
|
|
540
|
+
checks = []
|
|
541
|
+
ok = True
|
|
542
|
+
for i, path in enumerate(inputs, 1):
|
|
543
|
+
if not os.path.isfile(path):
|
|
544
|
+
checks.append({"input": path, "ok": False, "issue": "文件不存在"})
|
|
545
|
+
ok = False
|
|
546
|
+
continue
|
|
547
|
+
v_ok, msg = validate_file(path, "文件")
|
|
548
|
+
if not v_ok:
|
|
549
|
+
checks.append({"input": path, "ok": False, "issue": msg})
|
|
550
|
+
ok = False
|
|
551
|
+
continue
|
|
552
|
+
fmt, ncols, _ = detect_format(path)
|
|
553
|
+
warn = check_input_format(command, path)
|
|
554
|
+
checks.append({"input": path, "ok": True, "format": fmt, "columns": ncols,
|
|
555
|
+
"warning": warn or None})
|
|
556
|
+
if warn:
|
|
557
|
+
ok = False
|
|
558
|
+
result = {"command": command, "valid": ok, "checks": checks}
|
|
559
|
+
if as_json:
|
|
560
|
+
click.echo(_json.dumps(result, ensure_ascii=False, indent=1))
|
|
561
|
+
else:
|
|
562
|
+
for c2 in checks:
|
|
563
|
+
st = "✅" if c2["ok"] else "❌"
|
|
564
|
+
w = f" ⚠️ {c2.get('warning')}" if c2.get("warning") else ""
|
|
565
|
+
click.echo(f" {st} {c2['input']} [{c2.get('format','?')}/{c2.get('columns','?')}列]{w}")
|
|
566
|
+
click.echo(f" 结论: {'✅ 可执行' if ok else '❌ 有预检问题(可 --force 忽略,见引擎报错)'}")
|
|
567
|
+
sys.exit(0 if (ok or force) else 3)
|
|
568
|
+
|
|
569
|
+
@cli.command(name="tool-provenance")
|
|
570
|
+
@click.argument("artifact")
|
|
571
|
+
def tool_provenance(artifact):
|
|
572
|
+
"""查询运行溯源: 读取 <artifact>.tbtools.json(Agent 能力7)"""
|
|
573
|
+
import json as _json
|
|
574
|
+
p = artifact + ".tbtools.json"
|
|
575
|
+
if not os.path.isfile(p):
|
|
576
|
+
click.echo(f"❌ 无溯源文件: {p}(命令成功运行后自动生成)", err=True)
|
|
577
|
+
sys.exit(1)
|
|
578
|
+
d = _json.load(open(p, encoding="utf-8"))
|
|
579
|
+
click.echo(_json.dumps(d, ensure_ascii=False, indent=1))
|
|
580
|
+
|
|
581
|
+
@cli.command(name="tool-result")
|
|
582
|
+
@click.argument("artifact")
|
|
583
|
+
def tool_result(artifact):
|
|
584
|
+
"""结构化结果摘要(Agent 能力5): 从 provenance 提炼 status/artifacts/inputs"""
|
|
585
|
+
import json as _json
|
|
586
|
+
p = artifact + ".tbtools.json"
|
|
587
|
+
if not os.path.isfile(p):
|
|
588
|
+
click.echo(_json.dumps({"status": "unknown", "artifact": artifact,
|
|
589
|
+
"error": "无溯源文件(命令未成功运行或未生成)"},
|
|
590
|
+
ensure_ascii=False, indent=1))
|
|
591
|
+
sys.exit(1)
|
|
592
|
+
d = _json.load(open(p, encoding="utf-8"))
|
|
593
|
+
summary = {
|
|
594
|
+
"schema_version": "1.0",
|
|
595
|
+
"status": "success" if d.get("exit_code") == 0 else "failed",
|
|
596
|
+
"tool": d.get("command"),
|
|
597
|
+
"artifacts": [{"path": o, "role": "primary_output"} for o in d.get("outputs", [])],
|
|
598
|
+
"inputs_count": len(d.get("inputs", [])),
|
|
599
|
+
"tbtools_cli": d.get("tbtools_cli"),
|
|
600
|
+
"timestamp": d.get("timestamp"),
|
|
601
|
+
}
|
|
602
|
+
click.echo(_json.dumps(summary, ensure_ascii=False, indent=1))
|
|
603
|
+
|
|
604
|
+
@cli.command(name="tool-run")
|
|
605
|
+
@click.argument("args", nargs=-1, required=True)
|
|
606
|
+
@click.option("--json", "as_json", is_flag=True, help="结构化结果回显(Agent 能力4)")
|
|
607
|
+
@click.option("--timeout", "timeout_s", type=int, default=0, help="超时秒数(0=不超时; 超时杀进程树并返回 TB007)")
|
|
608
|
+
def tool_run(args, as_json, timeout_s):
|
|
609
|
+
"""统一执行接口: 转发任意 tbtools 命令 + 结构化结果(退出码/时长/产物)"""
|
|
610
|
+
import json as _json
|
|
611
|
+
import time as _time
|
|
612
|
+
# --json 时: stdout 是协议(纯 JSON)——执行期 stdout → /dev/null(日志丢弃, Java 详情在 err 文件)
|
|
613
|
+
_saved_fd = None
|
|
614
|
+
if as_json:
|
|
615
|
+
import os as _os
|
|
616
|
+
_saved_fd = _os.dup(1)
|
|
617
|
+
_devnull = _os.open(_os.devnull, _os.O_WRONLY)
|
|
618
|
+
_os.dup2(_devnull, 1)
|
|
619
|
+
_os.close(_devnull)
|
|
620
|
+
t0 = _time.time()
|
|
621
|
+
_timed_out = False
|
|
622
|
+
try:
|
|
623
|
+
if timeout_s and timeout_s > 0:
|
|
624
|
+
# 超时模式: 独立进程执行, 可整体杀进程树(含 java/xvfb 子进程)
|
|
625
|
+
import subprocess as _sp
|
|
626
|
+
import sys as _sys
|
|
627
|
+
try:
|
|
628
|
+
_r = _sp.run([_sys.executable, "-m", "tbtools_cli.cli"] + list(args),
|
|
629
|
+
timeout=timeout_s)
|
|
630
|
+
ec = _r.returncode or 0
|
|
631
|
+
except _sp.TimeoutExpired:
|
|
632
|
+
_timed_out = True
|
|
633
|
+
ec = 1
|
|
634
|
+
else:
|
|
635
|
+
try:
|
|
636
|
+
ec = cli.main(list(args), standalone_mode=False) if cli is not None else 1
|
|
637
|
+
except SystemExit as _e:
|
|
638
|
+
ec = int(_e.code or 0) # 命令内部 sys.exit(0/2) 在嵌套调用下逃逸——捕获
|
|
639
|
+
ec = ec or 0
|
|
640
|
+
finally:
|
|
641
|
+
if _saved_fd is not None:
|
|
642
|
+
import os as _os
|
|
643
|
+
_os.dup2(_saved_fd, 1)
|
|
644
|
+
_os.close(_saved_fd)
|
|
645
|
+
dt = round(_time.time() - t0, 2)
|
|
646
|
+
# 产物+错误: 明确输出识别(args 中最后图形参数 → 其 provenance), 非扫描猜测
|
|
647
|
+
artifacts, error = [], None
|
|
648
|
+
for a in reversed(args):
|
|
649
|
+
if a.endswith((".svg", ".png", ".pdf")):
|
|
650
|
+
_po = a + ".tbtools.json"
|
|
651
|
+
if os.path.isfile(_po):
|
|
652
|
+
try:
|
|
653
|
+
_prov = _json.load(open(_po, encoding="utf-8"))
|
|
654
|
+
artifacts = _prov.get("outputs", [])
|
|
655
|
+
error = _prov.get("error")
|
|
656
|
+
except Exception:
|
|
657
|
+
pass
|
|
658
|
+
break
|
|
659
|
+
if _timed_out:
|
|
660
|
+
error = {"code": "TB007_TOOL_TIMEOUT", "retryable": True,
|
|
661
|
+
"suggested_action": "retry with --timeout higher or smaller input"}
|
|
662
|
+
result = {"schema_version": "1.0", "exit_code": ec, "duration_s": dt,
|
|
663
|
+
"artifacts": artifacts, "error": error, "timed_out": _timed_out}
|
|
664
|
+
if as_json:
|
|
665
|
+
click.echo(_json.dumps(result, ensure_ascii=False, indent=1))
|
|
666
|
+
else:
|
|
667
|
+
click.echo(f" exit: {ec} | {dt}s | 产物: {artifacts or '无'}")
|
|
668
|
+
sys.exit(ec if ec else 0)
|
|
669
|
+
|
|
670
|
+
# ── Job 模型(GLM P1 + 状态机完善 2026-09-23)──
|
|
671
|
+
# 状态机: running → succeeded(exit 0) / failed(exit≠0) / cancelled / timed_out
|
|
672
|
+
def _jobs_dir():
|
|
673
|
+
d = os.path.join(os.path.expanduser("~"), ".config", "tbtools-cli", "jobs")
|
|
674
|
+
os.makedirs(d, exist_ok=True)
|
|
675
|
+
return d
|
|
676
|
+
|
|
677
|
+
def _job_save(job):
|
|
678
|
+
import json as _json
|
|
679
|
+
jf = os.path.join(_jobs_dir(), f"{job['id']}.json")
|
|
680
|
+
_json.dump(job, open(jf, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
|
681
|
+
|
|
682
|
+
def _job_finish(job, status, exit_code):
|
|
683
|
+
"""进程结束后: 从 provenance 读 exit_code/error 判 succeeded/failed 并落盘"""
|
|
684
|
+
import json as _json
|
|
685
|
+
job["status"] = status
|
|
686
|
+
job["exit_code"] = exit_code
|
|
687
|
+
job["finished_at"] = __import__("time").strftime("%Y-%m-%dT%H:%M:%S")
|
|
688
|
+
# 输出识别(args 中最后图形参数 → provenance, 取 error)
|
|
689
|
+
for a in reversed(job.get("args", [])):
|
|
690
|
+
if a.endswith((".svg", ".png", ".pdf")):
|
|
691
|
+
_po = a + ".tbtools.json"
|
|
692
|
+
if os.path.isfile(_po):
|
|
693
|
+
try:
|
|
694
|
+
job["error"] = _json.load(open(_po, encoding="utf-8")).get("error")
|
|
695
|
+
except Exception:
|
|
696
|
+
pass
|
|
697
|
+
break
|
|
698
|
+
_job_save(job)
|
|
699
|
+
|
|
700
|
+
@cli.command(name="tool-submit")
|
|
701
|
+
@click.argument("args", nargs=-1, required=True)
|
|
702
|
+
@click.option("--timeout", "timeout_s", type=int, default=0, help="超时秒数(0=不超时; 超时杀进程树并置 timed_out)")
|
|
703
|
+
def tool_submit(args, timeout_s):
|
|
704
|
+
"""异步提交任务: 后台执行, 返回 job_id(Agent 长任务; 状态机 running→succeeded/failed/cancelled/timed_out)"""
|
|
705
|
+
import json as _json
|
|
706
|
+
import time as _time
|
|
707
|
+
import uuid as _uuid
|
|
708
|
+
import subprocess as _sp
|
|
709
|
+
import sys as _sys
|
|
710
|
+
jid = f"job_{_time.strftime('%Y%m%d_%H%M%S')}_{_uuid.uuid4().hex[:6]}"
|
|
711
|
+
log = os.path.join(_jobs_dir(), f"{jid}.log")
|
|
712
|
+
# 主进程直接 Popen(独立子进程, 不受 submit 退出影响); 终态由 job-status 惰性判定
|
|
713
|
+
with open(log, "w", encoding="utf-8") as _lf:
|
|
714
|
+
p = _sp.Popen([_sys.executable, "-m", "tbtools_cli.cli"] + list(args),
|
|
715
|
+
stdout=_lf, stderr=_sp.STDOUT, start_new_session=True)
|
|
716
|
+
job = {"id": jid, "status": "running", "pid": p.pid, "args": list(args),
|
|
717
|
+
"started_at": _time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
718
|
+
"timeout_s": timeout_s, "log": log}
|
|
719
|
+
_job_save(job)
|
|
720
|
+
click.echo(_json.dumps({"job_id": jid, "status": "running", "pid": p.pid}, ensure_ascii=False, indent=1))
|
|
721
|
+
|
|
722
|
+
@cli.command(name="job-status")
|
|
723
|
+
@click.argument("job_id")
|
|
724
|
+
def job_status(job_id):
|
|
725
|
+
"""查询任务状态(状态机: running/succeeded/failed/cancelled/timed_out + 退出码)"""
|
|
726
|
+
import json as _json
|
|
727
|
+
jf = os.path.join(_jobs_dir(), f"{job_id}.json")
|
|
728
|
+
if not os.path.isfile(jf):
|
|
729
|
+
click.echo(f"❌ 未知 job: {job_id}", err=True)
|
|
730
|
+
sys.exit(1)
|
|
731
|
+
job = _json.load(open(jf, encoding="utf-8"))
|
|
732
|
+
import time as _t
|
|
733
|
+
# ① 超时惰性判定: running + elapsed > timeout_s → killpg + timed_out
|
|
734
|
+
if job["status"] == "running" and job.get("pid") and job.get("timeout_s", 0) > 0:
|
|
735
|
+
try:
|
|
736
|
+
_start = _t.mktime(_t.strptime(job["started_at"], "%Y-%m-%dT%H:%M:%S"))
|
|
737
|
+
if _t.time() - _start > job["timeout_s"]:
|
|
738
|
+
import signal as _sig
|
|
739
|
+
try:
|
|
740
|
+
os.killpg(os.getpgid(job["pid"]), _sig.SIGKILL)
|
|
741
|
+
except Exception:
|
|
742
|
+
pass
|
|
743
|
+
_job_finish(job, "timed_out", -1)
|
|
744
|
+
except Exception:
|
|
745
|
+
pass
|
|
746
|
+
# ② running 但 pid 已消失(子进程独立退出)→ 惰性终态判定(provenance)
|
|
747
|
+
if job["status"] == "running" and job.get("pid"):
|
|
748
|
+
alive = os.path.exists(f"/proc/{job['pid']}") if os.path.isdir("/proc") else True
|
|
749
|
+
if not alive:
|
|
750
|
+
# 从 provenance 判终态(子进程独立写; 无 provenance=失败)
|
|
751
|
+
_ec, _err = None, None
|
|
752
|
+
for a in reversed(job.get("args", [])):
|
|
753
|
+
if a.endswith((".svg", ".png", ".pdf")) and os.path.isfile(a + ".tbtools.json"):
|
|
754
|
+
try:
|
|
755
|
+
_pr = _json.load(open(a + ".tbtools.json", encoding="utf-8"))
|
|
756
|
+
_ec, _err = _pr.get("exit_code"), _pr.get("error")
|
|
757
|
+
except Exception:
|
|
758
|
+
pass
|
|
759
|
+
break
|
|
760
|
+
if _ec is None:
|
|
761
|
+
_job_finish(job, "failed", -1)
|
|
762
|
+
else:
|
|
763
|
+
_job_finish(job, "succeeded" if _ec == 0 else "failed", _ec)
|
|
764
|
+
job["error"] = _err
|
|
765
|
+
_job_save(job)
|
|
766
|
+
click.echo(_json.dumps({"job_id": job_id, "status": job.get("status"),
|
|
767
|
+
"exit_code": job.get("exit_code"),
|
|
768
|
+
"started_at": job.get("started_at"),
|
|
769
|
+
"finished_at": job.get("finished_at"),
|
|
770
|
+
"log": job.get("log") or os.path.join(_jobs_dir(), f"{job_id}.log")},
|
|
771
|
+
ensure_ascii=False, indent=1))
|
|
772
|
+
|
|
773
|
+
@cli.command(name="job-log")
|
|
774
|
+
@click.argument("job_id")
|
|
775
|
+
@click.option("--tail", "n", type=int, default=30)
|
|
776
|
+
def job_log(job_id, n):
|
|
777
|
+
"""查看任务日志(尾部 N 行)"""
|
|
778
|
+
lf = os.path.join(_jobs_dir(), f"{job_id}.log")
|
|
779
|
+
if not os.path.isfile(lf):
|
|
780
|
+
click.echo(f"❌ 无日志: {job_id}", err=True)
|
|
781
|
+
sys.exit(1)
|
|
782
|
+
lines = open(lf, encoding="utf-8", errors="replace").read().splitlines()
|
|
783
|
+
click.echo("\n".join(lines[-n:]))
|
|
784
|
+
|
|
785
|
+
@cli.command(name="job-result")
|
|
786
|
+
@click.argument("job_id")
|
|
787
|
+
def job_result(job_id):
|
|
788
|
+
"""任务结构化结果(状态机终态 + artifacts + error; Agent 结果验证)"""
|
|
789
|
+
import json as _json
|
|
790
|
+
jf = os.path.join(_jobs_dir(), f"{job_id}.json")
|
|
791
|
+
if not os.path.isfile(jf):
|
|
792
|
+
click.echo(f"❌ 未知 job: {job_id}", err=True)
|
|
793
|
+
sys.exit(1)
|
|
794
|
+
job = _json.load(open(jf, encoding="utf-8"))
|
|
795
|
+
artifacts = []
|
|
796
|
+
for a in reversed(job.get("args", [])):
|
|
797
|
+
if a.endswith((".svg", ".png", ".pdf")):
|
|
798
|
+
if os.path.isfile(a):
|
|
799
|
+
artifacts.append(os.path.abspath(a))
|
|
800
|
+
break
|
|
801
|
+
click.echo(_json.dumps({"schema_version": "1.0", "job_id": job_id,
|
|
802
|
+
"status": job.get("status"), "exit_code": job.get("exit_code"),
|
|
803
|
+
"artifacts": artifacts, "error": job.get("error")},
|
|
804
|
+
ensure_ascii=False, indent=1))
|
|
805
|
+
|
|
806
|
+
@cli.command(name="job-cancel")
|
|
807
|
+
@click.argument("job_id")
|
|
808
|
+
def job_cancel(job_id):
|
|
809
|
+
"""取消任务(杀进程树, 含 java/xvfb 子进程; 终态 cancelled)"""
|
|
810
|
+
import json as _json
|
|
811
|
+
import signal as _sig
|
|
812
|
+
jf = os.path.join(_jobs_dir(), f"{job_id}.json")
|
|
813
|
+
if not os.path.isfile(jf):
|
|
814
|
+
click.echo(f"❌ 未知 job: {job_id}", err=True)
|
|
815
|
+
sys.exit(1)
|
|
816
|
+
job = _json.load(open(jf, encoding="utf-8"))
|
|
817
|
+
if job.get("status") != "running" or not job.get("pid"):
|
|
818
|
+
click.echo(f"⏹ {job_id} 非运行中(当前 {job.get('status')})", err=True)
|
|
819
|
+
sys.exit(1)
|
|
820
|
+
try:
|
|
821
|
+
os.killpg(os.getpgid(job["pid"]), _sig.SIGKILL)
|
|
822
|
+
_job_finish(job, "cancelled", -1)
|
|
823
|
+
click.echo(f"✅ 已取消 {job_id}(PID {job['pid']} 进程组)")
|
|
824
|
+
except Exception:
|
|
825
|
+
# 进程已死(未落盘终态)→ 惰性判定(provenance)
|
|
826
|
+
_ec, _err = None, None
|
|
827
|
+
for a in reversed(job.get("args", [])):
|
|
828
|
+
if a.endswith((".svg", ".png", ".pdf")) and os.path.isfile(a + ".tbtools.json"):
|
|
829
|
+
try:
|
|
830
|
+
import json as _json
|
|
831
|
+
_pr = _json.load(open(a + ".tbtools.json", encoding="utf-8"))
|
|
832
|
+
_ec, _err = _pr.get("exit_code"), _pr.get("error")
|
|
833
|
+
except Exception:
|
|
834
|
+
pass
|
|
835
|
+
break
|
|
836
|
+
if _ec is None:
|
|
837
|
+
_job_finish(job, "failed", -1)
|
|
838
|
+
else:
|
|
839
|
+
_job_finish(job, "succeeded" if _ec == 0 else "failed", _ec)
|
|
840
|
+
job["error"] = _err
|
|
841
|
+
_job_save(job)
|
|
842
|
+
click.echo(f"⏹ {job_id} 进程已结束(终态 {job['status']}, exit {job.get('exit_code')})")
|
|
843
|
+
|
|
844
|
+
@cli.command(name="job-clean")
|
|
845
|
+
@click.option("--days", "days", type=int, default=7, help="清理 N 天前已结束(非 running)的 job(默认 7)")
|
|
846
|
+
@click.option("--all", "all_flag", is_flag=True, help="清理全部已结束 job(忽略天数)")
|
|
847
|
+
def job_clean(days, all_flag):
|
|
848
|
+
"""清理历史 job(已结束且超期的 json/log)"""
|
|
849
|
+
import json as _json
|
|
850
|
+
import time as _time
|
|
851
|
+
jdir = _jobs_dir()
|
|
852
|
+
now = _time.time()
|
|
853
|
+
removed = []
|
|
854
|
+
for f in os.listdir(jdir):
|
|
855
|
+
if not (f.endswith(".json") or f.endswith(".log")):
|
|
856
|
+
continue
|
|
857
|
+
jid = f.rsplit(".", 1)[0]
|
|
858
|
+
jp = os.path.join(jdir, f"{jid}.json")
|
|
859
|
+
try:
|
|
860
|
+
job = _json.load(open(jp, encoding="utf-8"))
|
|
861
|
+
except Exception:
|
|
862
|
+
continue
|
|
863
|
+
if job.get("status") == "running":
|
|
864
|
+
continue # 运行中不碰
|
|
865
|
+
age = now - os.path.getmtime(jp)
|
|
866
|
+
if all_flag or age > days * 86400:
|
|
867
|
+
for ext in (".json", ".log"):
|
|
868
|
+
p = os.path.join(jdir, jid + ext)
|
|
869
|
+
if os.path.isfile(p):
|
|
870
|
+
os.unlink(p)
|
|
871
|
+
removed.append(os.path.basename(p))
|
|
872
|
+
if removed:
|
|
873
|
+
click.echo(f"🗑 清理 {len(removed)} 个文件(>={days} 天前已结束):")
|
|
874
|
+
for r in removed[:8]:
|
|
875
|
+
click.echo(f" {r}")
|
|
876
|
+
if len(removed) > 8:
|
|
877
|
+
click.echo(f" ... 等 {len(removed) - 8} 个")
|
|
878
|
+
else:
|
|
879
|
+
click.echo(" 无过期 job 可清理(running 任务保留)")
|
|
880
|
+
|
|
881
|
+
@cli.command(name="search")
|
|
882
|
+
@click.argument("keyword", required=False)
|
|
883
|
+
@click.option("--json", "as_json", is_flag=True, help="结构化输出(JSON, 供 Agent 发现)")
|
|
884
|
+
@click.option("--input", "in_fmt", default=None, help="反向搜索: 输入格式(gff3/fasta/tsv/...)")
|
|
885
|
+
@click.option("--output", "out_fmt", default=None, help="反向搜索: 输出格式(svg/tsv/nwk/...)")
|
|
886
|
+
@click.option("--capability", "cap", default=None, help="反向搜索: 能力标签(如 phylogeny/enrichment)")
|
|
887
|
+
def search_cmd(keyword, as_json, in_fmt, out_fmt, cap):
|
|
888
|
+
"""模糊搜索命令(匹配名称+doc): tbtools search <关键词> [--json]"""
|
|
889
|
+
import json as _json
|
|
890
|
+
if not keyword and not (in_fmt or out_fmt or cap):
|
|
891
|
+
click.echo(_json.dumps({"query": "", "hits": [], "error": "need keyword or --input/--output/--capability"},
|
|
892
|
+
ensure_ascii=False) if as_json
|
|
893
|
+
else "❌ 需要关键词或 --input/--output/--capability。试试: tbtools search volcano")
|
|
894
|
+
sys.exit(1)
|
|
895
|
+
meta_path = os.path.join(ROOT, "tbtools_cli", "command_metadata.json")
|
|
896
|
+
if not os.path.isfile(meta_path):
|
|
897
|
+
click.echo(f"❌ 元数据缺失: {meta_path}", err=True)
|
|
898
|
+
sys.exit(1)
|
|
899
|
+
meta = _json.load(open(meta_path, encoding="utf-8"))
|
|
900
|
+
# 多词查询: 空格拆分, 全部词须命中(名/help/class)——Agent 自然语言("gene structure")
|
|
901
|
+
kws = [w for w in (keyword or "").lower().split() if w]
|
|
902
|
+
hits = []
|
|
903
|
+
for name, v in meta.items():
|
|
904
|
+
hay = " ".join([name.lower(),
|
|
905
|
+
(v.get("help", "") or "")[:300].lower(),
|
|
906
|
+
(v.get("class", "") or "").lower()])
|
|
907
|
+
if all(w in hay for w in kws):
|
|
908
|
+
cat = _LG.CATEGORY_MAP.get(name, "engine")
|
|
909
|
+
kind = v.get("kind", "?")
|
|
910
|
+
desc = (v.get("help", "") or "").split("#")[-1].strip()[:60]
|
|
911
|
+
hits.append((name, cat, kind, desc))
|
|
912
|
+
if not hits and len(kws) > 1:
|
|
913
|
+
# AND 无果回退 OR(Agent 宽松匹配优于无结果)
|
|
914
|
+
for name, v in meta.items():
|
|
915
|
+
hay = " ".join([name.lower(), (v.get("help", "") or "")[:300].lower(),
|
|
916
|
+
(v.get("class", "") or "").lower()])
|
|
917
|
+
if any(w in hay for w in kws):
|
|
918
|
+
cat = v.get("group") or _LG.CATEGORY_MAP.get(name, "engine")
|
|
919
|
+
kind = v.get("kind", "?")
|
|
920
|
+
desc = (v.get("help", "") or "").split("#")[-1].strip()[:60]
|
|
921
|
+
hits.append((name, cat, kind, desc))
|
|
922
|
+
|
|
923
|
+
# 反向/能力过滤(GLM: 能力图搜索; --input/--output/--capability)
|
|
924
|
+
if (in_fmt or out_fmt or cap) and meta:
|
|
925
|
+
filtered = []
|
|
926
|
+
for name, v in meta.items():
|
|
927
|
+
ins = [i.get("format", "") for i in v.get("inputs", [])] if v.get("inputs") else []
|
|
928
|
+
outs = v.get("outputs", []) or []
|
|
929
|
+
caps = v.get("capabilities", []) or []
|
|
930
|
+
if in_fmt and in_fmt not in ins:
|
|
931
|
+
continue
|
|
932
|
+
if v.get("alias_of") and not kws:
|
|
933
|
+
continue # 反向/能力搜索: alias 不污染工具空间(仅 canonical)
|
|
934
|
+
if out_fmt and out_fmt not in outs:
|
|
935
|
+
continue
|
|
936
|
+
if cap and cap not in caps:
|
|
937
|
+
continue
|
|
938
|
+
cat = v.get("group") or _LG.CATEGORY_MAP.get(name, "engine")
|
|
939
|
+
desc = (v.get("help", "") or "").split("#")[-1].strip()[:60]
|
|
940
|
+
filtered.append((name, cat, v.get("kind", "?"), desc))
|
|
941
|
+
hits = filtered
|
|
942
|
+
if not hits:
|
|
943
|
+
if not keyword and not (in_fmt or out_fmt or cap):
|
|
944
|
+
click.echo(_json.dumps({"query": "", "hits": [], "error": "need keyword or --input/--output/--capability"},
|
|
945
|
+
ensure_ascii=False) if as_json
|
|
946
|
+
else "❌ 需要关键词或 --input/--output/--capability。试试: tbtools search volcano")
|
|
947
|
+
sys.exit(1)
|
|
948
|
+
click.echo(_json.dumps({"query": keyword or "", "hits": []}, ensure_ascii=False) if as_json
|
|
949
|
+
else f"❌ 没有匹配 '{keyword}' 的命令。试试: tbtools list")
|
|
950
|
+
sys.exit(1)
|
|
951
|
+
if as_json:
|
|
952
|
+
click.echo(_json.dumps({"query": keyword, "hits": [
|
|
953
|
+
{"name": n, "group": g, "kind": k, "description": d} for n, g, k, d in sorted(hits)
|
|
954
|
+
]}, ensure_ascii=False, indent=1))
|
|
955
|
+
return
|
|
956
|
+
kw_disp = keyword or ""
|
|
957
|
+
click.echo(f"🔍 匹配 '{kw_disp}' 的命令({len(hits)} 个):")
|
|
958
|
+
for name, cat, kind, desc in sorted(hits):
|
|
959
|
+
click.echo(f" {name:24s} [{cat}/{kind}] {desc}")
|
|
960
|
+
click.echo("\n查看详情: tbtools help <命令> | 全量: tbtools list")
|
|
961
|
+
|
|
962
|
+
@cli.command(name='list')
|
|
963
|
+
@click.argument('category', required=False)
|
|
964
|
+
def listing(category):
|
|
965
|
+
"""列出可用命令(plots/tools/rpc)——TTY 下自动分页"""
|
|
966
|
+
lines = []
|
|
967
|
+
if not category or category == 'plots':
|
|
968
|
+
lines.append(c("绘图/分析命令:", "bold"))
|
|
969
|
+
for gname in sorted(_LG.GROUPS.keys()):
|
|
970
|
+
g = _LG._groups.get(gname)
|
|
971
|
+
if g and g.commands:
|
|
972
|
+
lines.append(f"\n {c(gname, 'cyan', bold=True)} — {_LG.GROUPS[gname]}")
|
|
973
|
+
for cname, cmd in sorted(g.commands.items()):
|
|
974
|
+
short = (cmd.help or '').split('\n')[0][:60]
|
|
975
|
+
lines.append(f" {c(f'{cname:20s}', 'green')} {short}")
|
|
976
|
+
if not category:
|
|
977
|
+
lines.append("\n用法: tbtools list tools|rpc")
|
|
978
|
+
elif category == 'tools':
|
|
979
|
+
lines.append("命令行工具:")
|
|
980
|
+
# 排除绘图类命令(属于 seq/expr/tree/syn/sets/chipseq 分组的)
|
|
981
|
+
plot_groups = {'seq', 'expr', 'tree', 'syn', 'sets', 'chipseq'}
|
|
982
|
+
# V1 §3 P3-9: 插件工具在绘图分组但实为命令行工具,强制显示(kallisto/fimo 等)
|
|
983
|
+
plugin_tools = {"kallisto", "memeViz", "qdot", "tfbsShift", "smart", "fimo",
|
|
984
|
+
"mcscanxd", "notung", "quickAnno", "hmmerSearch", "gsea", "meme", "mast"}
|
|
985
|
+
count = 0
|
|
986
|
+
for n in sorted(dir(_ac)):
|
|
987
|
+
if n.startswith('_') and n.endswith('_impl') and not n.startswith('__'):
|
|
988
|
+
cmd = n[1:-5]
|
|
989
|
+
cat = _LG.CATEGORY_MAP.get(cmd, 'engine')
|
|
990
|
+
if cat in plot_groups and cmd not in plugin_tools:
|
|
991
|
+
continue # 跳过绘图类(插件工具除外)
|
|
992
|
+
doc = getattr(_ac, n).__doc__ or ''
|
|
993
|
+
short = doc.split(':',1)[1].strip()[:60] if ':' in doc else ''
|
|
994
|
+
lines.append(f" {cmd:20s} {short}")
|
|
995
|
+
count += 1
|
|
996
|
+
# 加上共享注册表(P0-3:82 个 CLI 工具,排除已被 _impl 覆盖的)
|
|
997
|
+
reg_count = 0
|
|
998
|
+
for tname in sorted(CLI_TOOLS):
|
|
999
|
+
if getattr(_ac, f'_{tname}_impl', None):
|
|
1000
|
+
continue # 已在上面列出
|
|
1001
|
+
lines.append(f" {tname:20s} {CLI_TOOLS[tname].split('.')[-1]}")
|
|
1002
|
+
reg_count += 1
|
|
1003
|
+
# 加上手动注册的 3 个
|
|
1004
|
+
lines.append(f"\n共 {count + reg_count + 3} 个工具(含 {reg_count} 个注册表工具 + 3 个手动迁移)")
|
|
1005
|
+
elif category == 'rpc':
|
|
1006
|
+
lines.append("RPC 方法(188 个),启动 RPC 服务器后可用:")
|
|
1007
|
+
lines.append(" tbtools_rpc.sh start # 启动")
|
|
1008
|
+
lines.append(" tbtools_rpc.sh methods # 列出全部 188 方法")
|
|
1009
|
+
lines.append(" tbtools_rpc.sh call <method> '<json>'")
|
|
1010
|
+
else:
|
|
1011
|
+
click.echo(f"未知类别: {category}。可用: plots|tools|rpc", err=True)
|
|
1012
|
+
sys.exit(1)
|
|
1013
|
+
# 输出:TTY 且行数多 → pager;否则直接打印(管道/重定向可 grep)
|
|
1014
|
+
text = "\n".join(lines)
|
|
1015
|
+
is_tty = hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
|
|
1016
|
+
if is_tty and len(lines) > 20:
|
|
1017
|
+
click.echo_via_pager(text)
|
|
1018
|
+
else:
|
|
1019
|
+
click.echo(text)
|
|
1020
|
+
|
|
1021
|
+
@cli.command()
|
|
1022
|
+
@click.argument("name", required=False)
|
|
1023
|
+
def presets(name):
|
|
1024
|
+
"""列出或查看出版预设"""
|
|
1025
|
+
if name:
|
|
1026
|
+
p = PRESETS.get(name)
|
|
1027
|
+
if not p:
|
|
1028
|
+
click.echo(f"❌ 未知预设: {name}")
|
|
1029
|
+
click.echo(f" 可用: {', '.join(PRESETS.keys())}")
|
|
1030
|
+
sys.exit(1)
|
|
1031
|
+
click.echo(f"\n 预设: {name}")
|
|
1032
|
+
click.echo(f" 描述: {p['desc']}")
|
|
1033
|
+
for k, v in p.items():
|
|
1034
|
+
if k != 'desc':
|
|
1035
|
+
click.echo(f" {k}: {v}")
|
|
1036
|
+
else:
|
|
1037
|
+
click.echo("出版级预设模板")
|
|
1038
|
+
click.echo("用法: tbtools <命令> ... --preset <名称>\n")
|
|
1039
|
+
for n, d in list_presets():
|
|
1040
|
+
click.echo(f" {n:20s} {d}")
|
|
1041
|
+
|
|
1042
|
+
# ---- new 交互式向导 ----
|
|
1043
|
+
@cli.command()
|
|
1044
|
+
@click.option("--list", "list_only", is_flag=True, help="只列出场景,不交互")
|
|
1045
|
+
@click.option("--run", "run_it", is_flag=True, help="生成后直接执行")
|
|
1046
|
+
def new(list_only, run_it):
|
|
1047
|
+
"""交互式向导:按「想做什么」生成命令"""
|
|
1048
|
+
from tbtools_cli.scenarios import SCENARIOS, _categories
|
|
1049
|
+
if list_only:
|
|
1050
|
+
click.echo("可用的场景(tbtools new 交互式选择):")
|
|
1051
|
+
for cat, scens in _categories.items():
|
|
1052
|
+
click.echo(f"\n [{cat}]")
|
|
1053
|
+
for s in scens:
|
|
1054
|
+
sc = SCENARIOS[s]
|
|
1055
|
+
click.echo(f" {s:22s} → tbtools {sc['group']} {sc['cmd']}")
|
|
1056
|
+
return
|
|
1057
|
+
# 非 TTY 的 stdin:拒绝(引导 --list)——但允许显式测试/管道注入
|
|
1058
|
+
if not (hasattr(sys.stdin, "isatty") and sys.stdin.isatty()):
|
|
1059
|
+
# 检查 stdin 是否有数据(管道注入)
|
|
1060
|
+
try:
|
|
1061
|
+
import select
|
|
1062
|
+
if select.select([sys.stdin], [], [], 0)[0]:
|
|
1063
|
+
pass # 有管道输入,允许
|
|
1064
|
+
else:
|
|
1065
|
+
raise Exception("no input")
|
|
1066
|
+
except Exception:
|
|
1067
|
+
click.echo("交互式向导需要终端。\n tbtools new --list # 查看全部场景\n tbtools help <命令> # 查看用法", err=True)
|
|
1068
|
+
sys.exit(1)
|
|
1069
|
+
_run_new_wizard(_categories, SCENARIOS, run_it)
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def _wiz_prompt(text, *a, **kw):
|
|
1073
|
+
"""click.prompt 封装,EOF/非交互时友好退出"""
|
|
1074
|
+
try:
|
|
1075
|
+
return click.prompt(text, *a, **kw)
|
|
1076
|
+
except (EOFError, KeyboardInterrupt, click.Abort):
|
|
1077
|
+
click.echo("(已取消,未生成命令)", err=True)
|
|
1078
|
+
sys.exit(130)
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
def _run_new_wizard(_categories, SCENARIOS, run_it=False):
|
|
1082
|
+
"""执行向导:选类别 → 选场景 → 填参数 → 出命令"""
|
|
1083
|
+
# 1. 选类别
|
|
1084
|
+
cats = list(_categories.keys())
|
|
1085
|
+
click.echo("\n🧭 你想做什么?(选一个类型)")
|
|
1086
|
+
for i, cat in enumerate(cats, 1):
|
|
1087
|
+
click.echo(f" {i}. {cat}")
|
|
1088
|
+
cat_idx = _wiz_prompt("\n选择 [1-%d]" % len(cats), type=click.IntRange(1, len(cats)), default=1)
|
|
1089
|
+
cat = cats[cat_idx - 1]
|
|
1090
|
+
|
|
1091
|
+
# 2. 选场景
|
|
1092
|
+
scens = _categories[cat]
|
|
1093
|
+
click.echo(f"\n📂 [{cat}] 具体场景:")
|
|
1094
|
+
for i, s in enumerate(scens, 1):
|
|
1095
|
+
sc = SCENARIOS[s]
|
|
1096
|
+
click.echo(f" {i}. {s}\n {sc['desc']}")
|
|
1097
|
+
sc_idx = _wiz_prompt("\n选择 [1-%d]" % len(scens), type=click.IntRange(1, len(scens)), default=1)
|
|
1098
|
+
scen = SCENARIOS[scens[sc_idx - 1]]
|
|
1099
|
+
|
|
1100
|
+
# 3. 填参数(交互)
|
|
1101
|
+
click.echo(f"\n🔧 命令: tbtools {scen['group']} {scen['cmd']}")
|
|
1102
|
+
click.echo(f" 用法: {scen['usage']}\n")
|
|
1103
|
+
parts = [scen['cmd']]
|
|
1104
|
+
if scen['args']:
|
|
1105
|
+
click.echo("填参数(直接回车用默认/跳过,可稍后改):")
|
|
1106
|
+
for label, default in scen['args']:
|
|
1107
|
+
p = _wiz_prompt(f" {label}", default=default if default else None, show_default=False)
|
|
1108
|
+
p = (p or "").strip()
|
|
1109
|
+
if p:
|
|
1110
|
+
parts.append(p)
|
|
1111
|
+
else:
|
|
1112
|
+
click.echo("(此命令无需位置参数,请用 --help 看选项)")
|
|
1113
|
+
|
|
1114
|
+
# 4. 展示最终命令
|
|
1115
|
+
cmd_str = f"tbtools {scen['group']} " + " ".join(parts)
|
|
1116
|
+
click.echo("\n✅ 生成命令:")
|
|
1117
|
+
click.echo(f" {cmd_str}")
|
|
1118
|
+
# 坑位
|
|
1119
|
+
pit = get_pitfall_hint(scen['cmd'])
|
|
1120
|
+
if pit:
|
|
1121
|
+
click.echo(f"\n ⚠️ 坑位: {pit}")
|
|
1122
|
+
if run_it:
|
|
1123
|
+
click.echo("\n🚀 正在执行...")
|
|
1124
|
+
# 剥离前面的 'tbtools' 并调用相应分组命令
|
|
1125
|
+
argv = cmd_str.split()[1:] # 去 tbtools,剩 [group, args...]
|
|
1126
|
+
sys.exit(cli.main(argv, standalone_mode=False) or 0)
|
|
1127
|
+
click.echo(f"\n 复制后运行,或:\n 直接运行: tbtools {scen['group']} {scen['cmd']} --help")
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
# ---- check 格式探测器 ----
|
|
1131
|
+
@cli.command()
|
|
1132
|
+
@click.argument('files', nargs=-1, required=True)
|
|
1133
|
+
def check(files):
|
|
1134
|
+
"""探测文件格式:tbtools check <文件...>(FASTA/GFF/Newick/XML/TSV)"""
|
|
1135
|
+
import json as _json
|
|
1136
|
+
bad = 0 # N20: check 对不存在的文件报错后应非零退出
|
|
1137
|
+
for path in files:
|
|
1138
|
+
ok, msg = validate_file(path, "文件")
|
|
1139
|
+
if not ok:
|
|
1140
|
+
click.echo(msg, err=True)
|
|
1141
|
+
bad += 1
|
|
1142
|
+
continue
|
|
1143
|
+
fmt, ncols, sample = detect_format(path)
|
|
1144
|
+
import os as _os
|
|
1145
|
+
size = _os.path.getsize(path)
|
|
1146
|
+
with open(path, 'rb') as fh:
|
|
1147
|
+
head = fh.read(4)
|
|
1148
|
+
gz = head[:2] == b'\x1f\x8b'
|
|
1149
|
+
lines = sum(1 for _ in open(path, 'r', errors='replace')) if size < 50_000_000 else None
|
|
1150
|
+
click.echo(f"\n📄 {path}")
|
|
1151
|
+
click.echo(f" 格式: {fmt}{'+gzip' if gz else ''} 大小: {size:,}B" + (f" 行数: {lines:,}" if lines is not None else ""))
|
|
1152
|
+
if ncols:
|
|
1153
|
+
click.echo(f" 列数: {ncols}")
|
|
1154
|
+
for s in sample[:2]:
|
|
1155
|
+
disp = s if len(s) <= 90 else s[:87] + "..."
|
|
1156
|
+
click.echo(f" 样例: {disp}")
|
|
1157
|
+
if _json.dumps(sample[:1], ensure_ascii=False) and fmt == "unknown":
|
|
1158
|
+
click.echo(" (无法识别格式;如是表格确认分隔符是 Tab 还是逗号)")
|
|
1159
|
+
if bad:
|
|
1160
|
+
sys.exit(1)
|