From 40e699e173aee647d15f96e936291a2bca163aeb Mon Sep 17 00:00:00 2001 From: Jiang Date: Tue, 2 Jun 2026 14:54:08 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=E4=BB=A3=E7=A0=81=EF=BC=9B?= =?UTF-8?q?=E7=BA=A6=E6=9D=9Fcli=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cli/.gitignore | 3 + cli/README.md | 51 ++++++---- cli/build.sh | 26 +++++ cli/entrypoint.py | 5 + cli/requirements-build.txt | 1 + cli/tests/unit/test_tjwater_cli.py | 125 +++++++++++++---------- cli/tjwater | 17 ---- cli/tjwater.spec | 45 +++++++++ cli/tjwater_cli/apps.py | 52 +++++----- cli/tjwater_cli/commands_analysis.py | 8 +- cli/tjwater_cli/commands_project.py | 2 +- cli/tjwater_cli/core.py | 3 +- cli/tjwater_cli/formatters.py | 15 +++ cli/tjwater_cli/helping.py | 142 +++++++++++++++------------ cli/tjwater_cli/main.py | 17 ++-- cli/tjwater_cli/registry.py | 24 ++--- cli/tjwater_cli_endpoint_scope.md | 126 ++++++++++++------------ 17 files changed, 395 insertions(+), 267 deletions(-) create mode 100644 cli/.gitignore create mode 100755 cli/build.sh create mode 100644 cli/entrypoint.py create mode 100644 cli/requirements-build.txt delete mode 100755 cli/tjwater create mode 100644 cli/tjwater.spec create mode 100644 cli/tjwater_cli/formatters.py diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 0000000..995f861 --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,3 @@ +dist/ +build/ +__pycache__/ diff --git a/cli/README.md b/cli/README.md index c31819e..356638a 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,57 +1,68 @@ # TJWater CLI -独立于服务端主代码的 Python CLI 文件夹,放在 `TJWaterServerBinary/cli/` 下,供 agent 服务器**直接调用并通过 stdout/stderr 参与管道**。 +独立于服务端主代码的 Python CLI 文件夹,放在 `TJWaterServerBinary/cli/` 下,供 agent 服务器使用**编译后的可执行文件**直接调用,并通过 stdout/stderr 参与管道。 -## 直接使用 +## 构建可执行产物 ```bash cd TJWaterServerBinary/cli -./tjwater help --json +python -m pip install -r requirements.txt +python -m pip install -r requirements-build.txt +chmod +x build.sh +./build.sh ``` -这个入口文件可以直接参与管道: +构建完成后,直接使用编译产物: ```bash -./tjwater help --json | jq +./dist/tjwater-cli/tjwater-cli help ``` -它会优先使用: -1. `cli/.venv/bin/python` -2. 环境变量 `PYTHON` -3. 当前环境里的 `python` -4. 最后回退到 `python3` - -如果需要,也可以显式走 Python: +这个可执行文件可以直接参与管道: ```bash -python -m tjwater_agent_cli help --json +./dist/tjwater-cli/tjwater-cli help | jq +``` + +当前采用 `PyInstaller onedir` 方式输出到 `dist/tjwater-cli/`,避免 onefile 在部分 agent/server 环境下依赖临时目录解包执行的问题。 + +如果需要在开发时直接走源码入口,也可以显式使用 Python: + +```bash +python -m tjwater_cli help ``` ## 部署到 agent 服务器 -最简单的方式是把整个 `TJWaterServerBinary/cli/` 文件夹同步到 agent 服务器,然后直接执行: +最简单的方式是把 `dist/tjwater-cli/` 整个目录同步到 agent 服务器,然后直接执行: + +```bash +./tjwater-cli/tjwater-cli help +``` + +如果希望打包传输: ```bash cd TJWaterServerBinary/cli -./tjwater help --json +tar -C dist -czf tjwater-cli-linux-amd64.tar.gz tjwater-cli ``` 如果希望放到 PATH 中: ```bash -chmod +x tjwater -ln -s /path/to/TJWaterServerBinary/cli/tjwater /usr/local/bin/tjwater -tjwater help --json +ln -s /path/to/TJWaterServerBinary/cli/dist/tjwater-cli/tjwater-cli /usr/local/bin/tjwater-cli +tjwater-cli help | jq ``` -## Python 依赖 +## 运行与构建依赖 ```bash cd TJWaterServerBinary/cli python -m pip install -r requirements.txt +python -m pip install -r requirements-build.txt ``` -只保留运行 CLI 必需依赖,不再包含安装包构建相关内容。 +`requirements.txt` 仅包含运行 CLI 的依赖;`requirements-build.txt` 仅包含生成可执行文件所需的构建依赖。 ## 认证上下文 diff --git a/cli/build.sh b/cli/build.sh new file mode 100755 index 0000000..f6574a6 --- /dev/null +++ b/cli/build.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [ -n "${PYTHON:-}" ]; then + PYTHON_BIN="$PYTHON" +elif command -v python >/dev/null 2>&1; then + PYTHON_BIN="python" +else + PYTHON_BIN="python3" +fi + +cd "$ROOT" + +"$PYTHON_BIN" -m PyInstaller --noconfirm --clean tjwater.spec + +BIN_PATH="$ROOT/dist/" +if [ ! -x "$BIN_PATH" ]; then + echo "build succeeded but executable was not created: $BIN_PATH" >&2 + exit 1 +fi + +"$BIN_PATH" help >/dev/null + +echo "built executable: $BIN_PATH" diff --git a/cli/entrypoint.py b/cli/entrypoint.py new file mode 100644 index 0000000..46d2a02 --- /dev/null +++ b/cli/entrypoint.py @@ -0,0 +1,5 @@ +from tjwater_cli.main import console_entry + + +if __name__ == "__main__": + console_entry() diff --git a/cli/requirements-build.txt b/cli/requirements-build.txt new file mode 100644 index 0000000..31f7e39 --- /dev/null +++ b/cli/requirements-build.txt @@ -0,0 +1 @@ +pyinstaller>=6.11,<7 diff --git a/cli/tests/unit/test_tjwater_cli.py b/cli/tests/unit/test_tjwater_cli.py index 7709a71..5748cc7 100644 --- a/cli/tests/unit/test_tjwater_cli.py +++ b/cli/tests/unit/test_tjwater_cli.py @@ -1,3 +1,4 @@ +import json from pathlib import Path from typer.testing import CliRunner @@ -64,61 +65,60 @@ def test_build_runtime_context_uses_default_server(monkeypatch): assert runtime.server == core.DEFAULT_SERVER -def test_help_json_lists_commands(): +def test_help_outputs_json_lists_commands(): + result = runner.invoke(app, ["help"]) + payload = json.loads(result.stdout) + + assert result.exit_code == 0 + assert payload["schema_version"] == "tjwater-cli/v1" + assert any(command["command"] == "project" for command in payload["commands"]) + assert any(command["command"] == "analysis" for command in payload["commands"]) + assert payload["menu_level"] == 1 + assert all(command["command"] != "project list" for command in payload["commands"]) + + +def test_help_option_json_is_removed(): result = runner.invoke(app, ["help", "--json"]) - assert result.exit_code == 0 - assert '"schema_version": "tjwater-cli/v1"' in result.stdout - assert '"command": "project"' in result.stdout - assert '"command": "analysis"' in result.stdout - assert '"menu_level": 1' in result.stdout - assert '"command": "project list"' not in result.stdout - - -def test_help_defaults_to_text(): - result = runner.invoke(app, ["help"]) - - assert result.exit_code == 0 - assert "Commands:" in result.stdout - assert "project: 项目与项目级元数据相关命令。" in result.stdout - assert "analysis: 分析计算与诊断相关命令。" in result.stdout - assert "Use `tjwater help` to see subcommands." in result.stdout - assert "usage: tjwater project help" not in result.stdout - assert "example: tjwater project help" not in result.stdout - assert "project list: 列出当前用户可访问项目" not in result.stdout - assert '"schema_version": "tjwater-cli/v1"' not in result.stdout + assert result.exit_code == 2 + assert "No such option: --json" in result.output def test_simulation_help_lists_subcommands(): result = runner.invoke(app, ["simulation", "help"]) + payload = json.loads(result.stdout) assert result.exit_code == 0 - assert "模拟运行与调度相关命令。" in result.stdout - assert "simulation run: 触发指定绝对时间的模拟运行" in result.stdout - assert "usage: tjwater simulation run --start-time --duration " in result.stdout - assert "example: tjwater --auth-context auth.json simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30" in result.stdout + assert payload["summary"] == "模拟运行与调度相关命令。" + commands = {command["command"]: command for command in payload["commands"]} + assert commands["simulation run"]["summary"] == "触发指定绝对时间的模拟运行" + assert commands["simulation run"]["usage"] == "tjwater-cli simulation run --start-time --duration " + assert commands["simulation run"]["example"] == "tjwater-cli --auth-context auth.json simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30" def test_nested_group_help_lists_examples(): result = runner.invoke(app, ["analysis", "leakage", "help"]) + payload = json.loads(result.stdout) assert result.exit_code == 0 - assert "漏损分析相关命令。" in result.stdout - assert "analysis leakage identify: 执行漏损识别" in result.stdout - assert "example: tjwater --auth-context auth.json analysis leakage identify" in result.stdout + assert payload["summary"] == "漏损分析相关命令。" + commands = {command["command"]: command for command in payload["commands"]} + assert commands["analysis leakage identify"]["summary"] == "执行漏损识别" + assert commands["analysis leakage identify"]["example"] == "tjwater-cli --auth-context auth.json analysis leakage identify --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T04:04:05+08:00" def test_analysis_help_uses_group_summaries_for_nested_groups(): result = runner.invoke(app, ["analysis", "help"]) + payload = json.loads(result.stdout) + commands = {command["command"]: command for command in payload["commands"]} assert result.exit_code == 0 - assert "analysis leakage: 漏损分析相关命令。" in result.stdout - assert "analysis burst-detection: 爆管检测相关命令。" in result.stdout - assert "analysis burst-location" not in result.stdout - assert "analysis risk" not in result.stdout - assert "analysis leakage: 执行漏损识别" not in result.stdout - assert "example: tjwater --auth-context auth.json analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 30 --burst-file ./burst.json --scheme burst_case_01" in result.stdout - assert "example: tjwater --auth-context auth.json analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --duration 900" in result.stdout + assert commands["analysis leakage"]["summary"] == "漏损分析相关命令。" + assert commands["analysis burst-detection"]["summary"] == "爆管检测相关命令。" + assert "analysis burst-location" not in commands + assert "analysis risk" not in commands + assert commands["analysis burst"]["example"] == "tjwater-cli --auth-context auth.json analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 30 --burst-file ./burst.json --scheme burst_case_01" + assert commands["analysis valve"]["example"] == "tjwater-cli --auth-context auth.json analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --duration 900" def test_bare_analysis_uses_typer_help_with_descriptions(): @@ -133,23 +133,48 @@ def test_bare_analysis_uses_typer_help_with_descriptions(): assert "risk" not in result.stdout -def test_leaf_help_shows_usage_and_example(): +def test_leaf_help_outputs_json(): result = runner.invoke(app, ["help", "simulation", "run"]) + payload = json.loads(result.stdout) assert result.exit_code == 0 - assert "Command: simulation run" in result.stdout - assert "结果需后续通过 data timeseries 在对应时间段查询" in result.stdout - assert "Usage: tjwater simulation run --start-time --duration " in result.stdout - assert "Examples:" in result.stdout - assert "tjwater --auth-context auth.json simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30" in result.stdout + assert payload["command"] == "simulation run" + assert payload["output"] == "模拟触发结果;实时数据需通过 data timeseries 命令按时间段查询" + assert payload["usage"] == "tjwater-cli simulation run --start-time --duration " + assert payload["examples"] == ["tjwater-cli --auth-context auth.json simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30"] def test_project_help_uses_legal_kind_example(): result = runner.invoke(app, ["project", "help"]) + payload = json.loads(result.stdout) + commands = {command["command"]: command for command in payload["commands"]} assert result.exit_code == 0 - assert "example: tjwater --auth-context auth.json project data --kind scada-info" in result.stdout - assert "--kind time" not in result.stdout + assert commands["project data"]["example"] == "tjwater-cli --auth-context auth.json project data --kind scada-info" + assert "--kind time" not in commands["project data"]["example"] + + +def test_root_help_flag_uses_typer_style_with_examples(): + result = runner.invoke(app, ["--help"], prog_name="tjwater-cli") + + assert result.exit_code == 0 + assert "Usage: tjwater-cli" in result.stdout + assert "Examples:" in result.stdout + assert "tjwater-cli help simulation run" in result.stdout + + +def test_leaf_help_flag_includes_usage_and_example(): + result = runner.invoke(app, ["simulation", "run", "--help"], prog_name="tjwater-cli") + + assert result.exit_code == 0 + assert "Usage: tjwater-cli simulation run [OPTIONS]" in result.stdout + assert "Usage example:" in result.stdout + assert "--start-time " in result.stdout + assert "--duration" in result.stdout + assert "Examples:" in result.stdout + assert "tjwater-cli simulation run" in result.stdout + assert "START_TIME" in result.stdout + assert "DURATION" in result.stdout def test_analysis_burst_returns_next_step_to_fetch_scheme(monkeypatch, tmp_path: Path): @@ -186,8 +211,8 @@ def test_analysis_burst_returns_next_step_to_fetch_scheme(monkeypatch, tmp_path: assert result.exit_code == 0 assert '"summary": "爆管分析执行成功"' in result.stdout - assert '"tjwater --auth-context auth.json data scheme get --name burst_case_01"' in result.stdout - assert '"tjwater --auth-context auth.json data scheme list"' in result.stdout + assert '"tjwater-cli --auth-context auth.json data scheme get --name burst_case_01"' in result.stdout + assert '"tjwater-cli --auth-context auth.json data scheme list"' in result.stdout def test_main_missing_option_error_includes_usage_and_next_step(capsys): @@ -197,8 +222,8 @@ def test_main_missing_option_error_includes_usage_and_next_step(capsys): assert exit_code == 2 assert '"summary": "缺少参数"' in stdout assert '"code": "MISSING_PARAMETER"' in stdout - assert '"usage": "tjwater simulation run --start-time --duration "' in stdout - assert '"tjwater help simulation run"' in stdout + assert '"usage": "tjwater-cli simulation run --start-time --duration "' in stdout + assert '"tjwater-cli help simulation run"' in stdout def test_main_bare_analysis_returns_typer_help_without_json_error(capsys): @@ -206,7 +231,7 @@ def test_main_bare_analysis_returns_typer_help_without_json_error(capsys): stdout = capsys.readouterr().out assert exit_code == 0 - assert "Usage: tjwater analysis" in stdout + assert "Usage: tjwater-cli analysis" in stdout assert "分析计算与诊断相关命令。" in stdout assert '"ok": false' not in stdout @@ -268,8 +293,8 @@ def test_simulation_run_translates_rfc3339(monkeypatch, tmp_path: Path): "start_time": "03:04:05+08:00", "duration": 30, } - assert '"tjwater --auth-context auth.json data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00"' in result.stdout - assert '"tjwater --auth-context auth.json data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00"' in result.stdout + assert '"tjwater-cli --auth-context auth.json data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00"' in result.stdout + assert '"tjwater-cli --auth-context auth.json data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00"' in result.stdout def test_project_export_inp_downloads_file(monkeypatch, tmp_path: Path): diff --git a/cli/tjwater b/cli/tjwater deleted file mode 100755 index e2428c8..0000000 --- a/cli/tjwater +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -if [ -x "$ROOT/.venv/bin/python" ]; then - PYTHON_BIN="$ROOT/.venv/bin/python" -elif [ -n "${PYTHON:-}" ]; then - PYTHON_BIN="$PYTHON" -elif command -v python >/dev/null 2>&1; then - PYTHON_BIN="python" -else - PYTHON_BIN="python3" -fi - -export PYTHONPATH="$ROOT${PYTHONPATH:+:$PYTHONPATH}" -exec "$PYTHON_BIN" -m tjwater_agent_cli "$@" diff --git a/cli/tjwater.spec b/cli/tjwater.spec new file mode 100644 index 0000000..6a034b9 --- /dev/null +++ b/cli/tjwater.spec @@ -0,0 +1,45 @@ +# -*- mode: python ; coding: utf-8 -*- + +from PyInstaller.utils.hooks import collect_data_files + + +datas = collect_data_files("certifi") + + +a = Analysis( + ["entrypoint.py"], + pathex=["."], + binaries=[], + datas=datas, + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name="tjwater-cli", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=True, +) + +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name="tjwater-cli", +) diff --git a/cli/tjwater_cli/apps.py b/cli/tjwater_cli/apps.py index 13de6b5..6108f19 100644 --- a/cli/tjwater_cli/apps.py +++ b/cli/tjwater_cli/apps.py @@ -2,31 +2,33 @@ from __future__ import annotations import typer -app = typer.Typer(help="TJWater agent CLI", add_completion=False, no_args_is_help=True) -project_app = typer.Typer(no_args_is_help=True) -network_app = typer.Typer(no_args_is_help=True) -component_app = typer.Typer(no_args_is_help=True) -component_option_app = typer.Typer(no_args_is_help=True) -simulation_app = typer.Typer(no_args_is_help=True) -analysis_app = typer.Typer(no_args_is_help=True) -analysis_leakage_app = typer.Typer(no_args_is_help=True) -analysis_leakage_schemes_app = typer.Typer(no_args_is_help=True) -analysis_burst_detection_app = typer.Typer(no_args_is_help=True) -analysis_burst_detection_schemes_app = typer.Typer(no_args_is_help=True) -analysis_burst_location_app = typer.Typer(no_args_is_help=True) -analysis_burst_location_schemes_app = typer.Typer(no_args_is_help=True) -analysis_risk_app = typer.Typer(no_args_is_help=True) -analysis_sensor_placement_app = typer.Typer(no_args_is_help=True) -data_app = typer.Typer(no_args_is_help=True) -data_timeseries_app = typer.Typer(no_args_is_help=True) -data_timeseries_realtime_app = typer.Typer(no_args_is_help=True) -data_timeseries_scheme_app = typer.Typer(no_args_is_help=True) -data_timeseries_scada_app = typer.Typer(no_args_is_help=True) -data_timeseries_composite_app = typer.Typer(no_args_is_help=True) -data_scada_app = typer.Typer(no_args_is_help=True) -data_scheme_app = typer.Typer(no_args_is_help=True) -data_extension_app = typer.Typer(no_args_is_help=True) -data_misc_app = typer.Typer(no_args_is_help=True) +from .formatters import TJWaterGroup + +app = typer.Typer(help="TJWater agent CLI", add_completion=False, no_args_is_help=True, cls=TJWaterGroup) +project_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +network_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +component_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +component_option_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +simulation_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +analysis_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +analysis_leakage_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +analysis_leakage_schemes_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +analysis_burst_detection_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +analysis_burst_detection_schemes_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +analysis_burst_location_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +analysis_burst_location_schemes_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +analysis_risk_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +analysis_sensor_placement_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +data_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +data_timeseries_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +data_timeseries_realtime_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +data_timeseries_scheme_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +data_timeseries_scada_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +data_timeseries_composite_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +data_scada_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +data_scheme_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +data_extension_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) +data_misc_app = typer.Typer(no_args_is_help=True, cls=TJWaterGroup) app.add_typer(project_app, name="project") app.add_typer(network_app, name="network") diff --git a/cli/tjwater_cli/commands_analysis.py b/cli/tjwater_cli/commands_analysis.py index 4a3121a..3708caa 100644 --- a/cli/tjwater_cli/commands_analysis.py +++ b/cli/tjwater_cli/commands_analysis.py @@ -58,8 +58,8 @@ def simulation_run( require_auth=True, require_network_ctx=True, next_commands=[ - f"tjwater --auth-context auth.json data timeseries realtime links --start-time {parsed.isoformat()} --end-time {end_time}", - f"tjwater --auth-context auth.json data timeseries realtime nodes --start-time {parsed.isoformat()} --end-time {end_time}", + f"tjwater-cli --auth-context auth.json data timeseries realtime links --start-time {parsed.isoformat()} --end-time {end_time}", + f"tjwater-cli --auth-context auth.json data timeseries realtime nodes --start-time {parsed.isoformat()} --end-time {end_time}", ], ) @@ -92,8 +92,8 @@ def analysis_burst( require_auth=True, require_network_ctx=True, next_commands=[ - f"tjwater --auth-context auth.json data scheme get --name {scheme_name}", - "tjwater --auth-context auth.json data scheme list", + f"tjwater-cli --auth-context auth.json data scheme get --name {scheme_name}", + "tjwater-cli --auth-context auth.json data scheme list", ], ) diff --git a/cli/tjwater_cli/commands_project.py b/cli/tjwater_cli/commands_project.py index 4345967..4470e91 100644 --- a/cli/tjwater_cli/commands_project.py +++ b/cli/tjwater_cli/commands_project.py @@ -98,7 +98,7 @@ def project_export_inp( data={"output": str(output), "bytes": len(content)}, ctx=runtime, duration_ms=duration_dump + duration_download, - next_commands=["tjwater project info"], + next_commands=["tjwater-cli project info"], ) diff --git a/cli/tjwater_cli/core.py b/cli/tjwater_cli/core.py index 1881042..cf1187e 100644 --- a/cli/tjwater_cli/core.py +++ b/cli/tjwater_cli/core.py @@ -13,6 +13,7 @@ import requests import typer SCHEMA_VERSION = "tjwater-cli/v1" +CLI_NAME = "tjwater-cli" DEFAULT_TIMEOUT = 60 DEFAULT_SERVER = "http://192.168.1.114:8000" @@ -180,7 +181,7 @@ def require_access_token(ctx: RuntimeContext) -> str: code="UNAUTHENTICATED", message="missing access token for agent context", exit_code=3, - next_commands=["tjwater --auth-context /path/to/auth-context.json"], + next_commands=["tjwater-cli --auth-context /path/to/auth-context.json"], ) diff --git a/cli/tjwater_cli/formatters.py b/cli/tjwater_cli/formatters.py new file mode 100644 index 0000000..bf94950 --- /dev/null +++ b/cli/tjwater_cli/formatters.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import click +import typer.core + + +class TJWaterGroup(typer.core.TyperGroup): + def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: + super().format_help(ctx, formatter) + from .helping import build_group_help_appendix + + appendix = build_group_help_appendix(ctx) + if appendix: + formatter.write_paragraph() + formatter.write_text(appendix) diff --git a/cli/tjwater_cli/helping.py b/cli/tjwater_cli/helping.py index 1461fd4..5f01547 100644 --- a/cli/tjwater_cli/helping.py +++ b/cli/tjwater_cli/helping.py @@ -37,7 +37,7 @@ def context_command_path(click_ctx: click.Context | None) -> tuple[str, ...]: def _build_click_context(path: tuple[str, ...]) -> click.Context | None: root = _click_root_command() - ctx: click.Context = click.Context(root, info_name="tjwater") + ctx: click.Context = click.Context(root, info_name="tjwater-cli") command: click.Command = root for token in path: if not isinstance(command, click.Group): @@ -54,7 +54,7 @@ def build_usage(path: tuple[str, ...]) -> str | None: ctx = _build_click_context(path) if ctx is None: return None - parts = ["tjwater", *path] + parts = ["tjwater-cli", *path] for parameter in ctx.command.params: if not isinstance(parameter, click.Option): continue @@ -165,7 +165,7 @@ def _build_example(path: tuple[str, ...], *, existing_examples: list[str] | None has_required_options = all(f"--{option_name}" in example for option_name in required_option_names) if has_auth and has_required_options: return example - parts = ["tjwater", "--auth-context", "auth.json", *path] + parts = ["tjwater-cli", "--auth-context", "auth.json", *path] if ctx is None: return " ".join(parts) for parameter in ctx.command.params: @@ -198,8 +198,8 @@ def _enrich_index_payload(payload: dict[str, Any]) -> dict[str, Any]: path = tuple(command_item["command"].split()) doc = get_command_doc(path) if doc is None and has_subcommands(path): - command_item["usage"] = f"tjwater {' '.join(path)} help" - command_item["example"] = f"tjwater {' '.join(path)} help" + command_item["usage"] = f"tjwater-cli {' '.join(path)} help" + command_item["example"] = f"tjwater-cli {' '.join(path)} help" else: existing_examples = [] if doc is None else list(doc.get("examples", [])) command_item["usage"] = build_usage(path) or command_item.get("usage") @@ -220,11 +220,8 @@ def resolve_help_payload(path: tuple[str, ...]) -> tuple[dict[str, Any] | None, return None, False -def emit_help_payload(payload: dict[str, Any], *, json_output: bool, is_index: bool) -> None: - if json_output: - typer.echo(json.dumps(payload, ensure_ascii=False)) - else: - typer.echo(render_help_text(payload, is_index=is_index)) +def emit_help_payload(payload: dict[str, Any]) -> None: + typer.echo(json.dumps(payload, ensure_ascii=False)) def merge_next_commands(*groups: list[str] | None) -> list[str]: @@ -259,12 +256,12 @@ def build_error_guidance(click_ctx: click.Context | None) -> tuple[Any, list[str return ( { "command_group": " ".join(group_path), - "usage": f"tjwater {' '.join(group_path)} help", - "examples": [f"tjwater {' '.join(group_path)} help", f"tjwater help {' '.join(group_path)}"], + "usage": f"tjwater-cli {' '.join(group_path)} help", + "examples": [f"tjwater-cli {' '.join(group_path)} help", f"tjwater-cli help {' '.join(group_path)}"], }, merge_next_commands( - [f"tjwater {' '.join(group_path)} help", f"tjwater help {' '.join(group_path)}"], - ["tjwater help"], + [f"tjwater-cli {' '.join(group_path)} help", f"tjwater-cli help {' '.join(group_path)}"], + ["tjwater-cli help"], ), ) payload, is_index = resolve_help_payload(command_path) @@ -275,21 +272,21 @@ def build_error_guidance(click_ctx: click.Context | None) -> tuple[Any, list[str "usage": payload.get("usage") or usage, "examples": payload.get("examples", []), }, - merge_next_commands([f"tjwater help {' '.join(command_path)}"], ["tjwater help"]), + merge_next_commands([f"tjwater-cli help {' '.join(command_path)}"], ["tjwater-cli help"]), ) if payload is not None and is_index: return ( { "command_group": " ".join(command_path), - "usage": f"tjwater {' '.join(command_path)} help", - "examples": [f"tjwater {' '.join(command_path)} help", f"tjwater help {' '.join(command_path)}"], + "usage": f"tjwater-cli {' '.join(command_path)} help", + "examples": [f"tjwater-cli {' '.join(command_path)} help", f"tjwater-cli help {' '.join(command_path)}"], }, merge_next_commands( - [f"tjwater {' '.join(command_path)} help", f"tjwater help {' '.join(command_path)}"], - ["tjwater help"], + [f"tjwater-cli {' '.join(command_path)} help", f"tjwater-cli help {' '.join(command_path)}"], + ["tjwater-cli help"], ), ) - return ({"usage": usage} if usage else None, ["tjwater help"]) + return ({"usage": usage} if usage else None, ["tjwater-cli help"]) def classify_click_error(exc: click.ClickException) -> tuple[str, str]: @@ -305,55 +302,61 @@ def classify_click_error(exc: click.ClickException) -> tuple[str, str]: return "CLI 参数错误", "USAGE_ERROR" -def render_help_text(payload: dict[str, Any], *, is_index: bool) -> str: - lines: list[str] = [str(payload.get("summary", ""))] - if is_index: - is_top_level = payload.get("menu_level") == 1 - lines.append("") - lines.append("Commands:") - for command in payload.get("commands", []): - lines.append(f" {command['command']}: {command['summary']}") - if not is_top_level and command.get("usage"): - lines.append(f" usage: {command['usage']}") - if not is_top_level and command.get("example"): - lines.append(f" example: {command['example']}") - lines.append("") - if is_top_level: - lines.append("Use `tjwater help` to see subcommands.") - else: - lines.append("Use `tjwater help --json` for structured output.") - return "\n".join(lines) +def _build_root_help_epilog() -> str: + return "\n".join( + [ + "\b", + "Examples:", + " tjwater-cli help", + " tjwater-cli help simulation run", + " tjwater-cli simulation run --help", + ] + ) - lines.append("") - lines.append(f"Command: {payload['command']}") - lines.append(f"Description: {payload['description']}") - if payload.get("usage"): - lines.append(f"Usage: {payload['usage']}") - - options = payload.get("options", []) - if options: - lines.append("") - lines.append("Options:") - for option in options: - suffix = " (required)" if option.get("required") else "" - lines.append(f" --{option['name']}{suffix}: {option['description']}") +def _build_leaf_help_epilog(path: tuple[str, ...], payload: dict[str, Any]) -> str: + lines = ["\b"] + description = payload.get("description") + usage = payload.get("usage") examples = payload.get("examples", []) + next_commands = payload.get("next_commands", []) + if description: + lines.extend([f"Description: {description}", ""]) + if usage: + lines.extend([f"Usage example: {usage}", ""]) if examples: - lines.append("") lines.append("Examples:") - for example in examples: - lines.append(f" {example}") - - lines.append("") - lines.append("Use `tjwater help --json` for structured output.") + lines.extend(f" {example}" for example in examples) + lines.append("") + if next_commands: + lines.append("Next steps:") + lines.extend(f" {command}" for command in next_commands) + lines.append("") + lines.extend(["Structured JSON:", f" tjwater-cli help {' '.join(path)}"]) return "\n".join(lines) +def _build_group_help_epilog(path: tuple[str, ...], payload: dict[str, Any]) -> str: + lines = ["\b", "Examples:", f" tjwater-cli help {' '.join(path)}"] + for command in payload.get("commands", [])[:2]: + example = command.get("example") + if example: + lines.append(f" {example}") + return "\n".join(lines) + + +def build_group_help_appendix(click_ctx: click.Context | None) -> str | None: + path = context_command_path(click_ctx) + if not path: + return _build_root_help_epilog() + payload, is_index = resolve_help_payload(path) + if payload is None or not is_index: + return None + return _build_group_help_epilog(path, payload) + + def make_group_help_handler(path_prefix: tuple[str, ...]): - def group_help( - json_output: Annotated[bool, typer.Option("--json", help="输出 JSON")] = False, - ) -> None: + def group_help() -> None: payload, is_index = resolve_help_payload(path_prefix) if payload is None: raise CLIError( @@ -361,9 +364,9 @@ def make_group_help_handler(path_prefix: tuple[str, ...]): code="COMMAND_NOT_FOUND", message=f"unknown command path: {' '.join(path_prefix)}", exit_code=2, - next_commands=["tjwater help"], + next_commands=["tjwater-cli help"], ) - emit_help_payload(payload, json_output=json_output, is_index=is_index) + emit_help_payload(payload) group_help.__name__ = f"{'_'.join(path_prefix)}_help" return group_help @@ -375,19 +378,30 @@ def register_group_help_commands() -> None: def apply_typer_help_metadata() -> None: - app.help = "TJWater agent CLI" + app.help = "\n".join( + [ + "TJWater agent CLI", + "", + "Examples:", + " tjwater-cli help", + " tjwater-cli help simulation run", + " tjwater-cli simulation run --help", + ] + ) app.short_help = "TJWater agent CLI" for group_app, path_prefix in GROUP_HELP_APPS: for command_info in group_app.registered_commands: command_path = (*path_prefix, command_info.name) if command_info.name == "help": - command_info.help = f"显示 {' '.join(path_prefix)} 的帮助信息。" + command_info.help = f"输出 {' '.join(path_prefix)} 的 JSON 帮助信息。" command_info.short_help = command_info.help + command_info.epilog = "\n".join(["\b", "Example:", f" tjwater-cli help {' '.join(path_prefix)}"]) command_info.hidden = False continue payload = get_command_doc(command_path) command_info.help = None if payload is None else str(payload.get("summary", "")) command_info.short_help = command_info.help + command_info.epilog = None if payload is None else _build_leaf_help_epilog(command_path, payload) command_info.hidden = is_hidden_path(command_path) for group_info in group_app.registered_groups: group_path = (*path_prefix, group_info.name) diff --git a/cli/tjwater_cli/main.py b/cli/tjwater_cli/main.py index ebc3d14..9cddbdb 100644 --- a/cli/tjwater_cli/main.py +++ b/cli/tjwater_cli/main.py @@ -44,11 +44,8 @@ def root_callback( register_group_help_commands() -@app.command("help", context_settings={"allow_extra_args": True, "ignore_unknown_options": True}) -def help_command( - ctx: typer.Context, - json_output: Annotated[bool, typer.Option("--json", help="输出 JSON")] = False, -) -> None: +@app.command("help", context_settings={"allow_extra_args": True}) +def help_command(ctx: typer.Context) -> None: command_path = list(ctx.args) payload, is_index = resolve_help_payload(tuple(command_path)) if payload is None: @@ -61,13 +58,13 @@ def help_command( server=None, request_id=None, data={ - "usage": "tjwater help ", - "examples": ["tjwater help simulation run", "tjwater simulation help"], + "usage": "tjwater-cli help ", + "examples": ["tjwater-cli help simulation run", "tjwater-cli simulation help"], }, - next_commands=["tjwater help", "tjwater help simulation"], + next_commands=["tjwater-cli help", "tjwater-cli help simulation"], ) raise typer.Exit(code=2) - emit_help_payload(payload, json_output=json_output, is_index=is_index) + emit_help_payload(payload) # Must run at import time because tests call runner.invoke(app, ...) directly. @@ -76,7 +73,7 @@ apply_typer_help_metadata() def main(argv: list[str] | None = None) -> int: try: - app(args=argv if argv is not None else sys.argv[1:], prog_name="tjwater", standalone_mode=False) + app(args=argv if argv is not None else sys.argv[1:], prog_name="tjwater-cli", standalone_mode=False) return 0 except CLIError as exc: click_ctx = click.get_current_context(silent=True) diff --git a/cli/tjwater_cli/registry.py b/cli/tjwater_cli/registry.py index d83d782..8b964f5 100644 --- a/cli/tjwater_cli/registry.py +++ b/cli/tjwater_cli/registry.py @@ -39,15 +39,15 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { path=("project", "list"), summary="列出当前用户可访问项目", description="调用 /meta/projects 返回项目列表。", - examples=("tjwater --auth-context auth.json project list",), - next_commands=("tjwater --auth-context auth.json project info",), + examples=("tjwater-cli --auth-context auth.json project list",), + next_commands=("tjwater-cli --auth-context auth.json project info",), output="项目摘要列表", ), ("project", "info"): CommandDoc( path=("project", "info"), summary="读取当前项目元数据", description="调用 /meta/project 返回当前 project 详情。", - examples=("tjwater --auth-context auth.json project info",), + examples=("tjwater-cli --auth-context auth.json project info",), output="项目元数据", ), ("project", "db-health"): CommandDoc( @@ -109,8 +109,8 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { CommandOptionDoc("duration", "持续分钟数", required=True), ), next_commands=( - "tjwater --auth-context auth.json data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00", - "tjwater --auth-context auth.json data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00", + "tjwater-cli --auth-context auth.json data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00", + "tjwater-cli --auth-context auth.json data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00", ), output="模拟触发结果;实时数据需通过 data timeseries 命令按时间段查询", ), @@ -125,11 +125,11 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { CommandOptionDoc("scheme", "方案名称"), ), examples=( - "tjwater --auth-context auth.json analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 30 --burst-file ./burst.json --scheme burst_case_01", + "tjwater-cli --auth-context auth.json analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 30 --burst-file ./burst.json --scheme burst_case_01", ), next_commands=( - "tjwater --auth-context auth.json data scheme get --name burst_case_01", - "tjwater --auth-context auth.json data scheme list", + "tjwater-cli --auth-context auth.json data scheme get --name burst_case_01", + "tjwater-cli --auth-context auth.json data scheme list", ), output="分析执行结果;方案详情需通过 data scheme 命令单独查询", ), @@ -138,7 +138,7 @@ COMMAND_DOCS: dict[tuple[str, ...], CommandDoc] = { summary="执行阀门关闭或隔离分析", description="mode=close 使用 valve 列表;mode=isolation 需要 accident element,可选 disabled-valve。", examples=( - "tjwater --auth-context auth.json analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --duration 900", + "tjwater-cli --auth-context auth.json analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --duration 900", ), ), ("analysis", "flushing"): CommandDoc( @@ -422,13 +422,13 @@ def list_subcommands(path_prefix: tuple[str, ...], summary: str | None = None) - seen.add(subcommand) current_path = (*path_prefix, subcommand) is_group = has_subcommands(current_path) - usage = f"tjwater {' '.join(current_path)} help" if is_group else (doc.examples[0] if doc.examples else _build_usage(doc)) + usage = f"tjwater-cli {' '.join(current_path)} help" if is_group else (doc.examples[0] if doc.examples else _build_usage(doc)) commands.append( { "command": " ".join(current_path), "summary": get_group_summary(current_path) if is_group else doc.summary, "usage": usage, - "example": f"tjwater {' '.join(current_path)} help" if is_group else _build_examples(doc)[0], + "example": f"tjwater-cli {' '.join(current_path)} help" if is_group else _build_examples(doc)[0], } ) return { @@ -440,7 +440,7 @@ def list_subcommands(path_prefix: tuple[str, ...], summary: str | None = None) - def _build_usage(doc: CommandDoc) -> str: - parts = ["tjwater", *doc.path] + parts = ["tjwater-cli", *doc.path] for option in doc.options: placeholder = option.name.upper().replace("-", "_") if option.required: diff --git a/cli/tjwater_cli_endpoint_scope.md b/cli/tjwater_cli_endpoint_scope.md index 0a3d837..9b87f47 100644 --- a/cli/tjwater_cli_endpoint_scope.md +++ b/cli/tjwater_cli_endpoint_scope.md @@ -7,13 +7,13 @@ 首批 CLI 采用 **少量顶层入口 + 业务域二级分组 + 只读/分析优先** 的设计。 ```text -tjwater project -tjwater network -tjwater component -tjwater simulation -tjwater analysis -tjwater data -tjwater help +tjwater-cli project +tjwater-cli network +tjwater-cli component +tjwater-cli simulation +tjwater-cli analysis +tjwater-cli data +tjwater-cli help ``` 首批默认不暴露: @@ -45,12 +45,12 @@ tjwater help | `simulation` | `run` | 模拟运行 | | `analysis` | `burst`、`valve`、`flushing`、`age`、`contaminant`、`sensor-placement`、`leakage`、`burst-detection`、`burst-location`、`risk` | 任务级分析 | | `data` | `timeseries`、`scada`、`scheme`、`extension`、`misc` | 数据查询 | -| `help` | `--json`、`COMMAND --json` | Agent 能力发现和命令说明 | +| `help` | `COMMAND` | Agent 能力发现和命令说明 | 命令深度建议: -- 常规命令不超过 3 层:`tjwater component option get` -- 时序数据允许 4 层:`tjwater data timeseries realtime links` +- 常规命令不超过 3 层:`tjwater-cli component option get` +- 时序数据允许 4 层:`tjwater-cli data timeseries realtime links` - `risk` 归入 `analysis risk` - `scada`、`scheme`、`extension` 归入 `data` @@ -88,7 +88,7 @@ tjwater help - 用户输入的业务时间默认按 **UTC+8** 理解;若命令直接接收完整时间戳,应使用 ISO 8601 / RFC 3339 并显式包含时区。CLI 可直接传 `+08:00`,也可传其他时区的绝对时间,由服务端统一归一化。 - 范围参数优先拆成 `--start-time` / `--end-time`,不再引入模糊的 `--time-range ...` 写法。 - 复合输入优先使用可重复显式选项或 `--input FILE`,避免把多个语义字段压进 `ID:SIZE`、`NODE:VALUE`、`VALVE:OPENING` 这类 shell 内联 DSL。 -- 若必须传大批量复合参数,优先支持 `--input FILE`,文件格式由 `help --json` 给出 schema。 +- 若必须传大批量复合参数,优先支持 `--input FILE`,文件格式由 `help` 给出 schema。 ## 首批 CLI 范围 @@ -138,11 +138,11 @@ Agent 调用认证上下文: | 命令 | 覆盖接口 | 说明 | |---|---|---| -| `tjwater project list` | `GET /meta/projects` | 项目列表 | -| `tjwater project info` | `GET /meta/project` | 当前 project 信息 | -| `tjwater project db-health` | `GET /meta/db/health` | 当前 project 数据库健康 | -| `tjwater project export-inp --output PATH` | `GET /exportinp/`、`GET /dumpinp/`、`GET /downloadinp/` | 导出当前 project 的 INP 到本地文件 | -| `tjwater project data --kind scada-info\|scheme-list\|burst-locate-result` | `GET /scada-info`、`GET /scheme-list`、`GET /burst-locate-result*` | 当前 project 的业务数据 | +| `tjwater-cli project list` | `GET /meta/projects` | 项目列表 | +| `tjwater-cli project info` | `GET /meta/project` | 当前 project 信息 | +| `tjwater-cli project db-health` | `GET /meta/db/health` | 当前 project 数据库健康 | +| `tjwater-cli project export-inp --output PATH` | `GET /exportinp/`、`GET /dumpinp/`、`GET /downloadinp/` | 导出当前 project 的 INP 到本地文件 | +| `tjwater-cli project data --kind scada-info\|scheme-list\|burst-locate-result` | `GET /scada-info`、`GET /scheme-list`、`GET /burst-locate-result*` | 当前 project 的业务数据 | 暂不暴露: @@ -181,8 +181,8 @@ app/api/v1/endpoints/network/*.py | 命令 | 覆盖接口 | 说明 | |---|---|---| -| `tjwater network get-node-properties --node NODE` | `GET /getnodeproperties/` | 读取当前 project 中指定节点的属性 | -| `tjwater network get-link-properties --link LINK` | `GET /getlinkproperties/` | 读取当前 project 中指定管线的属性 | +| `tjwater-cli network get-node-properties --node NODE` | `GET /getnodeproperties/` | 读取当前 project 中指定节点的属性 | +| `tjwater-cli network get-link-properties --link LINK` | `GET /getlinkproperties/` | 读取当前 project 中指定管线的属性 | 暂不暴露: @@ -209,14 +209,14 @@ app/api/v1/endpoints/components/*.py | 命令 | 覆盖接口 | 说明 | |---|---|---| -| `tjwater component option schema --kind time` | `GET /gettimeschema` | 时间选项 schema | -| `tjwater component option get --kind time` | `GET /gettimeproperties/` | 时间选项属性 | -| `tjwater component option schema --kind energy` | `GET /getenergyschema/` | 全局能耗选项 schema | -| `tjwater component option get --kind energy` | `GET /getenergyproperties/` | 全局能耗选项属性 | -| `tjwater component option schema --kind pump-energy` | `GET /getpumpenergyschema/` | 泵能耗选项 schema | -| `tjwater component option get --kind pump-energy --pump PUMP` | `GET /getpumpenergyproperties//` | 指定泵的能耗选项属性 | -| `tjwater component option schema --kind network` | `GET /getoptionschema/` | 管网选项 schema | -| `tjwater component option get --kind network` | `GET /getoptionproperties/` | 管网选项属性 | +| `tjwater-cli component option schema --kind time` | `GET /gettimeschema` | 时间选项 schema | +| `tjwater-cli component option get --kind time` | `GET /gettimeproperties/` | 时间选项属性 | +| `tjwater-cli component option schema --kind energy` | `GET /getenergyschema/` | 全局能耗选项 schema | +| `tjwater-cli component option get --kind energy` | `GET /getenergyproperties/` | 全局能耗选项属性 | +| `tjwater-cli component option schema --kind pump-energy` | `GET /getpumpenergyschema/` | 泵能耗选项 schema | +| `tjwater-cli component option get --kind pump-energy --pump PUMP` | `GET /getpumpenergyproperties//` | 指定泵的能耗选项属性 | +| `tjwater-cli component option schema --kind network` | `GET /getoptionschema/` | 管网选项 schema | +| `tjwater-cli component option get --kind network` | `GET /getoptionproperties/` | 管网选项属性 | 暂不暴露: @@ -273,22 +273,22 @@ app/api/v1/endpoints/risk.py | 命令 | 覆盖接口 | 说明 | |---|---|---| -| `tjwater simulation run --start-time RFC3339 --duration MINUTES` | `POST /runsimulationmanuallybydate/` | 按指定绝对开始时间触发当前 project 的实时模拟;`start-time` 必须显式带时区,结果写入服务端时序库,后续通过 `tjwater data timeseries realtime *` 查询 | -| `tjwater analysis burst --start-time TIME --duration SEC --scheme SCHEME --burst-file FILE` | `GET /burst_analysis/` | 爆管分析;`FILE` 提供爆管点与流量列表,CLI 负责转换为 `burst_ID[]` / `burst_size[]` | -| `tjwater analysis valve --mode close\|isolation --start-time TIME --valve VALVE` | `GET /valve_close_analysis/`、`GET /valve_isolation_analysis/` | 阀门分析,`--valve` 可重复 | -| `tjwater analysis flushing --start-time TIME --valve-setting-file FILE --drainage-node NODE --flow FLOW [--duration SEC] [--scheme SCHEME]` | `GET /flushing_analysis/` | 冲洗分析;`FILE` 提供阀门与开度列表,CLI 负责转换为 `valves[]` / `valves_k[]` | -| `tjwater analysis age --start-time TIME --duration SEC` | `GET /age_analysis/` | 水龄分析 | -| `tjwater analysis contaminant --start-time TIME --duration SEC --source-node NODE --concentration VALUE [--pattern PATTERN] [--scheme SCHEME]` | `GET /contaminant_simulation/` | 污染物模拟 | -| `tjwater analysis sensor-placement kmeans --count N` | `GET /pressuresensorplacementkmeans/` | 基于 kmeans 的传感器放置分析;不包含创建方案 | -| `tjwater analysis leakage identify --scheme SCHEME --start-time TIME --end-time TIME` | `POST /leakage/identify/` | 漏损识别 | -| `tjwater analysis leakage schemes list\|get` | `GET /leakage/schemes/`、`GET /leakage/schemes/{scheme_name}` | 漏损方案查询 | -| `tjwater analysis burst-detection detect --scheme SCHEME --start-time TIME --end-time TIME` | `POST /burst-detection/detect/` | 爆管检测 | -| `tjwater analysis burst-detection schemes list\|get` | `GET /burst-detection/schemes/`、`GET /burst-detection/schemes/{scheme_name}` | 爆管检测方案查询 | -| `tjwater analysis burst-location locate --scheme SCHEME --start-time TIME --end-time TIME` | `POST /burst-location/locate/` | 爆管定位 | -| `tjwater analysis burst-location schemes list\|get` | `GET /burst-location/schemes/`、`GET /burst-location/schemes/{scheme_name}` | 爆管定位方案查询 | -| `tjwater analysis risk pipe-now --pipe PIPE` | `GET /getpiperiskprobabilitynow/` | 单条管道当前风险 | -| `tjwater analysis risk pipe-history --pipe PIPE` | `GET /getpiperiskprobability/` | 单条管道历史风险 | -| `tjwater analysis risk network` | `GET /getnetworkpiperiskprobabilitynow/`、`GET /getpiperiskprobabilitygeometries/` | 当前 project 全网风险 | +| `tjwater-cli simulation run --start-time RFC3339 --duration MINUTES` | `POST /runsimulationmanuallybydate/` | 按指定绝对开始时间触发当前 project 的实时模拟;`start-time` 必须显式带时区,结果写入服务端时序库,后续通过 `tjwater-cli data timeseries realtime *` 查询 | +| `tjwater-cli analysis burst --start-time TIME --duration SEC --scheme SCHEME --burst-file FILE` | `GET /burst_analysis/` | 爆管分析;`FILE` 提供爆管点与流量列表,CLI 负责转换为 `burst_ID[]` / `burst_size[]` | +| `tjwater-cli analysis valve --mode close\|isolation --start-time TIME --valve VALVE` | `GET /valve_close_analysis/`、`GET /valve_isolation_analysis/` | 阀门分析,`--valve` 可重复 | +| `tjwater-cli analysis flushing --start-time TIME --valve-setting-file FILE --drainage-node NODE --flow FLOW [--duration SEC] [--scheme SCHEME]` | `GET /flushing_analysis/` | 冲洗分析;`FILE` 提供阀门与开度列表,CLI 负责转换为 `valves[]` / `valves_k[]` | +| `tjwater-cli analysis age --start-time TIME --duration SEC` | `GET /age_analysis/` | 水龄分析 | +| `tjwater-cli analysis contaminant --start-time TIME --duration SEC --source-node NODE --concentration VALUE [--pattern PATTERN] [--scheme SCHEME]` | `GET /contaminant_simulation/` | 污染物模拟 | +| `tjwater-cli analysis sensor-placement kmeans --count N` | `GET /pressuresensorplacementkmeans/` | 基于 kmeans 的传感器放置分析;不包含创建方案 | +| `tjwater-cli analysis leakage identify --scheme SCHEME --start-time TIME --end-time TIME` | `POST /leakage/identify/` | 漏损识别 | +| `tjwater-cli analysis leakage schemes list\|get` | `GET /leakage/schemes/`、`GET /leakage/schemes/{scheme_name}` | 漏损方案查询 | +| `tjwater-cli analysis burst-detection detect --scheme SCHEME --start-time TIME --end-time TIME` | `POST /burst-detection/detect/` | 爆管检测 | +| `tjwater-cli analysis burst-detection schemes list\|get` | `GET /burst-detection/schemes/`、`GET /burst-detection/schemes/{scheme_name}` | 爆管检测方案查询 | +| `tjwater-cli analysis burst-location locate --scheme SCHEME --start-time TIME --end-time TIME` | `POST /burst-location/locate/` | 爆管定位 | +| `tjwater-cli analysis burst-location schemes list\|get` | `GET /burst-location/schemes/`、`GET /burst-location/schemes/{scheme_name}` | 爆管定位方案查询 | +| `tjwater-cli analysis risk pipe-now --pipe PIPE` | `GET /getpiperiskprobabilitynow/` | 单条管道当前风险 | +| `tjwater-cli analysis risk pipe-history --pipe PIPE` | `GET /getpiperiskprobability/` | 单条管道历史风险 | +| `tjwater-cli analysis risk network` | `GET /getnetworkpiperiskprobabilitynow/`、`GET /getpiperiskprobabilitygeometries/` | 当前 project 全网风险 | 暂缓或暂不暴露: @@ -310,7 +310,7 @@ POST /daily_scheduling_analysis/ - `simulation run` 不直接回传全量模拟结果;它负责触发服务端模拟,并返回执行摘要、时间窗口和后续查询提示。 - 当前 `runsimulationmanuallybydate` 接口会从 `start_time` 指定的绝对时间开始,按 15 分钟步长运行直到达到 `duration`,结果持久化到服务端时序存储。 - `start_time` 必须显式带时区;CLI 推荐直接传 **UTC+8** 时间,服务端统一转换后执行和落库。CLI 文档与帮助信息需要把这条规则写成显式契约,不能把数据库存储时间直接暴露成用户输入语义。 -- 模拟结果读取统一走 `tjwater data timeseries realtime *`,而不是再单独设计 `simulation output`。 +- 模拟结果读取统一走 `tjwater-cli data timeseries realtime *`,而不是再单独设计 `simulation output`。 - `analysis` 相关命令首批也按同步请求处理;若后续服务端真的引入任务队列,再单独设计 `job` 类基础设施能力。 ### Data @@ -328,22 +328,22 @@ app/api/v1/endpoints/project_data.py | 命令 | 覆盖接口 | 说明 | |---|---|---| -| `tjwater data timeseries realtime links --start-time TIME --end-time TIME` | `GET /realtime/links` | 查询指定时间范围内的实时/模拟管道数据 | -| `tjwater data timeseries realtime nodes --start-time TIME --end-time TIME` | `GET /realtime/nodes` | 查询指定时间范围内的实时/模拟节点数据 | -| `tjwater data timeseries realtime simulation-by-id-time --id ID --type pipe\|junction --time TIME` | `GET /realtime/query/by-id-time` | 查询指定元素在指定时间点的模拟结果 | -| `tjwater data timeseries realtime simulation-by-time-property --type pipe\|junction --time TIME --property PROPERTY` | `GET /realtime/query/by-time-property` | 查询指定时间点某类元素某属性的聚合模拟结果 | -| `tjwater data timeseries scheme links --scheme SCHEME --start-time TIME --end-time TIME` | `GET /scheme/links`、`GET /scheme/links/{link_id}/field` | 方案管道数据 | -| `tjwater data timeseries scheme node-field --node NODE --field FIELD` | `GET /scheme/nodes/{node_id}/field` | 方案节点字段 | -| `tjwater data timeseries scheme simulation --query by-id-time\|by-scheme-time-property --scheme SCHEME --id ID --time TIME --property PROPERTY` | `GET /scheme/query/*` | 方案模拟查询 | -| `tjwater data timeseries scada query --device-id ID --start-time TIME --end-time TIME [--device-id ID ...] [--field FIELD]` | `GET /scada/by-ids-time-range`、`GET /scada/by-ids-field-time-range` | SCADA 时序;CLI 把重复 `--device-id` 转换为后端逗号分隔参数 | -| `tjwater data timeseries composite --kind scada-simulation\|element-simulation\|element-scada --feature FEATURE --start-time TIME --end-time TIME` | `GET /composite/*` | 复合查询,`--feature` 可重复 | -| `tjwater data timeseries composite pipeline-health --pipe PIPE --start-time TIME --end-time TIME` | `GET /composite/pipeline-health-prediction` | 管道健康预测 | -| `tjwater data scada schema --kind device\|device-data\|element\|info` | `GET /getscada*schema/` | `SCADA` 元数据 `schema` | -| `tjwater data scada get\|list --kind device\|device-data\|element\|info` | `scada.py` 下 `GET` 查询接口 | `SCADA` 元数据 | -| `tjwater data scheme schema\|get\|list` | `schemes.py` 下 `GET` 接口 | 当前 project 方案查询 | -| `tjwater data extension keys\|get\|list` | `extension.py` 下 `GET` 查询接口 | 当前 project 扩展数据查询 | -| `tjwater data misc sensor-placements` | `GET /getallsensorplacements/` | 当前 project 传感器位置 | -| `tjwater data misc burst-location-results` | `GET /getallburstlocateresults/` | 当前 project 爆管定位结果 | +| `tjwater-cli data timeseries realtime links --start-time TIME --end-time TIME` | `GET /realtime/links` | 查询指定时间范围内的实时/模拟管道数据 | +| `tjwater-cli data timeseries realtime nodes --start-time TIME --end-time TIME` | `GET /realtime/nodes` | 查询指定时间范围内的实时/模拟节点数据 | +| `tjwater-cli data timeseries realtime simulation-by-id-time --id ID --type pipe\|junction --time TIME` | `GET /realtime/query/by-id-time` | 查询指定元素在指定时间点的模拟结果 | +| `tjwater-cli data timeseries realtime simulation-by-time-property --type pipe\|junction --time TIME --property PROPERTY` | `GET /realtime/query/by-time-property` | 查询指定时间点某类元素某属性的聚合模拟结果 | +| `tjwater-cli data timeseries scheme links --scheme SCHEME --start-time TIME --end-time TIME` | `GET /scheme/links`、`GET /scheme/links/{link_id}/field` | 方案管道数据 | +| `tjwater-cli data timeseries scheme node-field --node NODE --field FIELD` | `GET /scheme/nodes/{node_id}/field` | 方案节点字段 | +| `tjwater-cli data timeseries scheme simulation --query by-id-time\|by-scheme-time-property --scheme SCHEME --id ID --time TIME --property PROPERTY` | `GET /scheme/query/*` | 方案模拟查询 | +| `tjwater-cli data timeseries scada query --device-id ID --start-time TIME --end-time TIME [--device-id ID ...] [--field FIELD]` | `GET /scada/by-ids-time-range`、`GET /scada/by-ids-field-time-range` | SCADA 时序;CLI 把重复 `--device-id` 转换为后端逗号分隔参数 | +| `tjwater-cli data timeseries composite --kind scada-simulation\|element-simulation\|element-scada --feature FEATURE --start-time TIME --end-time TIME` | `GET /composite/*` | 复合查询,`--feature` 可重复 | +| `tjwater-cli data timeseries composite pipeline-health --pipe PIPE --start-time TIME --end-time TIME` | `GET /composite/pipeline-health-prediction` | 管道健康预测 | +| `tjwater-cli data scada schema --kind device\|device-data\|element\|info` | `GET /getscada*schema/` | `SCADA` 元数据 `schema` | +| `tjwater-cli data scada get\|list --kind device\|device-data\|element\|info` | `scada.py` 下 `GET` 查询接口 | `SCADA` 元数据 | +| `tjwater-cli data scheme schema\|get\|list` | `schemes.py` 下 `GET` 接口 | 当前 project 方案查询 | +| `tjwater-cli data extension keys\|get\|list` | `extension.py` 下 `GET` 查询接口 | 当前 project 扩展数据查询 | +| `tjwater-cli data misc sensor-placements` | `GET /getallsensorplacements/` | 当前 project 传感器位置 | +| `tjwater-cli data misc burst-location-results` | `GET /getallburstlocateresults/` | 当前 project 爆管定位结果 | - `realtime` 是首批 simulation 结果的主读取域;CLI 可以按任务语义组合 `links`、`nodes`、`simulation-by-id-time`、`simulation-by-time-property`,但底层数据源仍以 `realtime.py` 为准。 - `realtime`、`scheme`、`composite` 等时间查询命令面向用户时仍按 **UTC+8** 输入;CLI/服务端负责转换为后端使用的 **UTC0** 条件进行检索。若返回结果直接包含时间戳,必须显式带时区,避免把存储时间和展示时间混淆。 @@ -434,8 +434,8 @@ POST /users/{user_id}/deactivate | 命令 | 说明 | |---|---| -| `tjwater help --json` | 返回当前 CLI 能力清单,供 Agent 发现可用命令 | -| `tjwater help COMMAND --json` | 返回某个命令的参数、输出、示例和推荐后续命令 | +| `tjwater-cli help` | 返回当前 CLI 能力清单,供 Agent 发现可用命令 | +| `tjwater-cli help COMMAND` | 返回某个命令的参数、输出、示例和推荐后续命令 | 输出补充约束: @@ -491,7 +491,7 @@ POST /users/{user_id}/deactivate "data": null, "metadata": {}, "next_commands": [ - "tjwater --auth-context /path/to/auth-context.json" + "tjwater-cli --auth-context /path/to/auth-context.json" ] } ``` @@ -500,7 +500,7 @@ POST /users/{user_id}/deactivate - `metadata` 至少建议包含:`request_id`、`server`、`duration_ms`、`generated_at`。 - `next_commands` 是面向 agent 的推荐后续动作,不影响退出码和主结果语义。 -- 所有 `help --json` 输出也应带 `schema_version`,便于 agent 做能力协商。 +- 所有 `help` 输出也应带 `schema_version`,便于 agent 做能力协商。 ## 后续开放条件