@rezti/dsh-rez-suite 0.1.25 → 0.1.27

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.
@@ -294,3 +294,85 @@
294
294
  font-weight: 600;
295
295
  color: var(--dsw-alias-label-primary);
296
296
  }
297
+
298
+ .upgradeLayer {
299
+ flex: none;
300
+ align-items: center;
301
+ width: 100%;
302
+ height: 42px;
303
+ margin: 8px 0 0;
304
+ display: flex;
305
+ position: relative;
306
+ }
307
+
308
+ .upgradeLayerRail {
309
+ width: 36px;
310
+ height: 36px;
311
+ margin: 0;
312
+ }
313
+
314
+ .upgradeButton {
315
+ width: calc(100% + 4px);
316
+ height: 42px;
317
+ color: var(--dsw-alias-label-primary);
318
+ cursor: pointer;
319
+ background: transparent;
320
+ border: none;
321
+ border-radius: 12px;
322
+ align-items: center;
323
+ gap: 8px;
324
+ margin: 0 -2px;
325
+ padding: 0 10px 0 8px;
326
+ font-family: inherit;
327
+ font-size: 14px;
328
+ display: inline-flex;
329
+ overflow: hidden;
330
+ }
331
+
332
+ .upgradeButton:hover,
333
+ .upgradeButton[data-outdated] {
334
+ background: var(--dsw-alias-interactive-bg-hover);
335
+ }
336
+
337
+ .upgradeButton:disabled {
338
+ cursor: progress;
339
+ opacity: 0.72;
340
+ }
341
+
342
+ .upgradeButton:focus-visible {
343
+ outline: 2px solid var(--dsw-alias-brand-primary);
344
+ outline-offset: -2px;
345
+ }
346
+
347
+ .upgradeLayerRail .upgradeButton {
348
+ border-radius: 50%;
349
+ justify-content: center;
350
+ gap: 0;
351
+ width: 36px;
352
+ height: 36px;
353
+ padding: 0;
354
+ }
355
+
356
+ .upgradeLabel {
357
+ text-overflow: ellipsis;
358
+ white-space: nowrap;
359
+ min-width: 0;
360
+ overflow: hidden;
361
+ }
362
+
363
+ .upgradeMeta {
364
+ color: var(--dsw-alias-label-tertiary);
365
+ font-variant-numeric: tabular-nums;
366
+ flex: none;
367
+ margin-left: auto;
368
+ font-size: 12px;
369
+ line-height: 16px;
370
+ }
371
+
372
+ .upgradeDot {
373
+ width: 7px;
374
+ height: 7px;
375
+ border-radius: 50%;
376
+ background: var(--dsw-alias-brand-primary);
377
+ flex: none;
378
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Sidebar footer action: upgrade @rezti/dsh-rez-suite without a terminal.
3
+ * Host code still needs a process respawn; the button does that after pnpm add.
4
+ */
5
+
6
+ import { useEffect, useMemo, useState } from 'react'
7
+ import { RezApi } from './api.ts'
8
+ import { tt } from './panel/helpers.ts'
9
+ import css from './panel/panel.module.css'
10
+ import type { RezUpdateStatus } from '../protocol.ts'
11
+
12
+ function UpgradeIcon() {
13
+ return (
14
+ <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
15
+ <path
16
+ d="M8 12.5V4.8M8 4.8 4.8 8M8 4.8 11.2 8M3 13.2h10"
17
+ fill="none"
18
+ stroke="currentColor"
19
+ strokeWidth="1.4"
20
+ strokeLinecap="round"
21
+ strokeLinejoin="round"
22
+ />
23
+ </svg>
24
+ )
25
+ }
26
+
27
+ async function waitUntilHostBack(api: RezApi, timeoutMs = 60_000): Promise<void> {
28
+ const deadline = Date.now() + timeoutMs
29
+ await new Promise((resolve) => setTimeout(resolve, 800))
30
+ while (Date.now() < deadline) {
31
+ try {
32
+ await api.updateStatus()
33
+ return
34
+ } catch {
35
+ await new Promise((resolve) => setTimeout(resolve, 700))
36
+ }
37
+ }
38
+ throw new Error(tt('upgrade.reloadTimeout'))
39
+ }
40
+
41
+ export function UpgradeButton({ wide }: { wide: boolean }) {
42
+ const api = useMemo(() => new RezApi(), [])
43
+ const [info, setInfo] = useState<RezUpdateStatus>()
44
+ const [busy, setBusy] = useState(false)
45
+ const [note, setNote] = useState('')
46
+
47
+ useEffect(() => {
48
+ let cancelled = false
49
+ void api.updateStatus().then((value) => {
50
+ if (!cancelled) setInfo(value)
51
+ }).catch((error: unknown) => {
52
+ if (!cancelled) setNote(error instanceof Error ? error.message : String(error))
53
+ })
54
+ return () => { cancelled = true }
55
+ }, [api])
56
+
57
+ const label = busy
58
+ ? tt('upgrade.running')
59
+ : info?.outdated === true
60
+ ? tt('upgrade.label')
61
+ : tt('upgrade.current')
62
+
63
+ const meta = note !== ''
64
+ ? note
65
+ : info === undefined
66
+ ? ''
67
+ : info.outdated
68
+ ? info.latest
69
+ : info.installed
70
+
71
+ async function onClick(): Promise<void> {
72
+ if (busy) return
73
+ setBusy(true)
74
+ setNote('')
75
+ try {
76
+ const snapshot = info ?? await api.updateStatus()
77
+ setInfo(snapshot)
78
+ if (!snapshot.outdated) {
79
+ setNote(tt('upgrade.already', { version: snapshot.installed }))
80
+ return
81
+ }
82
+ const result = await api.applyUpdate()
83
+ setInfo(result)
84
+ if (result.restarting) {
85
+ setNote(tt('upgrade.reloading'))
86
+ await waitUntilHostBack(api)
87
+ window.location.reload()
88
+ return
89
+ }
90
+ setNote(tt('upgrade.already', { version: result.installed }))
91
+ } catch (error) {
92
+ setNote(error instanceof Error ? error.message : String(error))
93
+ } finally {
94
+ setBusy(false)
95
+ }
96
+ }
97
+
98
+ return (
99
+ <div className={wide ? css.upgradeLayer : `${css.upgradeLayer} ${css.upgradeLayerRail}`}>
100
+ <button
101
+ type="button"
102
+ className={css.upgradeButton}
103
+ data-outdated={info?.outdated === true ? '' : undefined}
104
+ aria-label={tt('upgrade.aria')}
105
+ disabled={busy}
106
+ onClick={() => { void onClick() }}
107
+ >
108
+ <UpgradeIcon />
109
+ {wide && <span className={css.upgradeLabel}>{label}</span>}
110
+ {wide && meta !== '' && <span className={css.upgradeMeta}>{meta}</span>}
111
+ {!wide && info?.outdated === true && <span className={css.upgradeDot} />}
112
+ </button>
113
+ </div>
114
+ )
115
+ }
package/src/index.ts CHANGED
@@ -28,6 +28,8 @@ import { TokenManager } from './token-manager.ts'
28
28
  import { rezConfigTool, rezStatusTool } from './tools.ts'
29
29
  import type { RezConfig } from './protocol.ts'
30
30
  import { workspaceGuidanceFor, mountRezWorkspaces } from './boss/mount.ts'
31
+ import { liveSelfUpdate } from './self-update.ts'
32
+ import { mountTianyanchaMcp } from './tianyancha.ts'
31
33
 
32
34
  export const name = 'rez-suite'
33
35
 
@@ -78,6 +80,10 @@ export const Config: z<RezConfig> = z.object({
78
80
  enabled: z.boolean().default(true),
79
81
  siteUrl: z.string().default(REZ_DEFAULT_CONNECTIONS.gsc.siteUrl),
80
82
  }).default(REZ_DEFAULT_CONNECTIONS.gsc),
83
+ tianyancha: z.object({
84
+ enabled: z.boolean().default(true),
85
+ url: z.string().default(REZ_DEFAULT_CONNECTIONS.tianyancha.url),
86
+ }).default(REZ_DEFAULT_CONNECTIONS.tianyancha),
81
87
  }).default(REZ_DEFAULT_CONNECTIONS),
82
88
  billing: z.object({
83
89
  inputCostPer1k: z.number().default(0.001),
@@ -127,6 +133,7 @@ function delegatedHost(ctx: Context, getConfig: () => RezConfig): McpHost {
127
133
  export function apply(ctx: Context, config?: RezConfig): void {
128
134
  let current: () => RezConfig = () => normalize(config)
129
135
  const syncWorkspaces = mountRezWorkspaces(ctx, () => current().role)
136
+ const syncTianyancha = mountTianyanchaMcp(ctx, () => current().role)
130
137
 
131
138
  const tokens = new TokenManager(joinTokenDb())
132
139
  const host = delegatedHost(ctx, () => current())
@@ -144,6 +151,7 @@ export function apply(ctx: Context, config?: RezConfig): void {
144
151
  saveConfig(value)
145
152
  current = () => value
146
153
  syncWorkspaces()
154
+ syncTianyancha()
147
155
  try {
148
156
  const settings = ctx.get('settings') as { update?: (namespace: unknown, value: unknown) => Promise<void> } | undefined
149
157
  if (settings?.update !== undefined) await settings.update(REZ_SETTINGS_NAMESPACE, value)
@@ -158,6 +166,7 @@ export function apply(ctx: Context, config?: RezConfig): void {
158
166
  const emit = ctx.emit as unknown as (event: string, value: string) => void
159
167
  emit('credentials/updated', ref)
160
168
  },
169
+ selfUpdate: liveSelfUpdate(),
161
170
  })
162
171
 
163
172
  let disposeRoutes: (() => void) | undefined
@@ -196,10 +205,12 @@ export function apply(ctx: Context, config?: RezConfig): void {
196
205
  current = source
197
206
  sync()
198
207
  syncWorkspaces()
208
+ syncTianyancha()
199
209
  },
200
210
  onChange: () => {
201
211
  sync()
202
212
  syncWorkspaces()
213
+ syncTianyancha()
203
214
  },
204
215
  })
205
216
 
@@ -9,7 +9,7 @@
9
9
  import { lastMcpStartError, REZ_CREDENTIAL_REFS, secretConfigured, type RezSystem } from '@rezti/dsh-rez-sso'
10
10
  import type { RezConfig, RezServerStatus, RezTestResult } from './protocol.ts'
11
11
 
12
- export const COMPOSED_SERVERS = ['odoo', 'nextcloud', 'wechat', 'homeassistant', 'tapd', 'gsc'] as const
12
+ export const COMPOSED_SERVERS = ['odoo', 'nextcloud', 'wechat', 'homeassistant', 'tapd', 'gsc', 'tianyancha'] as const
13
13
 
14
14
  export type ComposedServer = (typeof COMPOSED_SERVERS)[number]
15
15
 
@@ -102,7 +102,10 @@ export function observeServer(
102
102
  }
103
103
 
104
104
  export function observeComposedServers(input: ObserveInput): RezServerStatus[] {
105
- return COMPOSED_SERVERS.map(server => observeServer(server, input))
105
+ const servers = input.config.role === 'boss'
106
+ ? COMPOSED_SERVERS
107
+ : COMPOSED_SERVERS.filter((server) => server !== 'tianyancha')
108
+ return servers.map(server => observeServer(server, input))
106
109
  }
107
110
 
108
111
  export function testComposedServers(input: ObserveInput): RezTestResult[] {
package/src/presets.ts CHANGED
@@ -127,6 +127,7 @@ const COMPILED: Record<Exclude<RezRoleId, 'all' | 'boss'>, { server: RezServerId
127
127
 
128
128
  /** Whether one MCP tool may be registered for the selected role. */
129
129
  export function isToolAllowed(role: RezRoleId, server: string, rawName: string): boolean {
130
+ if (server === 'tianyancha') return role === 'boss'
130
131
  if (role === 'all' || role === 'boss') return true
131
132
  const rules = COMPILED[role]
132
133
  return rules.some(rule => rule.server === server && rule.test.test(rawName))
package/src/protocol.ts CHANGED
@@ -48,6 +48,7 @@ export interface RezConnections {
48
48
  homeassistant: { enabled: boolean; url: string }
49
49
  tapd: { enabled: boolean; workspaceId: string; nickName: string }
50
50
  gsc: { enabled: boolean; siteUrl: string }
51
+ tianyancha: { enabled: boolean; url: string }
51
52
  }
52
53
 
53
54
  export interface RezPublicConnection {
@@ -90,6 +91,7 @@ export interface RezPublicConfig {
90
91
  homeassistant: RezPublicConnection
91
92
  tapd: RezPublicConnection
92
93
  gsc: RezPublicConnection
94
+ tianyancha: RezPublicConnection
93
95
  }
94
96
  billing: { inputCostPer1k: number; outputCostPer1k: number; monthlyBudget: number }
95
97
  }
@@ -168,6 +170,18 @@ export interface ApiErrorBody {
168
170
  error: string
169
171
  }
170
172
 
173
+ /** Installed vs npm latest for the suite package. */
174
+ export interface RezUpdateStatus {
175
+ package: string
176
+ installed: string
177
+ latest: string
178
+ outdated: boolean
179
+ }
180
+
181
+ export interface RezUpdateApplyResult extends RezUpdateStatus {
182
+ restarting: boolean
183
+ }
184
+
171
185
  /** Route paths the client calls (shared literals). */
172
186
  export const REZ_API_BASE = '/api/dsh-rez-suite' as const
173
187
 
@@ -179,4 +193,5 @@ export const REZ_API = {
179
193
  auditReset: REZ_API_BASE + '/audit/reset',
180
194
  weixin: REZ_API_BASE + '/weixin',
181
195
  weixinVerify: REZ_API_BASE + '/weixin/verify',
196
+ update: REZ_API_BASE + '/update',
182
197
  } as const
package/src/routes.ts CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  writeManagedCredential,
14
14
  type RezSystem,
15
15
  } from '@rezti/dsh-rez-sso'
16
- import type { RezConfig, RezMcpServerSpec } from './protocol.ts'
16
+ import type { RezConfig, RezMcpServerSpec, RezUpdateApplyResult, RezUpdateStatus } from './protocol.ts'
17
17
  import { isRezRoleId, REZ_API } from './protocol.ts'
18
18
  import type { McpHost } from './mcp-host.ts'
19
19
  import type { TokenManager } from './token-manager.ts'
@@ -135,11 +135,16 @@ export interface RezRoutesDeps {
135
135
  host: McpHost
136
136
  tokens: TokenManager
137
137
  onCredentialWritten?: (ref: string) => void
138
+ selfUpdate?: {
139
+ status: () => Promise<RezUpdateStatus>
140
+ apply: () => Promise<RezUpdateApplyResult>
141
+ relaunch: () => void
142
+ }
138
143
  }
139
144
 
140
145
  /** Build every /api/dsh-rez-suite route (exact paths). */
141
146
  export function makeRoutes(deps: RezRoutesDeps): WebRoute[] {
142
- const { getConfig, updateConfig, host, tokens, onCredentialWritten } = deps
147
+ const { getConfig, updateConfig, host, tokens, onCredentialWritten, selfUpdate } = deps
143
148
 
144
149
  const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
145
150
  if (!isLoopbackRequest(req)) {
@@ -215,5 +220,28 @@ export function makeRoutes(deps: RezRoutesDeps): WebRoute[] {
215
220
  writeJson(res, 200, { ok: true })
216
221
  },
217
222
  },
223
+ {
224
+ kind: 'exact',
225
+ path: REZ_API.update,
226
+ handler: async (req, res) => {
227
+ const method = req.method === 'POST' ? 'POST' : 'GET'
228
+ if (!guard(req, res, method)) return
229
+ if (selfUpdate === undefined) {
230
+ writeJson(res, 501, { error: 'self-update is not available' })
231
+ return
232
+ }
233
+ try {
234
+ if (method === 'GET') {
235
+ writeJson(res, 200, await selfUpdate.status())
236
+ return
237
+ }
238
+ const result = await selfUpdate.apply()
239
+ writeJson(res, 200, result)
240
+ if (result.restarting) setTimeout(() => { selfUpdate.relaunch() }, 400)
241
+ } catch (error) {
242
+ writeJson(res, 502, { error: error instanceof Error ? error.message : String(error) })
243
+ }
244
+ },
245
+ },
218
246
  ]
219
247
  }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * In-place upgrade of @rezti/dsh-rez-suite inside the running dsh profile.
3
+ * pnpm add writes the new bits; Node cannot replace an already-imported host
4
+ * module, so we respawn the same argv after the HTTP response flushes.
5
+ */
6
+ import { spawn } from 'node:child_process'
7
+ import { existsSync, readFileSync } from 'node:fs'
8
+ import { dirname, join } from 'node:path'
9
+ import { fileURLToPath } from 'node:url'
10
+ import type { RezUpdateApplyResult, RezUpdateStatus } from './protocol.ts'
11
+
12
+ export const SUITE_PACKAGE = '@rezti/dsh-rez-suite'
13
+ const NPM_LATEST = 'https://registry.npmjs.org/@rezti/dsh-rez-suite/latest'
14
+ const PNPM_TIMEOUT_MS = 180_000
15
+
16
+ export function compareVersions(left: string, right: string): number {
17
+ const a = left.split('.').map((part) => Number.parseInt(part, 10) || 0)
18
+ const b = right.split('.').map((part) => Number.parseInt(part, 10) || 0)
19
+ const n = Math.max(a.length, b.length)
20
+ for (let i = 0; i < n; i += 1) {
21
+ const av = a[i] ?? 0
22
+ const bv = b[i] ?? 0
23
+ if (av > bv) return 1
24
+ if (av < bv) return -1
25
+ }
26
+ return 0
27
+ }
28
+
29
+ export function readInstalledVersion(packageDir: string): string {
30
+ const raw = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { version?: unknown }
31
+ if (typeof raw.version !== 'string' || raw.version === '') throw new Error('suite package.json missing version')
32
+ return raw.version
33
+ }
34
+
35
+ export function findProfileDir(startDir: string): string {
36
+ let dir = startDir
37
+ for (let i = 0; i < 12; i += 1) {
38
+ const manifest = join(dir, 'package.json')
39
+ if (existsSync(manifest)) {
40
+ try {
41
+ const raw = JSON.parse(readFileSync(manifest, 'utf8')) as { dsh?: { profile?: { bundles?: unknown } } }
42
+ if (Array.isArray(raw.dsh?.profile?.bundles)) return dir
43
+ } catch {
44
+ // keep walking
45
+ }
46
+ }
47
+ const parent = dirname(dir)
48
+ if (parent === dir) break
49
+ dir = parent
50
+ }
51
+ throw new Error('cannot find dsh profile directory (no package.json with dsh.profile.bundles)')
52
+ }
53
+
54
+ export function shellQuote(value: string): string {
55
+ return `'${value.replaceAll("'", `'\\''`)}'`
56
+ }
57
+
58
+ export function relaunchScript(argv: string[], parentPid: number): string {
59
+ return `while kill -0 ${parentPid} 2>/dev/null; do sleep 0.15; done; sleep 0.25; exec ${argv.map(shellQuote).join(' ')}`
60
+ }
61
+
62
+ export async function fetchLatestVersion(fetchImpl: typeof fetch = fetch): Promise<string> {
63
+ const response = await fetchImpl(NPM_LATEST, { headers: { accept: 'application/json' } })
64
+ if (!response.ok) throw new Error(`npm registry HTTP ${response.status}`)
65
+ const body = await response.json() as { version?: unknown }
66
+ if (typeof body.version !== 'string' || body.version === '') throw new Error('npm registry missing version')
67
+ return body.version
68
+ }
69
+
70
+ export function runPnpmAdd(cwd: string, spec: string, env: NodeJS.ProcessEnv = process.env): Promise<void> {
71
+ return new Promise((resolve, reject) => {
72
+ const child = spawn('pnpm', ['add', spec], { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] })
73
+ let stderr = ''
74
+ child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
75
+ const timer = setTimeout(() => {
76
+ child.kill('SIGTERM')
77
+ reject(new Error('pnpm add timed out after 3 minutes'))
78
+ }, PNPM_TIMEOUT_MS)
79
+ child.on('error', (error) => {
80
+ clearTimeout(timer)
81
+ reject(error)
82
+ })
83
+ child.on('close', (code) => {
84
+ clearTimeout(timer)
85
+ if (code === 0) resolve()
86
+ else reject(new Error(stderr.trim() || `pnpm add exited ${String(code)}`))
87
+ })
88
+ })
89
+ }
90
+
91
+ export function spawnRelaunch(argv: string[], parentPid: number, cwd: string, env: NodeJS.ProcessEnv = process.env): void {
92
+ const child = spawn('/bin/bash', ['-c', relaunchScript(argv, parentPid)], {
93
+ cwd,
94
+ env,
95
+ detached: true,
96
+ stdio: 'ignore',
97
+ })
98
+ child.unref()
99
+ }
100
+
101
+ export interface SelfUpdateIo {
102
+ packageDir: string
103
+ fetchLatest: () => Promise<string>
104
+ install: (profileDir: string, spec: string) => Promise<void>
105
+ relaunch: () => void
106
+ }
107
+
108
+ let inflight: Promise<unknown> | undefined
109
+
110
+ export function createSelfUpdate(io: SelfUpdateIo) {
111
+ const status = async (): Promise<RezUpdateStatus> => {
112
+ const installed = readInstalledVersion(io.packageDir)
113
+ const latest = await io.fetchLatest()
114
+ return {
115
+ package: SUITE_PACKAGE,
116
+ installed,
117
+ latest,
118
+ outdated: compareVersions(latest, installed) > 0,
119
+ }
120
+ }
121
+
122
+ const apply = async (): Promise<RezUpdateApplyResult> => {
123
+ if (inflight !== undefined) throw new Error('upgrade already running')
124
+ const work = (async () => {
125
+ const snapshot = await status()
126
+ if (!snapshot.outdated) {
127
+ return { ...snapshot, restarting: false }
128
+ }
129
+ const profileDir = findProfileDir(io.packageDir)
130
+ await io.install(profileDir, `${SUITE_PACKAGE}@${snapshot.latest}`)
131
+ return { ...snapshot, installed: snapshot.latest, outdated: false, restarting: true }
132
+ })()
133
+ inflight = work
134
+ try {
135
+ return await work
136
+ } finally {
137
+ inflight = undefined
138
+ }
139
+ }
140
+
141
+ return { status, apply, relaunch: io.relaunch }
142
+ }
143
+
144
+ export function liveSelfUpdate(): ReturnType<typeof createSelfUpdate> {
145
+ const packageDir = dirname(fileURLToPath(new URL('.', import.meta.url)))
146
+ return createSelfUpdate({
147
+ packageDir,
148
+ fetchLatest: () => fetchLatestVersion(),
149
+ install: (profileDir, spec) => runPnpmAdd(profileDir, spec),
150
+ relaunch: () => {
151
+ spawnRelaunch(process.argv, process.pid, process.cwd())
152
+ setTimeout(() => { process.exit(0) }, 50)
153
+ },
154
+ })
155
+ }
package/src/store.ts CHANGED
@@ -125,6 +125,12 @@ export function publicConfig(config: RezConfig): RezPublicConfig {
125
125
  secretConfigured: secretConfigured('gsc'),
126
126
  secretRef: REZ_CREDENTIAL_REFS.gsc,
127
127
  },
128
+ tianyancha: {
129
+ enabled: connections.tianyancha.enabled,
130
+ url: connections.tianyancha.url,
131
+ secretConfigured: secretConfigured('tianyancha'),
132
+ secretRef: REZ_CREDENTIAL_REFS.tianyancha,
133
+ },
128
134
  },
129
135
  billing: { ...config.billing },
130
136
  }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * TianyanCha official MCP for the 法务助理 workspace.
3
+ * Reuse https://mcp.tianyancha.com/mcp via dsh-mcp-client. Do not spawn a
4
+ * local 162-tool catalog — tools/list only exposes a few agent entrypoints.
5
+ * Start the client only when suite role is boss so other identities never
6
+ * see mcp__tianyancha__* in the prompt.
7
+ */
8
+ import type { Context } from '@deepseek-ai/cordis'
9
+ import { loadRezConnections, startMcpWhenSecret } from '@rezti/dsh-rez-sso'
10
+ import type { RezRoleId } from './protocol.ts'
11
+
12
+ export const TIANYANCHA_MCP_URL = 'https://mcp.tianyancha.com/mcp'
13
+
14
+ export function shouldStartTianyancha(role: RezRoleId): boolean {
15
+ return role === 'boss'
16
+ }
17
+
18
+ export function bearerAuthorization(secret: string): string {
19
+ const trimmed = secret.trim()
20
+ if (/^bearer\s+/i.test(trimmed)) return 'Bearer ' + trimmed.replace(/^bearer\s+/i, '')
21
+ return 'Bearer ' + trimmed
22
+ }
23
+
24
+ export function tianyanchaMcpConfig(secret: string): Record<string, unknown> {
25
+ const { url } = loadRezConnections().tianyancha
26
+ return {
27
+ serverName: 'tianyancha',
28
+ transport: 'streamable-http',
29
+ url: url.length > 0 ? url : TIANYANCHA_MCP_URL,
30
+ headers: { Authorization: bearerAuthorization(secret) },
31
+ failOnStartupError: false,
32
+ }
33
+ }
34
+
35
+ /** Start when role is boss; dispose the mcp-client fiber when the identity leaves. */
36
+ export function mountTianyanchaMcp(
37
+ ctx: Context,
38
+ getRole: () => RezRoleId,
39
+ start: typeof startMcpWhenSecret = startMcpWhenSecret,
40
+ ): () => void {
41
+ let session: (() => void) | undefined
42
+ let pending = false
43
+
44
+ const ensure = (): void => {
45
+ void (async () => {
46
+ const want = shouldStartTianyancha(getRole())
47
+ if (!want) {
48
+ session?.()
49
+ session = undefined
50
+ return
51
+ }
52
+ if (session !== undefined || pending) return
53
+ pending = true
54
+ let raced = false
55
+ try {
56
+ const dispose = await start(ctx, {
57
+ system: 'tianyancha',
58
+ label: '天眼查',
59
+ config: tianyanchaMcpConfig,
60
+ })
61
+ if (!shouldStartTianyancha(getRole())) {
62
+ raced = true
63
+ dispose()
64
+ return
65
+ }
66
+ session = dispose
67
+ } catch (error: unknown) {
68
+ const why = error instanceof Error ? error.message : String(error)
69
+ console.error('[dsh-rez-suite] tianyancha MCP start failed:', why)
70
+ } finally {
71
+ pending = false
72
+ if (raced && shouldStartTianyancha(getRole()) && session === undefined) ensure()
73
+ }
74
+ })()
75
+ }
76
+
77
+ ensure()
78
+ return ensure
79
+ }
@@ -3,4 +3,4 @@ name: staff-legal
3
3
  description: 法务助理。全员可问制度、整理风险;能看哪些合同跟当前 Odoo/Nextcloud 帐号。不下具约束力的法律结论。
4
4
  ---
5
5
 
6
- 完整规矩在工作区 `AGENTS.md`。先 `mcp__odoo__get_current_context`。不是律师。
6
+ 完整规矩在工作区 `AGENTS.md`。先 `mcp__odoo__get_current_context`。不是律师。有 `mcp__tianyancha__*` 才查工商(仅老板身份会挂上)。
@@ -19,6 +19,10 @@
19
19
 
20
20
  没有独立的法务 App 时,不要假装有「案件模块」。用订单、伙伴、Nextcloud 文档凑齐材料即可。
21
21
 
22
+ ## 天眼查(仅老板身份)
23
+
24
+ 出现 `mcp__tianyancha__*` 时,才用来查对方公司工商/司法。官方 `tools/list` 只有搜索、公司画像、`call_tool` 等入口,不要枚举 162 个工具。没有这些工具就当没开通,不要提天眼查。
25
+
22
26
  ## 每次回答
23
27
 
24
28
  1. **结论**(风险高/中/低或「缺材料」)