@bigbrain-work/mcp-connect 1.0.0

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.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # @bigbrain-work/mcp-connect
2
+
3
+ 石榴 AI MCP 一键配置工具。用于将石榴 AI 的远程 MCP 服务接入 Codex、Claude Code 或 Cursor。
4
+
5
+ ## 使用
6
+
7
+ 先在石榴 AI 页面复制当前账户的 API Key,然后执行对应命令:
8
+
9
+ ```bash
10
+ npx -y @bigbrain-work/mcp-connect --agent codex
11
+ npx -y @bigbrain-work/mcp-connect --agent claude
12
+ npx -y @bigbrain-work/mcp-connect --agent cursor
13
+ ```
14
+
15
+ 安装器会隐藏输入 API Key。配置完成后,重启对应客户端并检查连接:
16
+
17
+ ```bash
18
+ npx -y @bigbrain-work/mcp-connect status
19
+ ```
20
+
21
+ 如已设置 `SHILIU_AI_API_KEY` 环境变量,安装器会直接使用该变量,不再提示输入。
22
+
23
+ ## 安全说明
24
+
25
+ - npm 包源码中不包含任何用户 API Key。
26
+ - Codex、Claude Code 和 Cursor 的 MCP 配置只保存环境变量引用,不保存真实 API Key。
27
+ - Windows 上,安装器通过标准输入将 API Key 写入当前用户环境变量,避免进入终端历史和子进程命令行。
28
+ - macOS/Linux 上,API Key 保存在权限为 `0600` 的 `~/.config/shiliu-ai/env`,并由用户登录配置加载。
29
+ - `status` 命令只显示“已设置/未设置”,不会输出 API Key。
30
+
31
+ ## 服务信息
32
+
33
+ - MCP 地址:`https://api.bigbrain.work/shiliu/mcp`
34
+ - 认证方式:`Authorization: Bearer <API_KEY>`
35
+ - 配置名称:`shiliu_mcp`
36
+
37
+ ## 发布
38
+
39
+ 首次发布前,确认 npm 账号属于 `bigbrain-work` organization,并启用双重验证:
40
+
41
+ ```bash
42
+ npm login
43
+ npm whoami
44
+ npm pack --dry-run
45
+ npm publish --access public
46
+ ```
47
+
48
+ 后续版本先更新 `package.json` 版本号,再发布:
49
+
50
+ ```bash
51
+ npm version patch
52
+ npm publish --access public
53
+ ```
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runCli } from '../src/cli.js'
4
+
5
+ runCli().catch((error) => {
6
+ console.error(`安装失败:${error.message}`)
7
+ process.exitCode = 1
8
+ })
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@bigbrain-work/mcp-connect",
3
+ "version": "1.0.0",
4
+ "description": "石榴 AI MCP 一键配置工具,支持 Codex、Claude Code 和 Cursor。",
5
+ "type": "module",
6
+ "bin": {
7
+ "mcp-connect": "bin/mcp-connect.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "test": "node --test",
16
+ "pack:check": "npm pack --dry-run"
17
+ },
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "keywords": [
22
+ "mcp",
23
+ "codex",
24
+ "claude-code",
25
+ "cursor",
26
+ "shiliu-ai"
27
+ ],
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "license": "UNLICENSED"
32
+ }
@@ -0,0 +1,56 @@
1
+ import { parseArgs } from 'node:util'
2
+
3
+ import { SUPPORTED_AGENTS } from './constants.js'
4
+
5
+ export function parseCliArguments(args) {
6
+ const parsed = parseArgs({
7
+ args,
8
+ allowPositionals: true,
9
+ strict: true,
10
+ options: {
11
+ agent: {
12
+ type: 'string',
13
+ short: 'a',
14
+ },
15
+ 'dry-run': {
16
+ type: 'boolean',
17
+ default: false,
18
+ },
19
+ home: {
20
+ type: 'string',
21
+ },
22
+ help: {
23
+ type: 'boolean',
24
+ short: 'h',
25
+ default: false,
26
+ },
27
+ version: {
28
+ type: 'boolean',
29
+ short: 'v',
30
+ default: false,
31
+ },
32
+ },
33
+ })
34
+
35
+ const command = parsed.positionals[0] || 'install'
36
+ if (!['install', 'status'].includes(command)) {
37
+ throw new Error(`不支持的命令:${command}`)
38
+ }
39
+
40
+ const agent = parsed.values.agent?.toLowerCase()
41
+ if (agent && !SUPPORTED_AGENTS.includes(agent)) {
42
+ throw new Error(`不支持的客户端:${agent}。可选值:${SUPPORTED_AGENTS.join('、')}`)
43
+ }
44
+ if (command === 'install' && !agent && !parsed.values.help && !parsed.values.version) {
45
+ throw new Error('请通过 --agent 指定客户端:codex、claude 或 cursor')
46
+ }
47
+
48
+ return {
49
+ command,
50
+ agent,
51
+ dryRun: parsed.values['dry-run'],
52
+ home: parsed.values.home,
53
+ help: parsed.values.help,
54
+ version: parsed.values.version,
55
+ }
56
+ }
package/src/cli.js ADDED
@@ -0,0 +1,68 @@
1
+ import os from 'node:os'
2
+ import path from 'node:path'
3
+
4
+ import { parseCliArguments } from './arguments.js'
5
+ import { configureAgent } from './configurators.js'
6
+ import {
7
+ API_KEY_ENV,
8
+ MCP_URL,
9
+ PACKAGE_NAME,
10
+ PACKAGE_VERSION,
11
+ SUPPORTED_AGENTS,
12
+ } from './constants.js'
13
+ import { persistApiKey, resolveApiKey } from './credentials.js'
14
+ import { printStatus } from './status.js'
15
+
16
+ function printHelp() {
17
+ console.log(`${PACKAGE_NAME} ${PACKAGE_VERSION}
18
+
19
+ 用法:
20
+ mcp-connect --agent <${SUPPORTED_AGENTS.join('|')}>
21
+ mcp-connect status
22
+
23
+ 选项:
24
+ -a, --agent <name> 配置指定客户端
25
+ --dry-run 只显示将执行的操作
26
+ --home <path> 指定用户目录(主要用于测试)
27
+ -h, --help 显示帮助
28
+ -v, --version 显示版本
29
+
30
+ 认证:
31
+ 安装器优先读取 ${API_KEY_ENV};未设置时会隐藏提示输入。
32
+ 真实 API Key 不会写入生成的 MCP 配置。`)
33
+ }
34
+
35
+ export async function runCli(argv = process.argv.slice(2)) {
36
+ const options = parseCliArguments(argv)
37
+ if (options.help) {
38
+ printHelp()
39
+ return
40
+ }
41
+ if (options.version) {
42
+ console.log(PACKAGE_VERSION)
43
+ return
44
+ }
45
+
46
+ const home = path.resolve(options.home || os.homedir())
47
+ if (options.command === 'status') {
48
+ const result = await printStatus({ home })
49
+ if (!result.remote.ok) {
50
+ process.exitCode = 1
51
+ }
52
+ return
53
+ }
54
+
55
+ const apiKey = await resolveApiKey()
56
+ await persistApiKey(apiKey, {
57
+ dryRun: options.dryRun,
58
+ home,
59
+ })
60
+ await configureAgent(options.agent, {
61
+ dryRun: options.dryRun,
62
+ home,
63
+ })
64
+
65
+ console.log(options.dryRun
66
+ ? `检查完成:将为 ${options.agent} 配置 ${MCP_URL},未写入任何文件。`
67
+ : `配置完成:${options.agent} 已接入石榴 AI MCP。请重启客户端后运行 ${PACKAGE_NAME} status 检查连接。`)
68
+ }
@@ -0,0 +1,152 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { spawnSync } from 'node:child_process'
4
+
5
+ import { API_KEY_ENV, MCP_URL, SERVER_NAME } from './constants.js'
6
+
7
+ export function codexArguments() {
8
+ return [
9
+ 'mcp',
10
+ 'add',
11
+ SERVER_NAME,
12
+ '--url',
13
+ MCP_URL,
14
+ '--bearer-token-env-var',
15
+ API_KEY_ENV,
16
+ ]
17
+ }
18
+
19
+ export function claudeServerDefinition() {
20
+ return {
21
+ type: 'http',
22
+ url: MCP_URL,
23
+ headers: {
24
+ Authorization: `Bearer \${${API_KEY_ENV}}`,
25
+ },
26
+ }
27
+ }
28
+
29
+ export function cursorServerDefinition() {
30
+ return {
31
+ type: 'http',
32
+ url: MCP_URL,
33
+ headers: {
34
+ Authorization: `Bearer \${env:${API_KEY_ENV}}`,
35
+ },
36
+ }
37
+ }
38
+
39
+ function commandText(command, args) {
40
+ return [command, ...args].map((value) => (
41
+ /[\s"]/u.test(value) ? JSON.stringify(value) : value
42
+ )).join(' ')
43
+ }
44
+
45
+ function execute(command, args, { dryRun = false } = {}) {
46
+ if (dryRun) {
47
+ console.log(`[dry-run] ${commandText(command, args)}`)
48
+ return
49
+ }
50
+
51
+ const result = spawnSync(command, args, {
52
+ encoding: 'utf8',
53
+ windowsHide: true,
54
+ shell: false,
55
+ })
56
+ if (result.error) {
57
+ if (result.error.code === 'ENOENT') {
58
+ throw new Error(`未找到 ${command},请先安装对应客户端`)
59
+ }
60
+ throw result.error
61
+ }
62
+ if (result.status !== 0) {
63
+ const detail = `${result.stderr || ''}\n${result.stdout || ''}`.trim()
64
+ const error = new Error(detail || `${command} 返回退出码 ${result.status}`)
65
+ error.commandOutput = detail
66
+ throw error
67
+ }
68
+ }
69
+
70
+ function isDuplicateError(error) {
71
+ return /already|exist|duplicate|已存在/iu.test(error.commandOutput || error.message)
72
+ }
73
+
74
+ export function configureCodex(options = {}) {
75
+ try {
76
+ execute('codex', codexArguments(), options)
77
+ } catch (error) {
78
+ if (!isDuplicateError(error)) {
79
+ throw error
80
+ }
81
+ execute('codex', ['mcp', 'remove', SERVER_NAME], options)
82
+ execute('codex', codexArguments(), options)
83
+ }
84
+ }
85
+
86
+ export function configureClaude(options = {}) {
87
+ const addArgs = [
88
+ 'mcp',
89
+ 'add-json',
90
+ '--scope',
91
+ 'user',
92
+ SERVER_NAME,
93
+ JSON.stringify(claudeServerDefinition()),
94
+ ]
95
+ try {
96
+ execute('claude', addArgs, options)
97
+ } catch (error) {
98
+ if (!isDuplicateError(error)) {
99
+ throw error
100
+ }
101
+ execute('claude', ['mcp', 'remove', '--scope', 'user', SERVER_NAME], options)
102
+ execute('claude', addArgs, options)
103
+ }
104
+ }
105
+
106
+ export async function configureCursor({ home, dryRun = false } = {}) {
107
+ const configPath = path.join(home, '.cursor', 'mcp.json')
108
+ if (dryRun) {
109
+ console.log(`[dry-run] 更新 ${configPath} 中的 ${SERVER_NAME},认证头使用 ${API_KEY_ENV} 环境变量`)
110
+ return configPath
111
+ }
112
+
113
+ let config = {}
114
+ try {
115
+ const source = await readFile(configPath, 'utf8')
116
+ config = JSON.parse(source)
117
+ } catch (error) {
118
+ if (error.code !== 'ENOENT') {
119
+ if (error instanceof SyntaxError) {
120
+ throw new Error(`${configPath} 不是有效 JSON,未做任何修改`)
121
+ }
122
+ throw error
123
+ }
124
+ }
125
+
126
+ config.mcpServers = {
127
+ ...(config.mcpServers || {}),
128
+ [SERVER_NAME]: cursorServerDefinition(),
129
+ }
130
+
131
+ await mkdir(path.dirname(configPath), { recursive: true })
132
+ const tempPath = `${configPath}.${process.pid}.tmp`
133
+ await writeFile(tempPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
134
+ await rename(tempPath, configPath)
135
+ return configPath
136
+ }
137
+
138
+ export async function configureAgent(agent, options) {
139
+ if (agent === 'codex') {
140
+ configureCodex(options)
141
+ return
142
+ }
143
+ if (agent === 'claude') {
144
+ configureClaude(options)
145
+ return
146
+ }
147
+ if (agent === 'cursor') {
148
+ await configureCursor(options)
149
+ return
150
+ }
151
+ throw new Error(`不支持的客户端:${agent}`)
152
+ }
@@ -0,0 +1,6 @@
1
+ export const PACKAGE_NAME = '@bigbrain-work/mcp-connect'
2
+ export const PACKAGE_VERSION = '1.0.0'
3
+ export const SERVER_NAME = 'shiliu_mcp'
4
+ export const MCP_URL = 'https://api.bigbrain.work/shiliu/mcp'
5
+ export const API_KEY_ENV = 'SHILIU_AI_API_KEY'
6
+ export const SUPPORTED_AGENTS = ['codex', 'claude', 'cursor']
@@ -0,0 +1,198 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { spawnSync } from 'node:child_process'
3
+ import { readFileSync } from 'node:fs'
4
+ import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'
5
+ import path from 'node:path'
6
+
7
+ import { API_KEY_ENV } from './constants.js'
8
+
9
+ function validateApiKey(value) {
10
+ const apiKey = value?.trim()
11
+ if (!apiKey) {
12
+ throw new Error('API Key 不能为空')
13
+ }
14
+ if (/\s/.test(apiKey)) {
15
+ throw new Error('API Key 不能包含空白字符')
16
+ }
17
+ if (apiKey.length < 12) {
18
+ throw new Error('API Key 长度异常,请确认复制完整')
19
+ }
20
+ return apiKey
21
+ }
22
+
23
+ export async function readHiddenInput(promptText, input = process.stdin, output = process.stdout) {
24
+ if (!input.isTTY || typeof input.setRawMode !== 'function') {
25
+ throw new Error(`当前终端不支持隐藏输入,请先设置 ${API_KEY_ENV} 环境变量后重试`)
26
+ }
27
+
28
+ output.write(promptText)
29
+ input.setRawMode(true)
30
+ input.resume()
31
+ input.setEncoding('utf8')
32
+
33
+ return new Promise((resolve, reject) => {
34
+ let value = ''
35
+
36
+ const cleanup = () => {
37
+ input.off('data', onData)
38
+ input.setRawMode(false)
39
+ input.pause()
40
+ }
41
+
42
+ const onData = (chunk) => {
43
+ for (const character of chunk) {
44
+ if (character === '\u0003') {
45
+ cleanup()
46
+ output.write('\n')
47
+ reject(new Error('操作已取消'))
48
+ return
49
+ }
50
+ if (character === '\r' || character === '\n') {
51
+ cleanup()
52
+ output.write('\n')
53
+ resolve(value)
54
+ return
55
+ }
56
+ if (character === '\u007f' || character === '\b') {
57
+ value = value.slice(0, -1)
58
+ continue
59
+ }
60
+ value += character
61
+ }
62
+ }
63
+
64
+ input.on('data', onData)
65
+ })
66
+ }
67
+
68
+ export async function resolveApiKey({ env = process.env, prompt = readHiddenInput } = {}) {
69
+ if (env[API_KEY_ENV]) {
70
+ return validateApiKey(env[API_KEY_ENV])
71
+ }
72
+ return validateApiKey(await prompt('请输入石榴 AI API Key(输入内容不会显示):'))
73
+ }
74
+
75
+ function runPowerShellWithInput(script, input) {
76
+ return new Promise((resolve, reject) => {
77
+ const child = spawn('powershell.exe', [
78
+ '-NoLogo',
79
+ '-NoProfile',
80
+ '-NonInteractive',
81
+ '-Command',
82
+ script,
83
+ ], {
84
+ windowsHide: true,
85
+ stdio: ['pipe', 'pipe', 'pipe'],
86
+ })
87
+
88
+ let stderr = ''
89
+ child.stderr.setEncoding('utf8')
90
+ child.stderr.on('data', (chunk) => {
91
+ stderr += chunk
92
+ })
93
+ child.on('error', reject)
94
+ child.on('close', (code) => {
95
+ if (code === 0) {
96
+ resolve()
97
+ } else {
98
+ reject(new Error(stderr.trim() || `PowerShell 返回退出码 ${code}`))
99
+ }
100
+ })
101
+ child.stdin.end(input)
102
+ })
103
+ }
104
+
105
+ function shellQuote(value) {
106
+ return `'${value.replaceAll("'", "'\\''")}'`
107
+ }
108
+
109
+ async function persistPosixApiKey(apiKey, home, env) {
110
+ const configDir = path.join(home, '.config', 'shiliu-ai')
111
+ const envFile = path.join(configDir, 'env')
112
+ await mkdir(configDir, { recursive: true })
113
+ await writeFile(envFile, `export ${API_KEY_ENV}=${shellQuote(apiKey)}\n`, {
114
+ encoding: 'utf8',
115
+ mode: 0o600,
116
+ })
117
+ await chmod(envFile, 0o600)
118
+
119
+ const shellName = path.basename(env.SHELL || '')
120
+ const profileName = shellName === 'zsh'
121
+ ? '.zprofile'
122
+ : shellName === 'bash'
123
+ ? '.bash_profile'
124
+ : '.profile'
125
+ const profilePath = path.join(home, profileName)
126
+ const sourceLine = `. ${shellQuote(envFile)} # shiliu-ai\n`
127
+ let profile = ''
128
+ try {
129
+ profile = await readFile(profilePath, 'utf8')
130
+ } catch (error) {
131
+ if (error.code !== 'ENOENT') {
132
+ throw error
133
+ }
134
+ }
135
+ if (!profile.includes('# shiliu-ai')) {
136
+ const separator = profile && !profile.endsWith('\n') ? '\n' : ''
137
+ await writeFile(profilePath, `${profile}${separator}${sourceLine}`, 'utf8')
138
+ }
139
+ }
140
+
141
+ export async function persistApiKey(apiKey, {
142
+ dryRun = false,
143
+ home,
144
+ platform = process.platform,
145
+ env = process.env,
146
+ } = {}) {
147
+ if (dryRun) {
148
+ return
149
+ }
150
+
151
+ if (platform === 'win32') {
152
+ const script = `$value = [Console]::In.ReadToEnd(); [Environment]::SetEnvironmentVariable('${API_KEY_ENV}', $value, 'User')`
153
+ await runPowerShellWithInput(script, apiKey)
154
+ } else {
155
+ await persistPosixApiKey(apiKey, home, env)
156
+ }
157
+ process.env[API_KEY_ENV] = apiKey
158
+ }
159
+
160
+ export function readPersistedApiKey({
161
+ platform = process.platform,
162
+ env = process.env,
163
+ home,
164
+ } = {}) {
165
+ if (env[API_KEY_ENV]) {
166
+ return env[API_KEY_ENV]
167
+ }
168
+
169
+ if (platform === 'win32') {
170
+ const result = spawnSync('powershell.exe', [
171
+ '-NoLogo',
172
+ '-NoProfile',
173
+ '-NonInteractive',
174
+ '-Command',
175
+ `[Console]::Out.Write([Environment]::GetEnvironmentVariable('${API_KEY_ENV}', 'User'))`,
176
+ ], {
177
+ windowsHide: true,
178
+ encoding: 'utf8',
179
+ })
180
+ return result.status === 0 ? result.stdout.trim() : ''
181
+ }
182
+
183
+ try {
184
+ const content = readFileSync(path.join(home, '.config', 'shiliu-ai', 'env'), 'utf8')
185
+ const prefix = `export ${API_KEY_ENV}=`
186
+ const line = content.split(/\r?\n/u).find((item) => item.startsWith(prefix))
187
+ if (!line) {
188
+ return ''
189
+ }
190
+ const encodedValue = line.slice(prefix.length)
191
+ if (encodedValue.startsWith("'") && encodedValue.endsWith("'")) {
192
+ return encodedValue.slice(1, -1).replaceAll("'\\''", "'")
193
+ }
194
+ return encodedValue
195
+ } catch {
196
+ return ''
197
+ }
198
+ }
package/src/status.js ADDED
@@ -0,0 +1,94 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import { API_KEY_ENV, MCP_URL, SERVER_NAME } from './constants.js'
5
+ import { readPersistedApiKey } from './credentials.js'
6
+
7
+ async function readText(filePath) {
8
+ try {
9
+ return await readFile(filePath, 'utf8')
10
+ } catch (error) {
11
+ if (error.code === 'ENOENT') {
12
+ return ''
13
+ }
14
+ throw error
15
+ }
16
+ }
17
+
18
+ async function hasServerConfig(filePath) {
19
+ const content = await readText(filePath)
20
+ return content.includes(SERVER_NAME) && content.includes(MCP_URL)
21
+ }
22
+
23
+ export async function inspectLocalConfiguration(home) {
24
+ return {
25
+ codex: await hasServerConfig(path.join(home, '.codex', 'config.toml')),
26
+ claude: await hasServerConfig(path.join(home, '.claude.json')),
27
+ cursor: await hasServerConfig(path.join(home, '.cursor', 'mcp.json')),
28
+ }
29
+ }
30
+
31
+ export async function probeMcp(apiKey, fetchImpl = fetch) {
32
+ if (!apiKey) {
33
+ return {
34
+ ok: false,
35
+ detail: `${API_KEY_ENV} 尚未设置`,
36
+ }
37
+ }
38
+
39
+ try {
40
+ const response = await fetchImpl(MCP_URL, {
41
+ method: 'POST',
42
+ headers: {
43
+ Accept: 'application/json, text/event-stream',
44
+ Authorization: `Bearer ${apiKey}`,
45
+ 'Content-Type': 'application/json',
46
+ },
47
+ body: JSON.stringify({
48
+ jsonrpc: '2.0',
49
+ id: 1,
50
+ method: 'initialize',
51
+ params: {
52
+ protocolVersion: '2025-03-26',
53
+ capabilities: {},
54
+ clientInfo: {
55
+ name: 'bigbrain-work-mcp-connect',
56
+ version: '1.0.0',
57
+ },
58
+ },
59
+ }),
60
+ signal: AbortSignal.timeout(10000),
61
+ })
62
+ return {
63
+ ok: response.ok,
64
+ detail: response.ok ? `HTTP ${response.status}` : `HTTP ${response.status},请检查 API Key`,
65
+ }
66
+ } catch (error) {
67
+ return {
68
+ ok: false,
69
+ detail: `连接失败:${error.message}`,
70
+ }
71
+ }
72
+ }
73
+
74
+ function mark(ok) {
75
+ return ok ? '✓' : '○'
76
+ }
77
+
78
+ export async function printStatus({ home, platform = process.platform, env = process.env } = {}) {
79
+ const apiKey = readPersistedApiKey({ platform, env, home })
80
+ const configs = await inspectLocalConfiguration(home)
81
+ const remote = await probeMcp(apiKey)
82
+
83
+ console.log(`API Key 环境变量 ${mark(Boolean(apiKey))} ${apiKey ? '已设置' : '未设置'}`)
84
+ console.log(`Codex 配置 ${mark(configs.codex)} ${configs.codex ? '已写入' : '未发现'}`)
85
+ console.log(`Claude Code 配置 ${mark(configs.claude)} ${configs.claude ? '已写入' : '未发现'}`)
86
+ console.log(`Cursor 配置 ${mark(configs.cursor)} ${configs.cursor ? '已写入' : '未发现'}`)
87
+ console.log(`石榴 AI MCP ${mark(remote.ok)} ${remote.detail}`)
88
+
89
+ return {
90
+ apiKey: Boolean(apiKey),
91
+ configs,
92
+ remote,
93
+ }
94
+ }