tyc-cli 0.3.5 → 0.3.6

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, 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,67 @@ 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 buildLoopbackRedirectUri(host, port, path) {
20
+ if (port === 0) {
21
+ throw new Error("--no-block 不能与 --redirect-port 0 同时使用,请指定固定回调端口。");
22
+ }
23
+ return `http://${host}:${port}${path}`;
24
+ }
25
+ function pickCallbackToken(opts) {
26
+ if (opts.callbackToken && opts.token) {
27
+ throw new Error("--callback-token 和 --token 只能指定一个。");
28
+ }
29
+ return opts.callbackToken || opts.token;
30
+ }
31
+ function saveOAuthTokenConfig(mcpURL, token, oauth, verbose) {
32
+ const cfg = loadConfig() || {};
33
+ cfg.url = mcpURL;
34
+ delete cfg.transport;
35
+ cfg.coreUrl = defaultCoreURL(mcpURL);
36
+ applyOAuthTokenToConfig(cfg, token, oauth);
37
+ saveConfig(cfg);
38
+ if (verbose) {
39
+ console.error(`> OAuth login state saved to ~/.tyc/config.json (url=${cfg.url})`);
40
+ }
41
+ }
42
+ async function verifySavedOAuthLogin(verify, verbose) {
43
+ if (!verify) {
44
+ printLoginSuccessBanner();
45
+ return;
46
+ }
47
+ const resolved = resolveConfig();
48
+ await verifyCoreAuthEndpoint(resolved, verbose);
49
+ if (verbose) {
50
+ console.error("> Shared Core auth check passed");
51
+ }
52
+ printLoginSuccessBanner();
53
+ }
54
+ async function completePendingOAuthLogin(pending, callbackToken, verify, verbose) {
55
+ const code = parseAuthorizationCallbackCode(callbackToken, pending.state);
56
+ if (verbose) {
57
+ console.error("> authorization code provided; exchanging access token");
58
+ }
59
+ const token = await exchangeAuthorizationCode({
60
+ tokenEndpoint: pending.tokenEndpoint,
61
+ clientId: pending.clientId,
62
+ clientSecret: pending.clientSecret,
63
+ code,
64
+ redirectUri: pending.redirectUri,
65
+ codeVerifier: pending.codeVerifier,
66
+ resource: pending.resource,
67
+ });
68
+ saveOAuthTokenConfig(pending.mcpURL, token, {
69
+ tokenEndpoint: pending.tokenEndpoint,
70
+ clientId: pending.clientId,
71
+ clientSecret: pending.clientSecret,
72
+ resource: pending.resource,
73
+ }, verbose);
74
+ clearPendingOAuthLogin(verbose);
75
+ await verifySavedOAuthLogin(verify, verbose);
20
76
  }
21
77
  export function registerLoginCommand(program) {
22
78
  program
@@ -34,9 +90,29 @@ export function registerLoginCommand(program) {
34
90
  .option("--redirect-host <host>", "本地回调 host(默认 localhost)")
35
91
  .option("--redirect-port <port>", "本地回调端口(默认 7078)")
36
92
  .option("--callback-path <path>", "本地回调路径(默认 /oauth/callback)")
93
+ .option("--callback-token <code-or-url>", "继续非阻塞登录:传入授权回调 code 或包含 code 的完整 callback URL")
94
+ .option("--token <code-or-url>", "同 --callback-token(兼容别名;仍需传授权回调 code)")
37
95
  .option("--no-open", "不自动打开浏览器,只打印授权 URL")
38
- .option("--no-verify", "保存 token 后跳过 initialize 连通性校验")
96
+ .option("--no-block", "配合 --no-open:打印授权 URL 并保存 pending 状态后立即退出")
97
+ .option("--no-verify", "保存 token 后跳过 shared core 鉴权校验")
39
98
  .action(async (opts) => {
99
+ const verbose = !!program.opts().verbose;
100
+ const callbackToken = pickCallbackToken(opts);
101
+ if (callbackToken) {
102
+ const pending = loadPendingOAuthLogin();
103
+ if (!pending) {
104
+ throw new Error("未找到待完成的 OAuth 登录状态。请先运行 tyc login --no-open --no-block。");
105
+ }
106
+ if (verbose) {
107
+ console.error(`> loaded pending OAuth login from ~/.tyc/oauth_pending.json (url=${pending.mcpURL})`);
108
+ }
109
+ await completePendingOAuthLogin(pending, callbackToken, opts.verify !== false && pending.verify !== false, verbose);
110
+ return;
111
+ }
112
+ const noBlock = opts.block === false;
113
+ if (noBlock && opts.open !== false) {
114
+ throw new Error("--no-block 需要与 --no-open 一起使用。");
115
+ }
40
116
  const current = loadConfig() || {};
41
117
  const mcpURL = opts.url || process.env.TYC_MCP_ENDPOINT || current.url || DEFAULT_MCP_URL;
42
118
  const knownDefaults = knownOAuthDefaultsForMcpURL(mcpURL);
@@ -45,8 +121,7 @@ export function registerLoginCommand(program) {
45
121
  const scope = opts.scope || "mcp:tools.call";
46
122
  const redirectHost = opts.redirectHost || "localhost";
47
123
  const redirectPort = parsePort(opts.redirectPort || "7078");
48
- const callbackPath = opts.callbackPath || "/oauth/callback";
49
- const verbose = !!program.opts().verbose;
124
+ const callbackPath = normalizeCallbackPath(opts.callbackPath);
50
125
  let issuer = opts.issuer || "";
51
126
  let resource = opts.resource || "";
52
127
  if (!opts.issuer || !opts.resource) {
@@ -108,16 +183,19 @@ export function registerLoginCommand(program) {
108
183
  }
109
184
  const state = randomState();
110
185
  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
- });
186
+ let callback;
187
+ const redirectUri = noBlock
188
+ ? buildLoopbackRedirectUri(redirectHost, redirectPort, callbackPath)
189
+ : (callback = await startCallbackServer({
190
+ host: redirectHost,
191
+ port: redirectPort,
192
+ path: callbackPath,
193
+ state,
194
+ timeoutMs: 5 * 60 * 1000,
195
+ })).redirectUri;
118
196
  if (!clientId) {
119
197
  if (!endpoints.registrationEndpoint) {
120
- callback.close();
198
+ callback?.close();
121
199
  throw new Error("OAuth metadata 未提供 registration_endpoint,请用 --client-id 指定预注册客户端。");
122
200
  }
123
201
  if (verbose) {
@@ -128,12 +206,12 @@ export function registerLoginCommand(program) {
128
206
  registeredClient = await registerDynamicOAuthClient({
129
207
  registrationEndpoint: endpoints.registrationEndpoint,
130
208
  clientName: "tyc-cli",
131
- redirectUris: [callback.redirectUri],
209
+ redirectUris: [redirectUri],
132
210
  tokenEndpointAuthMethod: "none",
133
211
  });
134
212
  }
135
213
  catch (err) {
136
- callback.close();
214
+ callback?.close();
137
215
  throw err;
138
216
  }
139
217
  clientId = registeredClient.clientId;
@@ -145,14 +223,36 @@ export function registerLoginCommand(program) {
145
223
  const authorizationURL = buildAuthorizationURL({
146
224
  authorizationEndpoint: endpoints.authorizationEndpoint,
147
225
  clientId,
148
- redirectUri: callback.redirectUri,
226
+ redirectUri,
149
227
  scope,
150
228
  state,
151
229
  codeChallenge: pkce.codeChallenge,
152
230
  resource,
153
231
  });
154
232
  if (verbose) {
155
- console.error(`> OAuth callback: ${callback.redirectUri}`);
233
+ console.error(`> OAuth callback: ${redirectUri}`);
234
+ }
235
+ if (noBlock) {
236
+ savePendingOAuthLogin({
237
+ version: 1,
238
+ createdAt: Date.now(),
239
+ mcpURL,
240
+ tokenEndpoint: endpoints.tokenEndpoint,
241
+ clientId,
242
+ clientSecret,
243
+ redirectUri,
244
+ codeVerifier: pkce.codeVerifier,
245
+ resource,
246
+ state,
247
+ verify: opts.verify !== false,
248
+ });
249
+ console.error("请在浏览器打开以下 URL 完成登录:");
250
+ console.error(authorizationURL);
251
+ console.error("");
252
+ console.error("完成授权后,复制回调 URL 中的 code 参数,然后运行:");
253
+ console.error("tyc login --callback-token <code_or_callback_url>");
254
+ console.error("也可以传完整 callback URL:tyc login --callback-token \"http://localhost:7078/oauth/callback?code=...&state=...\"");
255
+ return;
156
256
  }
157
257
  if (opts.open === false) {
158
258
  console.error("请在浏览器打开以下 URL 完成登录:");
@@ -162,6 +262,9 @@ export function registerLoginCommand(program) {
162
262
  console.error("正在打开浏览器完成 OAuth 登录...");
163
263
  openBrowser(authorizationURL);
164
264
  }
265
+ if (!callback) {
266
+ throw new Error("OAuth callback server not initialized");
267
+ }
165
268
  const code = await callback.waitForCode();
166
269
  if (verbose) {
167
270
  console.error("> authorization code received; exchanging access token");
@@ -171,40 +274,17 @@ export function registerLoginCommand(program) {
171
274
  clientId,
172
275
  clientSecret,
173
276
  code,
174
- redirectUri: callback.redirectUri,
277
+ redirectUri,
175
278
  codeVerifier: pkce.codeVerifier,
176
279
  resource,
177
280
  });
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();
281
+ saveOAuthTokenConfig(mcpURL, token, {
282
+ tokenEndpoint: endpoints.tokenEndpoint,
283
+ clientId,
284
+ clientSecret,
285
+ resource,
286
+ }, verbose);
287
+ clearPendingOAuthLogin(verbose);
288
+ await verifySavedOAuthLogin(opts.verify !== false, verbose);
209
289
  });
210
290
  }
@@ -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
  }