tyc-cli 0.3.5 → 0.3.7

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.
@@ -1,8 +1,7 @@
1
- import { DEFAULT_MCP_URL, defaultCoreURL, loadConfig, resolveConfig, saveConfig, } from "../config.js";
1
+ import { DEFAULT_MCP_URL, applyOAuthTokenToConfig, defaultCoreURL, loadConfig, resolveConfig, saveConfig, } from "../config.js";
2
2
  import { verifyCoreAuthEndpoint } from "../coreClient.js";
3
- import { clearSession } from "../session.js";
4
- import { ensureSession } from "../mcpClient.js";
5
- import { buildAuthorizationURL, createPKCE, discoverAuthorizationServer, discoverProtectedResourceMetadata, exchangeAuthorizationCode, knownOAuthDefaultsForMcpURL, openBrowser, randomState, registerDynamicOAuthClient, startCallbackServer, } from "../oauth.js";
3
+ import { buildAuthorizationURL, buildCliCallbackRedirectUri, createPKCE, discoverAuthorizationServer, discoverProtectedResourceMetadata, exchangeAuthorizationCode, knownOAuthDefaultsForMcpURL, openBrowser, parseAuthorizationCallbackCode, randomState, registerDynamicOAuthClient, startCallbackServer, } from "../oauth.js";
4
+ import { clearPendingOAuthLogin, loadPendingOAuthLogin, savePendingOAuthLogin, } from "../oauthPending.js";
6
5
  import { printLoginSuccessBanner } from "../logo.js";
7
6
  function parsePort(raw) {
8
7
  if (!raw)
@@ -13,10 +12,61 @@ function parsePort(raw) {
13
12
  }
14
13
  return n;
15
14
  }
16
- function normalizeTokenType(raw) {
17
- if (!raw)
18
- return "Bearer";
19
- return raw.toLowerCase() === "bearer" ? "Bearer" : raw;
15
+ function normalizeCallbackPath(raw) {
16
+ const path = raw || "/oauth/callback";
17
+ return path.startsWith("/") ? path : `/${path}`;
18
+ }
19
+ function pickCallbackToken(opts) {
20
+ if (opts.callbackToken && opts.token) {
21
+ throw new Error("--callback-token 和 --token 只能指定一个。");
22
+ }
23
+ return opts.callbackToken || opts.token;
24
+ }
25
+ function saveOAuthTokenConfig(mcpURL, token, oauth, verbose) {
26
+ const cfg = loadConfig() || {};
27
+ cfg.url = mcpURL;
28
+ delete cfg.transport;
29
+ cfg.coreUrl = defaultCoreURL(mcpURL);
30
+ applyOAuthTokenToConfig(cfg, token, oauth);
31
+ saveConfig(cfg);
32
+ if (verbose) {
33
+ console.error(`> OAuth login state saved to ~/.tyc/config.json (url=${cfg.url})`);
34
+ }
35
+ }
36
+ async function verifySavedOAuthLogin(verify, verbose) {
37
+ if (!verify) {
38
+ printLoginSuccessBanner();
39
+ return;
40
+ }
41
+ const resolved = resolveConfig();
42
+ await verifyCoreAuthEndpoint(resolved, verbose);
43
+ if (verbose) {
44
+ console.error("> Shared Core auth check passed");
45
+ }
46
+ printLoginSuccessBanner();
47
+ }
48
+ async function completePendingOAuthLogin(pending, callbackToken, verify, verbose) {
49
+ const code = parseAuthorizationCallbackCode(callbackToken, pending.state);
50
+ if (verbose) {
51
+ console.error("> authorization code provided; exchanging access token");
52
+ }
53
+ const token = await exchangeAuthorizationCode({
54
+ tokenEndpoint: pending.tokenEndpoint,
55
+ clientId: pending.clientId,
56
+ clientSecret: pending.clientSecret,
57
+ code,
58
+ redirectUri: pending.redirectUri,
59
+ codeVerifier: pending.codeVerifier,
60
+ resource: pending.resource,
61
+ });
62
+ saveOAuthTokenConfig(pending.mcpURL, token, {
63
+ tokenEndpoint: pending.tokenEndpoint,
64
+ clientId: pending.clientId,
65
+ clientSecret: pending.clientSecret,
66
+ resource: pending.resource,
67
+ }, verbose);
68
+ clearPendingOAuthLogin(verbose);
69
+ await verifySavedOAuthLogin(verify, verbose);
20
70
  }
21
71
  export function registerLoginCommand(program) {
22
72
  program
@@ -34,9 +84,29 @@ export function registerLoginCommand(program) {
34
84
  .option("--redirect-host <host>", "本地回调 host(默认 localhost)")
35
85
  .option("--redirect-port <port>", "本地回调端口(默认 7078)")
36
86
  .option("--callback-path <path>", "本地回调路径(默认 /oauth/callback)")
87
+ .option("--callback-token <code-or-url>", "继续非阻塞登录:传入授权回调 code 或包含 code 的完整 callback URL")
88
+ .option("--token <code-or-url>", "同 --callback-token(兼容别名;仍需传授权回调 code)")
37
89
  .option("--no-open", "不自动打开浏览器,只打印授权 URL")
38
- .option("--no-verify", "保存 token 后跳过 initialize 连通性校验")
90
+ .option("--no-block", "配合 --no-open:打印授权 URL 并保存 pending 状态后立即退出")
91
+ .option("--no-verify", "保存 token 后跳过 shared core 鉴权校验")
39
92
  .action(async (opts) => {
93
+ const verbose = !!program.opts().verbose;
94
+ const callbackToken = pickCallbackToken(opts);
95
+ if (callbackToken) {
96
+ const pending = loadPendingOAuthLogin();
97
+ if (!pending) {
98
+ throw new Error("未找到待完成的 OAuth 登录状态。请先运行 tyc login --no-open --no-block。");
99
+ }
100
+ if (verbose) {
101
+ console.error(`> loaded pending OAuth login from ~/.tyc/oauth_pending.json (url=${pending.mcpURL})`);
102
+ }
103
+ await completePendingOAuthLogin(pending, callbackToken, opts.verify !== false && pending.verify !== false, verbose);
104
+ return;
105
+ }
106
+ const noBlock = opts.block === false;
107
+ if (noBlock && opts.open !== false) {
108
+ throw new Error("--no-block 需要与 --no-open 一起使用。");
109
+ }
40
110
  const current = loadConfig() || {};
41
111
  const mcpURL = opts.url || process.env.TYC_MCP_ENDPOINT || current.url || DEFAULT_MCP_URL;
42
112
  const knownDefaults = knownOAuthDefaultsForMcpURL(mcpURL);
@@ -45,8 +115,7 @@ export function registerLoginCommand(program) {
45
115
  const scope = opts.scope || "mcp:tools.call";
46
116
  const redirectHost = opts.redirectHost || "localhost";
47
117
  const redirectPort = parsePort(opts.redirectPort || "7078");
48
- const callbackPath = opts.callbackPath || "/oauth/callback";
49
- const verbose = !!program.opts().verbose;
118
+ const callbackPath = normalizeCallbackPath(opts.callbackPath);
50
119
  let issuer = opts.issuer || "";
51
120
  let resource = opts.resource || "";
52
121
  if (!opts.issuer || !opts.resource) {
@@ -108,16 +177,19 @@ export function registerLoginCommand(program) {
108
177
  }
109
178
  const state = randomState();
110
179
  const pkce = createPKCE();
111
- const callback = await startCallbackServer({
112
- host: redirectHost,
113
- port: redirectPort,
114
- path: callbackPath.startsWith("/") ? callbackPath : `/${callbackPath}`,
115
- state,
116
- timeoutMs: 5 * 60 * 1000,
117
- });
180
+ let callback;
181
+ const redirectUri = noBlock
182
+ ? buildCliCallbackRedirectUri(issuer)
183
+ : (callback = await startCallbackServer({
184
+ host: redirectHost,
185
+ port: redirectPort,
186
+ path: callbackPath,
187
+ state,
188
+ timeoutMs: 5 * 60 * 1000,
189
+ })).redirectUri;
118
190
  if (!clientId) {
119
191
  if (!endpoints.registrationEndpoint) {
120
- callback.close();
192
+ callback?.close();
121
193
  throw new Error("OAuth metadata 未提供 registration_endpoint,请用 --client-id 指定预注册客户端。");
122
194
  }
123
195
  if (verbose) {
@@ -128,12 +200,12 @@ export function registerLoginCommand(program) {
128
200
  registeredClient = await registerDynamicOAuthClient({
129
201
  registrationEndpoint: endpoints.registrationEndpoint,
130
202
  clientName: "tyc-cli",
131
- redirectUris: [callback.redirectUri],
203
+ redirectUris: [redirectUri],
132
204
  tokenEndpointAuthMethod: "none",
133
205
  });
134
206
  }
135
207
  catch (err) {
136
- callback.close();
208
+ callback?.close();
137
209
  throw err;
138
210
  }
139
211
  clientId = registeredClient.clientId;
@@ -145,14 +217,36 @@ export function registerLoginCommand(program) {
145
217
  const authorizationURL = buildAuthorizationURL({
146
218
  authorizationEndpoint: endpoints.authorizationEndpoint,
147
219
  clientId,
148
- redirectUri: callback.redirectUri,
220
+ redirectUri,
149
221
  scope,
150
222
  state,
151
223
  codeChallenge: pkce.codeChallenge,
152
224
  resource,
153
225
  });
154
226
  if (verbose) {
155
- console.error(`> OAuth callback: ${callback.redirectUri}`);
227
+ console.error(`> OAuth callback: ${redirectUri}`);
228
+ }
229
+ if (noBlock) {
230
+ savePendingOAuthLogin({
231
+ version: 1,
232
+ createdAt: Date.now(),
233
+ mcpURL,
234
+ tokenEndpoint: endpoints.tokenEndpoint,
235
+ clientId,
236
+ clientSecret,
237
+ redirectUri,
238
+ codeVerifier: pkce.codeVerifier,
239
+ resource,
240
+ state,
241
+ verify: opts.verify !== false,
242
+ });
243
+ console.error("请在浏览器打开以下 URL 完成登录:");
244
+ console.error(authorizationURL);
245
+ console.error("");
246
+ console.error("完成授权后,浏览器会打开天眼查 CLI 回调页。请按页面提示复制命令到终端执行。");
247
+ console.error("也可以复制完整回调 URL 后运行:");
248
+ console.error(`tyc login --callback-token "${redirectUri}?code=...&state=..."`);
249
+ return;
156
250
  }
157
251
  if (opts.open === false) {
158
252
  console.error("请在浏览器打开以下 URL 完成登录:");
@@ -162,6 +256,9 @@ export function registerLoginCommand(program) {
162
256
  console.error("正在打开浏览器完成 OAuth 登录...");
163
257
  openBrowser(authorizationURL);
164
258
  }
259
+ if (!callback) {
260
+ throw new Error("OAuth callback server not initialized");
261
+ }
165
262
  const code = await callback.waitForCode();
166
263
  if (verbose) {
167
264
  console.error("> authorization code received; exchanging access token");
@@ -171,40 +268,17 @@ export function registerLoginCommand(program) {
171
268
  clientId,
172
269
  clientSecret,
173
270
  code,
174
- redirectUri: callback.redirectUri,
271
+ redirectUri,
175
272
  codeVerifier: pkce.codeVerifier,
176
273
  resource,
177
274
  });
178
- const cfg = loadConfig() || {};
179
- cfg.url = mcpURL;
180
- if (!cfg.transport)
181
- cfg.transport = "core";
182
- if (!cfg.coreUrl)
183
- cfg.coreUrl = defaultCoreURL(mcpURL);
184
- cfg.headers = { ...(cfg.headers || {}) };
185
- cfg.headers.Authorization = `${normalizeTokenType(token.token_type)} ${token.access_token}`;
186
- saveConfig(cfg);
187
- clearSession();
188
- if (verbose) {
189
- console.error(`> OAuth login state saved to ~/.tyc/config.json (url=${cfg.url})`);
190
- }
191
- if (opts.verify === false) {
192
- printLoginSuccessBanner();
193
- return;
194
- }
195
- const resolved = resolveConfig();
196
- if (resolved.transport === "mcp") {
197
- const sess = await ensureSession(resolved, { verbose });
198
- if (verbose) {
199
- console.error(`> MCP session initialized (sessionId=${sess.sessionId.slice(0, 16)}...)`);
200
- }
201
- }
202
- else {
203
- await verifyCoreAuthEndpoint(resolved, verbose);
204
- if (verbose) {
205
- console.error("> Shared Core auth check passed");
206
- }
207
- }
208
- printLoginSuccessBanner();
275
+ saveOAuthTokenConfig(mcpURL, token, {
276
+ tokenEndpoint: endpoints.tokenEndpoint,
277
+ clientId,
278
+ clientSecret,
279
+ resource,
280
+ }, verbose);
281
+ clearPendingOAuthLogin(verbose);
282
+ await verifySavedOAuthLogin(opts.verify !== false, verbose);
209
283
  });
210
284
  }
@@ -0,0 +1,60 @@
1
+ import { jsonToMarkdown } from "../utils/jsonToMarkdown.js";
2
+ import { applyTruncation, resolveOptionalInt, TRUNCATE_DEFAULTS, } from "../utils/truncate.js";
3
+ export function emitToolResult(result, opts, toolName) {
4
+ if (result.isError) {
5
+ const errText = extractText(result) || "未知业务错误";
6
+ console.error(errText);
7
+ process.exit(1);
8
+ }
9
+ const text = extractText(result);
10
+ if (text === null) {
11
+ console.error("tools/call 返回无可解析内容");
12
+ process.exit(1);
13
+ }
14
+ let payload;
15
+ try {
16
+ payload = JSON.parse(text);
17
+ }
18
+ catch {
19
+ emitWithTruncation(text.endsWith("\n") ? text.slice(0, -1) : text, opts);
20
+ return;
21
+ }
22
+ let rendered;
23
+ if (opts.md) {
24
+ rendered = jsonToMarkdown(payload, toolName);
25
+ }
26
+ else if (opts.compact) {
27
+ rendered = JSON.stringify(payload);
28
+ }
29
+ else {
30
+ rendered = JSON.stringify(payload, null, 2);
31
+ }
32
+ emitWithTruncation(rendered, opts);
33
+ }
34
+ function emitWithTruncation(rendered, opts) {
35
+ const head = resolveOptionalInt(opts.head, TRUNCATE_DEFAULTS.HEAD);
36
+ const tail = resolveOptionalInt(opts.tail, TRUNCATE_DEFAULTS.TAIL);
37
+ const threshold = resolveOptionalInt(opts.threshold, TRUNCATE_DEFAULTS.THRESHOLD);
38
+ const outputFile = typeof opts.outputFile === "string" && opts.outputFile.length > 0
39
+ ? opts.outputFile
40
+ : undefined;
41
+ const r = applyTruncation(rendered, {
42
+ full: !!opts.full,
43
+ head,
44
+ tail,
45
+ threshold,
46
+ outputFile,
47
+ });
48
+ console.log(r.rendered);
49
+ for (const n of r.notices) {
50
+ process.stderr.write(n + "\n");
51
+ }
52
+ }
53
+ function extractText(result) {
54
+ const content = result.content || [];
55
+ for (const c of content) {
56
+ if (typeof c.text === "string" && c.text.length > 0)
57
+ return c.text;
58
+ }
59
+ return null;
60
+ }
package/dist/config.js CHANGED
@@ -1,17 +1,17 @@
1
1
  // ~/.tyc/config.json 读写
2
2
  //
3
- // 配置文件格式与通用 MCP 客户端保持一致:
3
+ // 配置文件格式:
4
4
  // { "url": "https://mcp.tianyancha.com/mcp",
5
5
  // "headers": { "Authorization": "xxxx" },
6
- // "transport": "core",
7
6
  // "coreUrl": "https://mcp.tianyancha.com/v1/core/tools/call" }
8
7
  //
9
- // 默认端点指向天眼查公网 MCP Server 的 canonical 入口 /mcp(/v1 是兼容别名,
10
- // coreUrl 的 REST 端点仍只挂在 /v1/core 下);连接本地或私有环境可通过
8
+ // 默认端点指向天眼查公网 MCP Server 的 canonical 入口 /mcp,CLI 实际调用
9
+ // shared core REST 端点;连接本地或私有环境可通过
11
10
  // tyc init --url http://localhost:8080/mcp 或 TYC_MCP_ENDPOINT 环境变量覆盖。
12
11
  import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from "node:fs";
13
12
  import { join } from "node:path";
14
13
  import { homedir } from "node:os";
14
+ import { exchangeRefreshToken } from "./oauth.js";
15
15
  const CONFIG_DIR = join(homedir(), ".tyc");
16
16
  const CONFIG_FILE = join(CONFIG_DIR, "config.json");
17
17
  export const DEFAULT_MCP_URL = "https://mcp.tianyancha.com/mcp";
@@ -35,11 +35,57 @@ export function saveConfig(config) {
35
35
  }
36
36
  catch { /* best effort */ }
37
37
  }
38
- function normalizeTransport(raw) {
39
- const v = (raw || "").trim().toLowerCase();
40
- if (v === "mcp")
41
- return "mcp";
42
- return "core";
38
+ const OAUTH_REFRESH_SKEW_MS = 60 * 1000;
39
+ function normalizeTokenType(raw) {
40
+ if (!raw)
41
+ return "Bearer";
42
+ return raw.toLowerCase() === "bearer" ? "Bearer" : raw;
43
+ }
44
+ function jwtExpiresAt(accessToken) {
45
+ const parts = accessToken.split(".");
46
+ if (parts.length < 2)
47
+ return undefined;
48
+ try {
49
+ const normalized = parts[1].replace(/-/g, "+").replace(/_/g, "/");
50
+ const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
51
+ const payload = JSON.parse(Buffer.from(padded, "base64").toString("utf-8"));
52
+ if (typeof payload.exp === "number" && Number.isFinite(payload.exp)) {
53
+ return payload.exp * 1000;
54
+ }
55
+ }
56
+ catch {
57
+ // Non-JWT access tokens are valid OAuth responses too; expires_in remains authoritative.
58
+ }
59
+ return undefined;
60
+ }
61
+ export function inferAccessTokenExpiresAt(token, now = Date.now()) {
62
+ if (typeof token.expires_in === "number" && Number.isFinite(token.expires_in) && token.expires_in > 0) {
63
+ return now + token.expires_in * 1000;
64
+ }
65
+ return jwtExpiresAt(token.access_token);
66
+ }
67
+ export function applyOAuthTokenToConfig(config, token, opts) {
68
+ const now = opts.now ?? Date.now();
69
+ const tokenType = normalizeTokenType(token.token_type);
70
+ const refreshToken = token.refresh_token || opts.refreshToken;
71
+ if (!config.headers)
72
+ config.headers = {};
73
+ config.headers.Authorization = `${tokenType} ${token.access_token}`;
74
+ if (!refreshToken) {
75
+ delete config.oauth;
76
+ return;
77
+ }
78
+ config.oauth = {
79
+ tokenEndpoint: opts.tokenEndpoint,
80
+ clientId: opts.clientId,
81
+ clientSecret: opts.clientSecret,
82
+ refreshToken,
83
+ resource: opts.resource,
84
+ scope: token.scope || opts.scope,
85
+ tokenType,
86
+ accessTokenExpiresAt: inferAccessTokenExpiresAt(token, now),
87
+ updatedAt: now,
88
+ };
43
89
  }
44
90
  export function defaultCoreURL(mcpURL) {
45
91
  const u = new URL(mcpURL);
@@ -57,7 +103,6 @@ export function defaultCoreURL(mcpURL) {
57
103
  export function resolveConfig() {
58
104
  const cfg = loadConfig() || {};
59
105
  const url = process.env.TYC_MCP_ENDPOINT || cfg.url || DEFAULT_MCP_URL;
60
- const transport = normalizeTransport(process.env.TYC_TRANSPORT || cfg.transport);
61
106
  const coreUrl = process.env.TYC_CORE_ENDPOINT || cfg.coreUrl || defaultCoreURL(url);
62
107
  const headers = { ...(cfg.headers || {}) };
63
108
  if (!headers.Authorization && process.env.TYC_AUTHORIZATION) {
@@ -67,5 +112,60 @@ export function resolveConfig() {
67
112
  console.error("未配置 Authorization。请先运行:tyc init --authorization YOUR_API_KEY");
68
113
  process.exit(1);
69
114
  }
70
- return { url, coreUrl, transport, headers };
115
+ return { url, coreUrl, headers, oauth: cfg.oauth ? { ...cfg.oauth } : undefined };
116
+ }
117
+ function hasBearerAuthorization(config) {
118
+ return /^Bearer\s+\S+/i.test(config.headers.Authorization || "");
119
+ }
120
+ function shouldRefreshOAuth(oauth, opts) {
121
+ if (opts.force)
122
+ return true;
123
+ if (!oauth.accessTokenExpiresAt)
124
+ return false;
125
+ const skewMs = opts.skewMs ?? OAUTH_REFRESH_SKEW_MS;
126
+ return oauth.accessTokenExpiresAt <= Date.now() + skewMs;
127
+ }
128
+ export async function refreshResolvedConfigOAuth(resolved, opts = {}) {
129
+ const stored = loadConfig() || {};
130
+ const oauth = stored.oauth;
131
+ if (!oauth?.refreshToken || !oauth.tokenEndpoint || !oauth.clientId) {
132
+ return { config: resolved, refreshed: false };
133
+ }
134
+ if (!hasBearerAuthorization(resolved)) {
135
+ return { config: resolved, refreshed: false };
136
+ }
137
+ if (!shouldRefreshOAuth(oauth, opts)) {
138
+ return { config: resolved, refreshed: false };
139
+ }
140
+ let token;
141
+ try {
142
+ token = await exchangeRefreshToken({
143
+ tokenEndpoint: oauth.tokenEndpoint,
144
+ clientId: oauth.clientId,
145
+ clientSecret: oauth.clientSecret,
146
+ refreshToken: oauth.refreshToken,
147
+ resource: oauth.resource,
148
+ });
149
+ }
150
+ catch (err) {
151
+ const msg = err instanceof Error ? err.message : String(err);
152
+ throw new Error(`OAuth refresh token 刷新失败,请重新运行 tyc login:${msg}`);
153
+ }
154
+ applyOAuthTokenToConfig(stored, token, {
155
+ tokenEndpoint: oauth.tokenEndpoint,
156
+ clientId: oauth.clientId,
157
+ clientSecret: oauth.clientSecret,
158
+ refreshToken: oauth.refreshToken,
159
+ resource: oauth.resource,
160
+ scope: oauth.scope,
161
+ });
162
+ saveConfig(stored);
163
+ if (opts.verbose) {
164
+ console.error("> OAuth access token refreshed");
165
+ }
166
+ return { config: resolveConfig(), refreshed: true };
167
+ }
168
+ export async function resolveConfigWithFreshOAuth(opts = {}) {
169
+ const resolved = resolveConfig();
170
+ return refreshResolvedConfigOAuth(resolved, opts);
71
171
  }
@@ -72,6 +72,7 @@ export async function verifyCoreAuthEndpoint(cfg, verbose = false) {
72
72
  }
73
73
  export async function callCoreTool(cfg, name, args, opts) {
74
74
  const verbose = !!opts?.verbose;
75
+ const format = opts?.format || "json";
75
76
  const headers = {
76
77
  "Content-Type": "application/json",
77
78
  Accept: "application/json",
@@ -80,7 +81,7 @@ export async function callCoreTool(cfg, name, args, opts) {
80
81
  const body = {
81
82
  tool_name: name,
82
83
  arguments: args,
83
- format: "json",
84
+ format,
84
85
  };
85
86
  if (verbose) {
86
87
  const masked = {};
@@ -88,7 +89,6 @@ export async function callCoreTool(cfg, name, args, opts) {
88
89
  masked[k] = k.toLowerCase() === "authorization" ? maskToken(v) : v;
89
90
  }
90
91
  console.error(`> POST ${cfg.coreUrl}`);
91
- console.error(`> transport: core`);
92
92
  console.error(`> headers: ${JSON.stringify(masked)}`);
93
93
  console.error(`> body: ${JSON.stringify(body)}`);
94
94
  }