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.
Files changed (139) hide show
  1. tbtools_cli/__init__.py +18 -0
  2. tbtools_cli/auto_commands.py +1149 -0
  3. tbtools_cli/cli.py +528 -0
  4. tbtools_cli/cli_load.py +352 -0
  5. tbtools_cli/cli_rpc.py +303 -0
  6. tbtools_cli/cli_tools_registry.py +93 -0
  7. tbtools_cli/cli_top.py +1160 -0
  8. tbtools_cli/command_metadata.json +4606 -0
  9. tbtools_cli/command_spec.py +382 -0
  10. tbtools_cli/config.example.toml +19 -0
  11. tbtools_cli/config.py +43 -0
  12. tbtools_cli/core.py +508 -0
  13. tbtools_cli/errors.py +38 -0
  14. tbtools_cli/presets.py +100 -0
  15. tbtools_cli/scenarios.py +92 -0
  16. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/AdmixtureCli.java +91 -0
  17. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/AmazingMetaCli.java +78 -0
  18. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/AssemblyRecommandCli.java +47 -0
  19. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/BamIndexCli.java +31 -0
  20. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/BamSortCli.java +43 -0
  21. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/BamStateCli.java +49 -0
  22. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/BarPlotterCli.java +18 -0
  23. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/BarplotCli.java +168 -0
  24. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/BatchVizMotifsCli.java +47 -0
  25. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/BlastXmlConvertCli.java +45 -0
  26. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/BlatExecutorCli.java +66 -0
  27. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/CalcRepeatCli.java +62 -0
  28. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/CalculateSimilarityCli.java +34 -0
  29. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/CddMotifCli.java +46 -0
  30. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/CircleGeneViewerCli.java +81 -0
  31. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/CircosCli.java +76 -0
  32. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/ColorSchemeCli.java +29 -0
  33. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/CtgGroupCli.java +29 -0
  34. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/CubeHeatmapCli.java +70 -0
  35. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/DeHistCli.java +46 -0
  36. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/DegramdomCli.java +33 -0
  37. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/DiffExpCli.java +44 -0
  38. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/DistanceCli.java +53 -0
  39. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/DualSynCli.java +147 -0
  40. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/EggnogCli.java +39 -0
  41. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/ExprCorrCli.java +27 -0
  42. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/FastaMergerCli.java +36 -0
  43. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/FastaTableConvertCli.java +38 -0
  44. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/FileCleanerCli.java +31 -0
  45. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/FileSplitCli.java +15 -0
  46. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/FindBlockDualCli.java +71 -0
  47. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/FindBlockMultipleCli.java +67 -0
  48. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/FindPathCli.java +15 -0
  49. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/GSEAWrapperCli.java +33 -0
  50. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/GenBank2FastaCli.java +32 -0
  51. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/GeneDensityCli.java +38 -0
  52. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/GeneLocGffCli.java +140 -0
  53. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/GeneStructureCli.java +90 -0
  54. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/GenericCli.java +177 -0
  55. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/GetENALinksCli.java +36 -0
  56. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/GetSubNewickTreeCli.java +50 -0
  57. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/GoEnrichCli.java +42 -0
  58. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/GoLevelCli.java +59 -0
  59. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/GroupedBarCli.java +95 -0
  60. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/GsaDiagCli.java +45 -0
  61. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/GxfFilterCli.java +39 -0
  62. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/GxfSortCli.java +23 -0
  63. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/HclustCli.java +43 -0
  64. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/HeatmapCli.java +89 -0
  65. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/HmmerSuiteCli.java +47 -0
  66. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/JgblocksCli.java +65 -0
  67. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/KeggEnrichCli.java +31 -0
  68. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/LayoutHeatmapCli.java +90 -0
  69. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MCScanXCli.java +80 -0
  70. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MCScanXFastCli.java +41 -0
  71. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MSACli.java +46 -0
  72. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MakeMotifCli.java +39 -0
  73. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MarkerDesignCli.java +62 -0
  74. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MarkerToolsCli.java +57 -0
  75. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/Mast2TabCli.java +27 -0
  76. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MastCli.java +54 -0
  77. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MastRunCli.java +39 -0
  78. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/Meme2TabCli.java +26 -0
  79. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MemeCli.java +74 -0
  80. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MemeRunCli.java +40 -0
  81. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MgGxfCli.java +35 -0
  82. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MicroSynCli.java +118 -0
  83. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MirIdentifyCli.java +55 -0
  84. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MotifCli.java +75 -0
  85. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MotifPatternCli.java +67 -0
  86. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MotifShiftCli.java +30 -0
  87. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MountainPlotCli.java +44 -0
  88. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/MultiSuperHeatCli.java +64 -0
  89. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/NeedlemanWunschCli.java +76 -0
  90. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/PafGC.java +15 -0
  91. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/PafVizCli.java +67 -0
  92. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/PeakDistCli.java +58 -0
  93. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/Pep2CodonCli.java +26 -0
  94. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/PfamMotifCli.java +43 -0
  95. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/PhyloTreeCli.java +92 -0
  96. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/PileUpCli.java +55 -0
  97. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/PlantCAREResultClassifyCli.java +36 -0
  98. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/PubmedSearchCli.java +25 -0
  99. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/QpcrCli.java +69 -0
  100. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/QpcrDdctCli.java +28 -0
  101. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/QpcrProcCli.java +28 -0
  102. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/QuickGenomeDotCli.java +66 -0
  103. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/QuickProteinAnnoCli.java +34 -0
  104. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/QuickRunIQtreeCli.java +69 -0
  105. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/QuickTrimALCli.java +67 -0
  106. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/RNAplotCli.java +129 -0
  107. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/RegionBedToGFF3Cli.java +43 -0
  108. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/RegionDepthCli.java +29 -0
  109. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/SamBamCovCli.java +33 -0
  110. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/SeqConverterCli.java +17 -0
  111. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/SeqLenTrackCli.java +41 -0
  112. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/SeveralSpeciesCli.java +132 -0
  113. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/SimpleEnricherCli.java +44 -0
  114. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/SimpleHmmscanCli.java +32 -0
  115. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/SixFrameTranlaterCli.java +35 -0
  116. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/StructAnnoCompareCli.java +66 -0
  117. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/SubmitSMARTCli.java +30 -0
  118. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/SuperCircosCli.java +330 -0
  119. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/TableColManipCli.java +56 -0
  120. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/TableCollapseCli.java +33 -0
  121. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/TargetScoreCli.java +47 -0
  122. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/TauCalcCli.java +28 -0
  123. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/TaxonomyBatchCli.java +72 -0
  124. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/TreeCli.java +75 -0
  125. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/TreeRootingCli.java +30 -0
  126. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/TrimMSACli.java +33 -0
  127. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/UnrootedTreeCli.java +44 -0
  128. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/UpSetCli.java +90 -0
  129. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/Venn5Cli.java +60 -0
  130. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/Venn6Cli.java +65 -0
  131. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/ViolinCli.java +66 -0
  132. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/VisualizeCli.java +57 -0
  133. tbtools_cli-1.2.0.data/data/tbtools_cli/bridges/VizGFACli.java +39 -0
  134. tbtools_cli-1.2.0.dist-info/METADATA +504 -0
  135. tbtools_cli-1.2.0.dist-info/RECORD +139 -0
  136. tbtools_cli-1.2.0.dist-info/WHEEL +5 -0
  137. tbtools_cli-1.2.0.dist-info/entry_points.txt +2 -0
  138. tbtools_cli-1.2.0.dist-info/licenses/LICENSE +21 -0
  139. 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)