tyc-cli 0.2.5 → 0.3.1

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.
@@ -2,6 +2,7 @@ import { getCategories, getToolsByGroup } from "../registry.js";
2
2
  import { resolveConfig } from "../config.js";
3
3
  import { callTool } from "../mcpClient.js";
4
4
  import { jsonToMarkdown } from "../utils/jsonToMarkdown.js";
5
+ import { applyTruncation, resolveOptionalInt, TRUNCATE_DEFAULTS, } from "../utils/truncate.js";
5
6
  export function registerCategoryCommands(program) {
6
7
  for (const cat of getCategories()) {
7
8
  const tools = getToolsByGroup(cat.group);
@@ -81,21 +82,43 @@ function emit(result, opts, toolName) {
81
82
  payload = JSON.parse(text);
82
83
  }
83
84
  catch {
84
- // 非 JSON 文本:直接原样输出
85
- process.stdout.write(text.endsWith("\n") ? text : text + "\n");
85
+ // 非 JSON 文本:直接原样输出(仍走截断 / 落盘)
86
+ emitWithTruncation(text.endsWith("\n") ? text.slice(0, -1) : text, opts);
86
87
  return;
87
88
  }
89
+ // 输出模式选择(互斥优先级 --md > --compact > --pretty / 默认 = pretty)
88
90
  let rendered;
89
91
  if (opts.md) {
90
92
  rendered = jsonToMarkdown(payload, toolName);
91
93
  }
92
- else if (opts.pretty) {
93
- rendered = JSON.stringify(payload, null, 2);
94
+ else if (opts.compact) {
95
+ rendered = JSON.stringify(payload);
94
96
  }
95
97
  else {
96
- rendered = JSON.stringify(payload);
98
+ // 默认 = pretty(含 --pretty 显式情况)
99
+ rendered = JSON.stringify(payload, null, 2);
100
+ }
101
+ emitWithTruncation(rendered, opts);
102
+ }
103
+ function emitWithTruncation(rendered, opts) {
104
+ // 解析 head / tail / threshold:commander 对 `[n]` 在未传值时给 true
105
+ const head = resolveOptionalInt(opts.head, TRUNCATE_DEFAULTS.HEAD);
106
+ const tail = resolveOptionalInt(opts.tail, TRUNCATE_DEFAULTS.TAIL);
107
+ const threshold = resolveOptionalInt(opts.threshold, TRUNCATE_DEFAULTS.THRESHOLD);
108
+ const outputFile = typeof opts.outputFile === "string" && opts.outputFile.length > 0
109
+ ? opts.outputFile
110
+ : undefined;
111
+ const r = applyTruncation(rendered, {
112
+ full: !!opts.full,
113
+ head,
114
+ tail,
115
+ threshold,
116
+ outputFile,
117
+ });
118
+ console.log(r.rendered);
119
+ for (const n of r.notices) {
120
+ process.stderr.write(n + "\n");
97
121
  }
98
- console.log(rendered);
99
122
  }
100
123
  function extractText(result) {
101
124
  const content = result.content || [];
package/dist/index.js CHANGED
@@ -59,12 +59,29 @@ DISCOVER BY CATEGORY (orthogonal to layers, 6 groups)
59
59
 
60
60
  ${renderCategoryLines()}
61
61
 
62
- OUTPUT FORMATS (mutually exclusive, priority: --md > --pretty > default)
62
+ OUTPUT FORMATS (mutually exclusive, priority: --md > --compact > --pretty / default)
63
63
 
64
- (default) compact single-line JSON (pipe-friendly)
65
- --pretty indented JSON (debug)
64
+ (default) indented JSON (pretty) — readable for both humans and agents
65
+ --pretty same as default (kept for backward compatibility / explicit intent)
66
+ --compact compact single-line JSON (pipe / jq friendly; old default behavior)
66
67
  --md Markdown tables (human + agent on-screen)
67
- --verbose also print MCP request details to stderr
68
+ --verbose also print MCP request details to stderr (orthogonal to above)
69
+
70
+ OUTPUT TRUNCATION & DUMP (orthogonal — apply to any sub-command)
71
+
72
+ --head [N] print only the first N lines (N defaults to 50 if flag given alone)
73
+ --tail [M] print only the last M lines (M defaults to 20 if flag given alone)
74
+ · --head + --tail together: head + "... omitted ..." + tail
75
+ · either flag alone: that side only
76
+ --full force-print the FULL rendered content (no truncation; highest priority)
77
+ --threshold <BYTES> master switch for byte-truncation. Without --threshold there is
78
+ NO automatic truncation, regardless of output size.
79
+ With --threshold N: when rendered output > N bytes, byte-truncate
80
+ from the head; --head / --tail are IGNORED in this mode.
81
+ --output-file <PATH> write the FULL rendered content to PATH. **No file is written
82
+ unless --output-file is explicitly passed** (no auto-dump).
83
+ Combine with --head/--tail/--threshold to keep stdout terse
84
+ while preserving the full result on disk.
68
85
 
69
86
  SETUP
70
87
 
@@ -97,10 +114,16 @@ function renderPriorityLines() {
97
114
  const program = new Command()
98
115
  .name("tyc")
99
116
  .description(`Tianyan AI CLI — ${getTotalCount()} commercial-data tools · L0=${getLayerCount("L0")} L1=${getLayerCount("L1")} L2=${getLayerCount("L2")} L3=${getLayerCount("L3")} · the AI-native gateway (pair with the TA MCP Server)`)
100
- .version("0.3.0")
101
- .option("--pretty", "格式化 JSON 输出(缩进 2 空格)")
117
+ .version("0.4.0")
118
+ .option("--pretty", "缩进 JSON 输出(默认行为,flag 保留以保持向后兼容)")
102
119
  .option("--md", "Markdown 表格化输出(适合人类阅读 / Agent 上屏)")
120
+ .option("--compact", "紧凑单行 JSON(管道 / jq 场景;为旧默认行为)")
103
121
  .option("--verbose", "打印 MCP 请求详情到 stderr")
122
+ .option("--head [n]", "仅输出前 N 行(不传值默认 50;与 --tail 同时给则同时输出两端)")
123
+ .option("--tail [m]", "仅输出后 M 行(不传值默认 20;与 --head 同时给则同时输出两端)")
124
+ .option("--full", "强制输出完整内容(最高优先级,覆盖 --head/--tail/--threshold)")
125
+ .option("--output-file <path>", "把完整结果写入指定路径(必须显式指定;不传则永不落盘)")
126
+ .option("--threshold <bytes>", "字节截断主开关:超过该字节数则从头按字节截断;不传则永不截断")
104
127
  .addHelpText("after", helpText);
105
128
  registerInitCommand(program);
106
129
  registerLayerCommands(program);
@@ -0,0 +1,152 @@
1
+ // 输出截断 / 落盘工具
2
+ //
3
+ // 设计原则(与用户对齐):
4
+ // 1. --threshold 是截断逻辑的"主开关"。**没有 --threshold 时永不按字节截断**。
5
+ // 2. --head N / --tail M 各自显式生效,互不依赖;同时给则同时输出两端。
6
+ // head/tail 是 line-based、对人类可读输出(pretty / md / compact)都适用。
7
+ // 3. --threshold 与 --head/--tail 同时出现时按"从头按字节截"语义优先(用户原话),
8
+ // 避免用户的字节预算被行截断绕过。
9
+ // 4. --full 最高优先级:永远输出完整内容;仍可与 --output-file 共存(落盘 + 全量打印)。
10
+ // 5. **不指定 --output-file 永不落盘**(用户明确要求)。
11
+ //
12
+ // 渲染顺序:先决定 mode(md / pretty / compact),渲染成字符串后送入 truncate;
13
+ // 落盘永远写"渲染后的字符串",与 stdout 看到的同源,便于 diff / 复盘。
14
+ import { writeFileSync, mkdirSync } from "node:fs";
15
+ import { dirname, resolve } from "node:path";
16
+ const HEAD_DEFAULT = 50;
17
+ const TAIL_DEFAULT = 20;
18
+ const THRESHOLD_DEFAULT = 5000;
19
+ /**
20
+ * 解析 commander 接收到的可选值参数:
21
+ * - undefined:用户未指定该 flag
22
+ * - true:用户指定了 flag 但未给值(commander `[n]` 语法)→ 用 fallback
23
+ * - string:用户指定了具体值 → parseInt
24
+ * - 非法值 → fallback(保持宽容;CLI 不该因为 --head abc 直接崩)
25
+ */
26
+ export function resolveOptionalInt(raw, fallback) {
27
+ if (raw === undefined)
28
+ return undefined;
29
+ if (raw === true || raw === "")
30
+ return fallback;
31
+ const n = parseInt(String(raw), 10);
32
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
33
+ }
34
+ /** Buffer 字节数(与 stdout 实际字节数一致;JS .length 是 UTF-16 code units,会高估纯 ASCII / 低估中文) */
35
+ function byteLen(s) {
36
+ return Buffer.byteLength(s, "utf8");
37
+ }
38
+ /**
39
+ * 按字节截到 ≤ maxBytes,且回退到 UTF-8 字符边界(避免半个汉字)。
40
+ * 我们只截 head 端(用户原话:"直接从头到尾按字节数量来截断")。
41
+ */
42
+ function truncateByBytes(s, maxBytes) {
43
+ const buf = Buffer.from(s, "utf8");
44
+ if (buf.length <= maxBytes)
45
+ return s;
46
+ let end = maxBytes;
47
+ // UTF-8 续字节模式 10xxxxxx:往回退到上一个起始字节
48
+ while (end > 0 && (buf[end] & 0xc0) === 0x80)
49
+ end--;
50
+ return buf.subarray(0, end).toString("utf8");
51
+ }
52
+ function truncateByLines(s, head, tail) {
53
+ const lines = s.split("\n");
54
+ const total = lines.length;
55
+ if (head === undefined && tail === undefined) {
56
+ return { out: s, truncated: false, total };
57
+ }
58
+ const h = head ?? 0;
59
+ const t = tail ?? 0;
60
+ if (h + t >= total) {
61
+ return { out: s, truncated: false, total };
62
+ }
63
+ const omitted = total - h - t;
64
+ const parts = [];
65
+ if (head !== undefined)
66
+ parts.push(...lines.slice(0, h));
67
+ if (head !== undefined && tail !== undefined) {
68
+ parts.push(`... (省略中间 ${omitted} 行) ...`);
69
+ parts.push(...lines.slice(-t));
70
+ }
71
+ else if (head !== undefined) {
72
+ parts.push(`... (省略后续 ${omitted} 行) ...`);
73
+ }
74
+ else if (tail !== undefined) {
75
+ parts.push(`... (省略前 ${omitted} 行) ...`);
76
+ parts.push(...lines.slice(-t));
77
+ }
78
+ return { out: parts.join("\n"), truncated: true, total };
79
+ }
80
+ function writeDump(absPath, content) {
81
+ const dir = dirname(absPath);
82
+ mkdirSync(dir, { recursive: true });
83
+ writeFileSync(absPath, content, { encoding: "utf8" });
84
+ }
85
+ /**
86
+ * 按用户语义对渲染好的字符串做截断 / 落盘。
87
+ *
88
+ * 决策树(每条分支互斥):
89
+ * 1. --output-file 给了 → 落盘(写"全量"原文,与 stdout 截断版本无关)
90
+ * 2. --full → stdout 全量
91
+ * 3. --threshold 给了:
92
+ * size > threshold → 字节截到 threshold(head 端)
93
+ * size ≤ threshold → 全量
94
+ * (此分支下 --head/--tail 被忽略,符合"从头按字节截断"语义)
95
+ * 4. --head 或 --tail 给了(且无 --threshold)→ line-based 头/尾/头+尾截断
96
+ * 5. 都没给 → 全量
97
+ */
98
+ export function applyTruncation(rendered, opts) {
99
+ const notices = [];
100
+ let fileWritten;
101
+ // 1. 落盘永远写全量
102
+ if (opts.outputFile && opts.outputFile.length > 0) {
103
+ const abs = resolve(opts.outputFile);
104
+ try {
105
+ writeDump(abs, rendered);
106
+ fileWritten = abs;
107
+ notices.push(`已写入完整结果到 ${abs}(${byteLen(rendered)} 字节)`);
108
+ }
109
+ catch (err) {
110
+ const msg = err instanceof Error ? err.message : String(err);
111
+ notices.push(`⚠️ --output-file 写入失败:${msg}(继续打印 stdout)`);
112
+ }
113
+ }
114
+ // 2. --full 最高优先级
115
+ if (opts.full) {
116
+ return { rendered, fileWritten, notices };
117
+ }
118
+ // 3. --threshold 主导:字节截
119
+ if (opts.threshold !== undefined) {
120
+ const size = byteLen(rendered);
121
+ if (size > opts.threshold) {
122
+ const cut = truncateByBytes(rendered, opts.threshold);
123
+ const omitted = size - byteLen(cut);
124
+ const tail = `\n... (已按 --threshold=${opts.threshold} 字节截断,省略尾部约 ${omitted} 字节) ...`;
125
+ notices.push(`输出被 --threshold=${opts.threshold} 截断(原始 ${size} 字节 → 输出 ${byteLen(cut)} 字节)` +
126
+ (fileWritten ? `;完整内容见 ${fileWritten}` : ";如需完整内容追加 --output-file <path>"));
127
+ return { rendered: cut + tail, fileWritten, notices };
128
+ }
129
+ return { rendered, fileWritten, notices };
130
+ }
131
+ // 4. line-based head/tail(无 --threshold)
132
+ if (opts.head !== undefined || opts.tail !== undefined) {
133
+ const r = truncateByLines(rendered, opts.head, opts.tail);
134
+ if (r.truncated) {
135
+ const segs = [];
136
+ if (opts.head !== undefined)
137
+ segs.push(`head=${opts.head}`);
138
+ if (opts.tail !== undefined)
139
+ segs.push(`tail=${opts.tail}`);
140
+ notices.push(`输出被 ${segs.join(" / ")} 截断(原始 ${r.total} 行)` +
141
+ (fileWritten ? `;完整内容见 ${fileWritten}` : ";如需完整内容追加 --output-file <path>"));
142
+ }
143
+ return { rendered: r.out, fileWritten, notices };
144
+ }
145
+ // 5. 无任何截断 flag
146
+ return { rendered, fileWritten, notices };
147
+ }
148
+ export const TRUNCATE_DEFAULTS = {
149
+ HEAD: HEAD_DEFAULT,
150
+ TAIL: TAIL_DEFAULT,
151
+ THRESHOLD: THRESHOLD_DEFAULT,
152
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tyc-cli",
3
- "version": "0.2.5",
3
+ "version": "0.3.1",
4
4
  "description": "天眼查 MCP CLI — 163 个业务语义聚合工具 · 命令行直达企业数据 · 通过 MCP Server 调用",
5
5
  "main": "dist/index.js",
6
6
  "bin": {