build(cli): replace bundled binary cli

This commit is contained in:
2026-06-07 18:53:10 +08:00
parent 8a7964dc57
commit 4b03aa3a91
3 changed files with 658 additions and 3 deletions
-1
View File
@@ -7,6 +7,5 @@ __pycache__/
docker-compose.yml
data/
logs/
cli/
AGENT_HARNESS_REPORT.md
HARNESS_INTRODUCTION.md
+2 -2
View File
@@ -65,7 +65,7 @@ WORKDIR /app
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=8787
ENV TJWATER_CLI_PATH=./cli/tjwater-cli/tjwater-cli
ENV TJWATER_CLI_PATH=./cli/tjwater-cli
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules
@@ -76,7 +76,7 @@ COPY .opencode ./.opencode
COPY cli ./cli
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh ./cli/tjwater-cli/tjwater-cli
RUN chmod +x /entrypoint.sh ./cli/tjwater-cli
ENTRYPOINT ["/entrypoint.sh"]
+656
View File
@@ -0,0 +1,656 @@
#!/usr/bin/env bun
const SCHEMA_VERSION = "tjwater-cli/v1";
const DEFAULT_SERVER = "http://192.168.1.114:8000";
const DEFAULT_TIMEOUT = 180;
const GROUPS = {
network: "管网节点、管线等基础属性查询命令。",
component: "组件选项与配置读取命令。",
simulation: "模拟运行与调度相关命令。",
analysis: "分析计算与诊断相关命令。",
data: "时序、SCADA 和方案数据查询命令。",
};
const NETWORK_COMMANDS = {
"get-junction-properties": {
summary: "读取节点属性成功",
path: "/getjunctionproperties/",
options: { junction: { required: true } },
params: ({ args, ctx }) => ({ network: requireNetwork(ctx), junction: args.junction }),
},
"get-pipe-properties": {
summary: "读取管道属性成功",
path: "/getpipeproperties/",
options: { pipe: { required: true } },
params: ({ args, ctx }) => ({ network: requireNetwork(ctx), pipe: args.pipe }),
},
"get-all-pipes-properties": {
summary: "读取全部管道属性成功",
path: "/getallpipeproperties/",
params: ({ ctx }) => ({ network: requireNetwork(ctx) }),
},
"get-reservoir-properties": {
summary: "读取水库属性成功",
path: "/getreservoirproperties/",
options: { reservoir: { required: true } },
params: ({ args, ctx }) => ({ network: requireNetwork(ctx), reservoir: args.reservoir }),
},
"get-all-reservoirs-properties": {
summary: "读取全部水库属性成功",
path: "/getallreservoirproperties/",
params: ({ ctx }) => ({ network: requireNetwork(ctx) }),
},
"get-tank-properties": {
summary: "读取水箱属性成功",
path: "/gettankproperties/",
options: { tank: { required: true } },
params: ({ args, ctx }) => ({ network: requireNetwork(ctx), tank: args.tank }),
},
"get-all-tanks-properties": {
summary: "读取全部水箱属性成功",
path: "/getalltankproperties/",
params: ({ ctx }) => ({ network: requireNetwork(ctx) }),
},
"get-pump-properties": {
summary: "读取水泵属性成功",
path: "/getpumpproperties/",
options: { pump: { required: true } },
params: ({ args, ctx }) => ({ network: requireNetwork(ctx), pump: args.pump }),
},
"get-all-pumps-properties": {
summary: "读取全部水泵属性成功",
path: "/getallpumpproperties/",
params: ({ ctx }) => ({ network: requireNetwork(ctx) }),
},
"get-valve-properties": {
summary: "读取阀门属性成功",
path: "/getvalveproperties/",
options: { valve: { required: true } },
params: ({ args, ctx }) => ({ network: requireNetwork(ctx), valve: args.valve }),
},
"get-all-valves-properties": {
summary: "读取全部阀门属性成功",
path: "/getallvalveproperties/",
params: ({ ctx }) => ({ network: requireNetwork(ctx) }),
},
};
const DATA_REALTIME_COMMANDS = {
links: {
summary: "读取实时管道数据成功",
path: "/realtime/links",
options: { "start-time": { required: true }, "end-time": { required: true } },
requireProject: true,
params: ({ args }) => ({
start_time: parseTime(args["start-time"], "--start-time"),
end_time: parseTime(args["end-time"], "--end-time"),
}),
},
nodes: {
summary: "读取实时节点数据成功",
path: "/realtime/nodes",
options: { "start-time": { required: true }, "end-time": { required: true } },
requireProject: true,
params: ({ args }) => ({
start_time: parseTime(args["start-time"], "--start-time"),
end_time: parseTime(args["end-time"], "--end-time"),
}),
},
"simulation-by-id-time": {
summary: "读取实时模拟数据成功",
path: "/realtime/query/by-id-time",
options: { id: { required: true }, type: { required: true }, time: { required: true } },
requireProject: true,
params: ({ args }) => ({
id: args.id,
type: args.type,
query_time: parseTime(args.time, "--time"),
}),
},
"simulation-by-time-property": {
summary: "读取实时属性聚合数据成功",
path: "/realtime/query/by-time-property",
options: { type: { required: true }, time: { required: true }, property: { required: true } },
requireProject: true,
params: ({ args }) => ({
type: args.type,
query_time: parseTime(args.time, "--time"),
property: args.property,
}),
},
};
const DATA_SCHEME_COMMANDS = {
list: {
summary: "读取方案列表成功",
path: "/schemes",
requireProject: true,
params: ({ args }) => maybe({ scheme_type: args["scheme-type"] }),
},
get: {
summary: "读取方案详情成功",
path: ({ positionals }) => `/schemes/${encodeURIComponent(requirePos(positionals, 0, "scheme name"))}`,
requireProject: true,
params: ({ args }) => maybe({ scheme_type: args["scheme-type"] }),
},
links: {
summary: "读取方案管道数据成功",
path: "/scheme/links",
options: { "start-time": { required: true }, "end-time": { required: true } },
requireProject: true,
params: ({ args, ctx }) => ({
scheme_name: resolveScheme(ctx, args.scheme, true),
scheme_type: args["scheme-type"] ?? "simulation",
start_time: parseTime(args["start-time"], "--start-time"),
end_time: parseTime(args["end-time"], "--end-time"),
}),
},
};
const DATA_SCADA_COMMANDS = {
list: {
summary: "读取 SCADA 元数据成功",
path: "/scada/devices",
requireProject: true,
params: ({ args }) => maybe({ kind: args.kind }),
},
query: {
summary: "读取 SCADA 时序成功",
path: ({ args }) => (args.field ? "/scada/by-ids-field-time-range" : "/scada/by-ids-time-range"),
options: { "device-id": { required: true, repeated: true }, "start-time": { required: true }, "end-time": { required: true } },
requireProject: true,
params: ({ args }) => ({
device_ids: toArray(args["device-id"]).join(","),
start_time: parseTime(args["start-time"], "--start-time"),
end_time: parseTime(args["end-time"], "--end-time"),
...maybe({ field: args.field }),
}),
},
};
async function main() {
const parsed = parseArgs(Bun.argv.slice(2));
if (parsed.help || parsed.command.length === 0) {
emitHelp(parsed.command);
return 0;
}
if (parsed.command[0] === "help") {
emitHelp(parsed.command.slice(1));
return 0;
}
const ctx = await buildContext(parsed.global);
const startedAt = Date.now();
const result = await dispatch(parsed, ctx);
const data = await requestJson(ctx, result);
emitSuccess({
summary: result.summary,
data,
ctx,
durationMs: Date.now() - startedAt,
nextCommands: result.nextCommands,
});
return 0;
}
async function dispatch(parsed, ctx) {
const [group, ...rest] = parsed.command;
if (group === "network") {
return commandFromMap(NETWORK_COMMANDS, rest, parsed, ctx);
}
if (group === "component" && rest[0] === "option") {
return componentOption(rest.slice(1), parsed, ctx);
}
if (group === "simulation" && rest[0] === "run") {
requireOptions(parsed.args, { "start-time": { required: true }, duration: { required: true } });
const start = parseTime(parsed.args["start-time"], "--start-time");
const duration = Number(parsed.args.duration);
const endTime = new Date(Date.parse(start) + duration * 60_000).toISOString();
return {
summary: "触发模拟成功",
method: "POST",
path: "/runsimulationmanuallybydate/",
body: { name: requireNetwork(ctx), start_time: start, duration },
nextCommands: [
`tjwater-cli data timeseries realtime links --start-time ${start} --end-time ${endTime}`,
`tjwater-cli data timeseries realtime nodes --start-time ${start} --end-time ${endTime}`,
],
};
}
if (group === "analysis") {
return analysis(rest, parsed, ctx);
}
if (group === "data") {
return dataCommand(rest, parsed, ctx);
}
throw cliError("未找到命令", "COMMAND_NOT_FOUND", `unknown command: ${parsed.command.join(" ")}`, 2, {
next_commands: ["tjwater-cli help"],
});
}
function commandFromMap(map, commandPath, parsed, ctx) {
const name = commandPath[0];
const spec = map[name];
if (!spec) {
throw cliError("未找到命令", "COMMAND_NOT_FOUND", `unknown command: ${parsed.command.join(" ")}`, 2, {
next_commands: ["tjwater-cli help"],
});
}
requireOptions(parsed.args, spec.options ?? {});
const positionals = [...commandPath.slice(1), ...parsed.positionals];
return {
summary: spec.summary,
method: spec.method ?? "GET",
path: valueOf(spec.path, { args: parsed.args, positionals, ctx }),
params: spec.params?.({ args: parsed.args, positionals, ctx }) ?? {},
body: spec.body?.({ args: parsed.args, positionals, ctx }),
requireProject: spec.requireProject,
nextCommands: spec.nextCommands,
};
}
function componentOption(rest, parsed, ctx) {
const action = rest[0];
if (action !== "schema" && action !== "get") {
throw cliError("未找到命令", "COMMAND_NOT_FOUND", "unknown component option command", 2);
}
requireOptions(parsed.args, { kind: { required: true } });
const kind = parsed.args.kind;
const routes = {
"time:schema": "/gettimeschema",
"time:get": "/gettimeproperties/",
"energy:schema": "/getenergyschema/",
"energy:get": "/getenergyproperties/",
"pump-energy:schema": "/getpumpenergyschema/",
"pump-energy:get": "/getpumpenergyproperties//",
"network:schema": "/getoptionschema/",
"network:get": "/getoptionproperties/",
};
const path = routes[`${kind}:${action}`];
if (!path) {
throw cliError("CLI 参数错误", "INVALID_KIND", "--kind must be one of time, energy, pump-energy, network", 2);
}
if (kind === "pump-energy" && action === "get" && !parsed.args.pump) {
throw cliError("CLI 参数错误", "PUMP_REQUIRED", "--pump is required when --kind pump-energy", 2);
}
return {
summary: action === "schema" ? "读取选项 schema 成功" : "读取选项属性成功",
method: "GET",
path,
params: { network: requireNetwork(ctx), ...maybe({ pump: parsed.args.pump }) },
};
}
function analysis(rest, parsed, ctx) {
const [domain, sub, leaf] = rest;
if (domain === "age") {
requireOptions(parsed.args, { "start-time": { required: true }, duration: { required: true } });
return {
summary: "水龄分析执行成功",
method: "GET",
path: "/age_analysis/",
params: {
network: requireNetwork(ctx),
start_time: parseTime(parsed.args["start-time"], "--start-time"),
duration: Number(parsed.args.duration),
},
};
}
if (domain === "leakage" && sub === "schemes") {
return schemesCommand("漏损", "/leakage/schemes", leaf, rest[3], parsed, ctx);
}
if (domain === "burst-detection" && sub === "schemes") {
return schemesCommand("爆管检测", "/burst-detection/schemes", leaf, rest[3], parsed, ctx);
}
if (domain === "leakage" && sub === "identify") {
return detectionCommand("漏损识别执行成功", "/leakage/identify/", parsed, ctx);
}
if (domain === "burst-detection" && sub === "detect") {
return detectionCommand("爆管检测执行成功", "/burst-detection/detect/", parsed, ctx);
}
if (domain === "sensor-placement" && sub === "kmeans") {
requireOptions(parsed.args, { count: { required: true } });
return {
summary: "传感器选址执行成功",
method: "POST",
path: "/pressure_sensor_placement_kmeans/",
body: {
name: requireNetwork(ctx),
scheme_name: resolveScheme(ctx, parsed.args.scheme, true),
sensor_number: Number(parsed.args.count),
min_diameter: Number(parsed.args["min-diameter"] ?? 0),
username: requireUsername(ctx),
},
};
}
throw cliError("未找到命令", "COMMAND_NOT_FOUND", `unknown analysis command: ${rest.join(" ")}`, 2);
}
function schemesCommand(label, basePath, action, inlineName, parsed, ctx) {
if (action === "list") {
return {
summary: `读取${label}方案列表成功`,
method: "GET",
path: `${basePath}/`,
params: { network: requireNetwork(ctx) },
};
}
if (action === "get") {
const name = inlineName ?? requirePos(parsed.positionals, 0, "scheme name");
return {
summary: `读取${label}方案详情成功`,
method: "GET",
path: `${basePath}/${encodeURIComponent(name)}`,
params: { network: requireNetwork(ctx) },
};
}
throw cliError("未找到命令", "COMMAND_NOT_FOUND", "unknown schemes command", 2);
}
function detectionCommand(summary, path, parsed, ctx) {
requireOptions(parsed.args, { "start-time": { required: true }, "end-time": { required: true } });
return {
summary,
method: "POST",
path,
body: {
network: requireNetwork(ctx),
scada_start: parseTime(parsed.args["start-time"], "--start-time"),
scada_end: parseTime(parsed.args["end-time"], "--end-time"),
scheme_name: resolveScheme(ctx, parsed.args.scheme, true),
},
};
}
function dataCommand(rest, parsed, ctx) {
if (rest[0] === "timeseries" && rest[1] === "realtime") {
return commandFromMap(DATA_REALTIME_COMMANDS, rest.slice(2), parsed, ctx);
}
if (rest[0] === "timeseries" && rest[1] === "scheme") {
return commandFromMap(DATA_SCHEME_COMMANDS, rest.slice(2), parsed, ctx);
}
if (rest[0] === "timeseries" && rest[1] === "scada") {
return commandFromMap(DATA_SCADA_COMMANDS, rest.slice(2), parsed, ctx);
}
if (rest[0] === "scada") {
return commandFromMap(DATA_SCADA_COMMANDS, rest.slice(1), parsed, ctx);
}
if (rest[0] === "scheme") {
return commandFromMap(DATA_SCHEME_COMMANDS, rest.slice(1), parsed, ctx);
}
throw cliError("未找到命令", "COMMAND_NOT_FOUND", `unknown data command: ${rest.join(" ")}`, 2);
}
async function requestJson(ctx, request) {
if (request.requireProject) {
requireProject(ctx);
}
const url = new URL(valueOf(request.path, { args: {}, positionals: [], ctx }), ctx.server);
for (const [key, value] of Object.entries(request.params ?? {})) {
if (value === undefined || value === null || value === "") continue;
if (Array.isArray(value)) {
for (const item of value) url.searchParams.append(key, String(item));
continue;
}
url.searchParams.set(key, String(value));
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), ctx.timeout * 1000);
const headers = {
Accept: "application/json",
"X-Request-Id": ctx.requestId,
...ctx.auth.headers,
};
if (ctx.auth.accessToken) headers.Authorization = `Bearer ${ctx.auth.accessToken}`;
if (ctx.auth.projectId) headers["X-Project-Id"] = ctx.auth.projectId;
if (ctx.auth.userId) headers["X-User-Id"] = ctx.auth.userId;
if (request.body !== undefined) headers["Content-Type"] = "application/json";
try {
const response = await fetch(url, {
method: request.method ?? "GET",
headers,
body: request.body === undefined ? undefined : JSON.stringify(request.body),
signal: controller.signal,
});
const text = await response.text();
let data = text;
try {
data = text ? JSON.parse(text) : null;
} catch {}
if (!response.ok) {
throw cliError("服务端请求失败", "HTTP_ERROR", `server returned HTTP ${response.status}`, response.status >= 500 ? 4 : 2, {
retryable: response.status >= 500,
data: { status: response.status, body: data },
});
}
return data;
} catch (error) {
if (error?.name === "AbortError") {
throw cliError("命令超时", "TIMEOUT", `request timed out after ${ctx.timeout}s`, 4, { retryable: true });
}
throw error;
} finally {
clearTimeout(timeout);
}
}
async function buildContext(global) {
const auth = await loadAuth(global.authStdin);
return {
server: (global.server ?? auth.server ?? DEFAULT_SERVER).replace(/\/+$/, ""),
auth,
scheme: global.scheme,
timeout: Number(global.timeout ?? DEFAULT_TIMEOUT),
requestId: global.requestId ?? crypto.randomUUID(),
};
}
async function loadAuth(authStdin) {
if (authStdin) {
const raw = await new Response(Bun.stdin.stream()).text();
return normalizeAuth(JSON.parse(raw || "{}"));
}
const extraHeaders = process.env.TJWATER_EXTRA_HEADERS
? JSON.parse(process.env.TJWATER_EXTRA_HEADERS)
: {};
return normalizeAuth({
server: process.env.TJWATER_SERVER,
access_token: process.env.TJWATER_ACCESS_TOKEN,
project_id: process.env.TJWATER_PROJECT_ID,
user_id: process.env.TJWATER_USER_ID,
username: process.env.TJWATER_USERNAME,
network: process.env.TJWATER_NETWORK,
headers: extraHeaders,
});
}
function normalizeAuth(raw) {
return {
server: pick(raw, "server", "base_url"),
accessToken: pick(raw, "access_token", "token", "accessToken"),
projectId: pick(raw, "project_id", "projectId", "x_project_id"),
userId: pick(raw, "user_id", "userId", "x_user_id"),
username: pick(raw, "username", "preferred_username"),
network: pick(raw, "network", "project_code", "projectCode", "project"),
headers: typeof raw.headers === "object" && raw.headers !== null ? raw.headers : {},
};
}
function parseArgs(argv) {
const global = {};
const command = [];
const args = {};
const positionals = [];
let help = false;
let commandStarted = false;
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token === "--help" || token === "-h") {
help = true;
continue;
}
if (!commandStarted && token.startsWith("--")) {
const key = camel(token.slice(2));
if (["authStdin"].includes(key)) {
global[key] = true;
} else {
global[key] = argv[++i];
}
continue;
}
commandStarted = true;
if (token.startsWith("--")) {
const key = token.slice(2);
const next = argv[i + 1];
const value = next && !next.startsWith("--") ? argv[++i] : true;
if (args[key] === undefined) {
args[key] = value;
} else if (Array.isArray(args[key])) {
args[key].push(value);
} else {
args[key] = [args[key], value];
}
continue;
}
if (Object.keys(args).length === 0 && positionals.length === 0) {
command.push(token);
} else {
positionals.push(token);
}
}
return { global, command, args, positionals, help };
}
function requireOptions(args, spec) {
for (const [name, option] of Object.entries(spec)) {
if (option.required && (args[name] === undefined || args[name] === "")) {
throw cliError("CLI 参数错误", "MISSING_OPTION", `missing required option --${name}`, 2);
}
}
}
function emitHelp(command = []) {
const payload = {
ok: true,
schema_version: SCHEMA_VERSION,
summary: command.length ? `命令帮助:${command.join(" ")}` : "TJWater agent CLI",
data: {
command: command.join(" "),
groups: GROUPS,
examples: [
"tjwater-cli help",
"tjwater-cli network get-all-pipes-properties",
"tjwater-cli data timeseries realtime links --start-time 2025-01-01T00:00:00+08:00 --end-time 2025-01-01T01:00:00+08:00",
"tjwater-cli analysis leakage schemes list",
],
},
};
console.log(JSON.stringify(payload, null, 2));
}
function emitSuccess({ summary, data, ctx, durationMs, nextCommands }) {
console.log(JSON.stringify({
ok: true,
schema_version: SCHEMA_VERSION,
summary,
data,
metadata: {
server: ctx.server,
request_id: ctx.requestId,
duration_ms: durationMs,
},
next_commands: nextCommands,
}));
}
function emitFailure(error) {
const payload = {
ok: false,
schema_version: SCHEMA_VERSION,
summary: error.summary ?? "命令失败",
error: {
code: error.code ?? "UNKNOWN",
message: error.message ?? String(error),
retryable: Boolean(error.retryable),
},
data: error.data,
next_commands: error.next_commands,
};
console.error(JSON.stringify(payload));
return error.exitCode ?? 1;
}
function cliError(summary, code, message, exitCode, extra = {}) {
const error = new Error(message);
error.summary = summary;
error.code = code;
error.exitCode = exitCode;
Object.assign(error, extra);
return error;
}
function requireNetwork(ctx) {
if (ctx.auth.network) return ctx.auth.network;
throw cliError("认证失败", "NETWORK_CONTEXT_REQUIRED", "missing network in auth context", 3);
}
function requireProject(ctx) {
if (ctx.auth.projectId) return ctx.auth.projectId;
throw cliError("认证失败", "PROJECT_CONTEXT_REQUIRED", "missing project_id for agent context", 3);
}
function requireUsername(ctx) {
if (ctx.auth.username) return ctx.auth.username;
throw cliError("认证失败", "USERNAME_CONTEXT_REQUIRED", "missing username in auth context", 3);
}
function resolveScheme(ctx, explicit, required) {
const scheme = explicit ?? ctx.scheme;
if (required && !scheme) {
throw cliError("CLI 参数错误", "SCHEME_REQUIRED", "missing scheme; use --scheme", 2);
}
return scheme;
}
function parseTime(value, optionName) {
if (!value || Number.isNaN(Date.parse(value)) || !/[zZ]|[+-]\d\d:\d\d$/.test(value)) {
throw cliError("CLI 参数错误", "INVALID_TIME", `${optionName} must be RFC3339 with timezone`, 2);
}
return value;
}
function requirePos(values, index, name) {
const value = values[index];
if (!value) throw cliError("CLI 参数错误", "MISSING_ARGUMENT", `missing ${name}`, 2);
return value;
}
function pick(raw, ...keys) {
for (const key of keys) {
if (raw?.[key] !== undefined && raw[key] !== "") return raw[key];
}
return undefined;
}
function valueOf(value, args) {
return typeof value === "function" ? value(args) : value;
}
function maybe(values) {
return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined && value !== ""));
}
function toArray(value) {
return Array.isArray(value) ? value : value === undefined ? [] : [value];
}
function camel(value) {
return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
}
main()
.then((code) => process.exit(code))
.catch((error) => process.exit(emitFailure(error)));