tovanix-mcp 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Astrenix Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,52 @@
1
- # Temporary Holding Version
1
+ # tovanix-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ One command to connect your AI tools to your [Tovanix](https://tovanix.com) account over MCP and install the Tovanix agent skill.
4
+
5
+ ```sh
6
+ npx tovanix-mcp
7
+ ```
8
+
9
+ It looks for the AI tools on your machine (Claude Code, Cursor, Codex, Gemini CLI, Qwen Code, Windsurf and more), asks for your MCP credential once, and then for each tool:
10
+
11
+ - registers the Tovanix MCP server — by running the tool's own `mcp add` command, or by merging one entry into its config file;
12
+ - installs the `tovanix-mcp` agent skill into the tool's skills folder.
13
+
14
+ Create a credential first at <https://tovanix.com/m/mcp>. A credential can do nothing until you switch on capabilities for it there.
15
+
16
+ ## Options
17
+
18
+ | Option | Meaning |
19
+ |---|---|
20
+ | `--tool <ids>` | Tools to set up, comma separated. Default: every tool found. |
21
+ | `--yes`, `-y` | Do not ask; set up every tool found. |
22
+ | `--credential <v>` | MCP credential. Prefer the `TOVANIX_MCP_CREDENTIAL` environment variable. |
23
+ | `--skill-only` / `--mcp-only` | Do only one of the two steps. |
24
+ | `--list` | Show every supported tool and whether it is set up automatically. |
25
+ | `--dry-run` | Show what would change without changing anything. |
26
+ | `--lang <en\|zh>` | Language of the output. |
27
+
28
+ No Node.js? `curl -fsSL https://tovanix.com/install.sh | sh` installs the skill and registers the server with command-line tools.
29
+
30
+ ## What it changes, and what it does not
31
+
32
+ - Config files are **merged**, never replaced. The previous file is kept next to it as `<file>.tovanix-backup`.
33
+ - A config file that contains comments, or is not valid JSON, is left untouched; the block to add is printed instead.
34
+ - Tools that are configured inside their own app (Claude desktop, Dify, Coze, n8n), or through YAML, are not edited; you get a link to the steps.
35
+ - A project file that would hold your credential (for example `.trae/mcp.json`) is only written when you name that tool with `--tool`.
36
+ - New config files are created readable by you only.
37
+
38
+ ## Your credential
39
+
40
+ - It is sent to one place: the Tovanix MCP endpoint, to check that it works before anything is written.
41
+ - It is never printed, logged or stored by this installer outside the tool config you asked for.
42
+ - The installer has no dependencies and contains no platform code or secrets. The list of tools and the skill come from Tovanix's public endpoints (`/api/public/agent/*`).
43
+
44
+ If a credential leaks, disable or delete it at <https://tovanix.com/m/mcp>.
45
+
46
+ ## Requirements
47
+
48
+ Node.js 18 or newer.
49
+
50
+ ## License
51
+
52
+ MIT
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli.js'
3
+
4
+ main(process.argv.slice(2)).then(
5
+ (code) => { process.exitCode = code },
6
+ (err) => {
7
+ process.stderr.write(`tovanix-mcp: ${err?.message || err}\n`)
8
+ process.exitCode = 1
9
+ },
10
+ )
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "tovanix-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "One-command installer that connects your AI tools (Claude Code, Cursor, Codex, Gemini CLI and more) to your Tovanix account over MCP and installs the Tovanix agent skill.",
5
+ "type": "module",
6
+ "bin": {
7
+ "tovanix-mcp": "bin/tovanix-mcp.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "scripts": {
19
+ "test": "node --test test/*.test.js"
20
+ },
21
+ "keywords": [
22
+ "mcp",
23
+ "model-context-protocol",
24
+ "tovanix",
25
+ "claude",
26
+ "cursor",
27
+ "codex",
28
+ "agent-skills",
29
+ "ai-agent"
30
+ ],
31
+ "homepage": "https://tovanix.com/agent",
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/Astrenix/tovanix-agent.git",
36
+ "directory": "installer"
37
+ },
38
+ "bugs": {
39
+ "url": "https://github.com/Astrenix/tovanix-agent/issues"
40
+ },
41
+ "author": "Astrenix Inc."
42
+ }
package/src/cli.js ADDED
@@ -0,0 +1,281 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import readline from 'node:readline'
5
+ import { spawnSync } from 'node:child_process'
6
+ import { fileURLToPath } from 'node:url'
7
+ import {
8
+ PLACEHOLDER, SERVER_NAME, commandFor, configPath, installSkill, isCredential, isDetected,
9
+ planJson, planToml, shellWords, skillTarget, strategyOf, which, writeConfig,
10
+ } from './core.js'
11
+ import { pickLang, translator } from './messages.js'
12
+
13
+ const DEFAULT_BASE = 'https://tovanix.com'
14
+ const ENV_VAR = 'TOVANIX_MCP_CREDENTIAL'
15
+
16
+ export function version () {
17
+ const pkg = JSON.parse(fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'))
18
+ return pkg.version
19
+ }
20
+
21
+ export function parseArgs (argv) {
22
+ const o = { tools: [], yes: false, credential: '', skillOnly: false, mcpOnly: false, list: false, dryRun: false, base: '', lang: '', help: false, version: false }
23
+ const need = (i, name) => {
24
+ if (i + 1 >= argv.length) throw new Error(`${name} needs a value`)
25
+ return argv[i + 1]
26
+ }
27
+ for (let i = 0; i < argv.length; i++) {
28
+ const a = argv[i]
29
+ if (a === '--yes' || a === '-y') o.yes = true
30
+ else if (a === '--skill-only') o.skillOnly = true
31
+ else if (a === '--mcp-only') o.mcpOnly = true
32
+ else if (a === '--list') o.list = true
33
+ else if (a === '--dry-run') o.dryRun = true
34
+ else if (a === '--help' || a === '-h') o.help = true
35
+ else if (a === '--version' || a === '-v') o.version = true
36
+ else if (a === '--tool') o.tools.push(...need(i++, a).split(',').map((s) => s.trim()).filter(Boolean))
37
+ else if (a === '--credential') o.credential = need(i++, a)
38
+ else if (a === '--base') o.base = need(i++, a)
39
+ else if (a === '--lang') o.lang = need(i++, a)
40
+ else throw new Error(`unknown option: ${a}`)
41
+ }
42
+ if (o.skillOnly && o.mcpOnly) throw new Error('--skill-only and --mcp-only cannot be used together')
43
+ return o
44
+ }
45
+
46
+ // Only https sites, plus http for a machine-local address (development).
47
+ export function normalizeBase (v) {
48
+ const u = new URL(v || DEFAULT_BASE)
49
+ const local = u.hostname === 'localhost' || u.hostname === '127.0.0.1'
50
+ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && local)) throw new Error('--base must be an https URL')
51
+ return u.origin
52
+ }
53
+
54
+ async function getJSON (url) {
55
+ const res = await fetch(url, { headers: { Accept: 'application/json', 'User-Agent': `tovanix-mcp/${version()}` } })
56
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
57
+ const body = await res.json()
58
+ if (!body || body.code !== 0 || !body.data) throw new Error('unexpected response')
59
+ return body.data
60
+ }
61
+
62
+ // Ask the MCP server what this credential may use. 'rejected' only on a clear 401/403.
63
+ export async function checkCredential (endpoint, credential) {
64
+ try {
65
+ const res = await fetch(endpoint, {
66
+ method: 'POST',
67
+ headers: { Authorization: `Bearer ${credential}`, 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' },
68
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
69
+ })
70
+ if (res.status === 401 || res.status === 403) return { state: 'rejected' }
71
+ if (!res.ok) return { state: 'unknown', err: `HTTP ${res.status}` }
72
+ const body = await res.json()
73
+ const tools = body?.result?.tools
74
+ if (!Array.isArray(tools)) return { state: 'unknown', err: 'unexpected response' }
75
+ // feedback tools are always present; they are not something the user switched on
76
+ return { state: 'ok', count: tools.filter((t) => !/^tovanix_platform_feedback_/.test(t.name)).length }
77
+ } catch (e) {
78
+ return { state: 'unknown', err: e?.message || String(e) }
79
+ }
80
+ }
81
+
82
+ function ask (question, { hidden = false } = {}) {
83
+ return new Promise((resolve) => {
84
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true })
85
+ if (hidden) {
86
+ // print the question, then swallow what is typed
87
+ rl.output.write(question)
88
+ rl._writeToOutput = () => {}
89
+ rl.question('', (a) => { rl.close(); process.stdout.write('\n'); resolve(a.trim()) })
90
+ } else {
91
+ rl.question(question, (a) => { rl.close(); resolve(a.trim()) })
92
+ }
93
+ })
94
+ }
95
+
96
+ export function pickByNumbers (answer, items) {
97
+ if (answer === '') return items
98
+ const idx = answer.split(/[\s,,]+/).filter(Boolean).map((s) => Number(s))
99
+ if (idx.some((n) => !Number.isInteger(n) || n < 1 || n > items.length)) return null
100
+ return [...new Set(idx)].map((n) => items[n - 1])
101
+ }
102
+
103
+ const pad = (s, n) => s + ' '.repeat(Math.max(1, n - s.length))
104
+
105
+ export async function main (argv, io = {}) {
106
+ const out = io.out || ((s) => process.stdout.write(s + '\n'))
107
+ const env = io.env || process.env
108
+ const home = io.home || os.homedir()
109
+ const cwd = io.cwd || process.cwd()
110
+ const interactive = io.interactive ?? (process.stdin.isTTY && process.stdout.isTTY)
111
+ const place = { home, cwd, env }
112
+
113
+ let opts
114
+ try { opts = parseArgs(argv) } catch (e) { out(`tovanix-mcp: ${e.message}`); return 2 }
115
+ const t = translator(pickLang(opts.lang, env))
116
+ if (opts.version) { out(version()); return 0 }
117
+ let base
118
+ try { base = normalizeBase(opts.base || env.TOVANIX_BASE) } catch (e) { out(`tovanix-mcp: ${e.message}`); return 2 }
119
+ if (opts.help) { out(t('help', { base })); return 0 }
120
+
121
+ out(t('title'))
122
+ let data
123
+ try {
124
+ data = await getJSON(`${base}/api/public/agent/clients`)
125
+ } catch (e) {
126
+ out(t('fetchFailed', { base, err: e.message }))
127
+ return 1
128
+ }
129
+ const clients = data.clients || []
130
+
131
+ if (opts.list) {
132
+ out(t('listHead'))
133
+ const w = Math.max(...clients.map((c) => c.id.length)) + 2
134
+ for (const c of clients) out(` ${pad(c.id, w)}${pad(c.name, 28)}${strategyOf(c) === 'manual' ? t('listManual') : t('listAuto')}`)
135
+ return 0
136
+ }
137
+ if (opts.dryRun) out(t('dryRun'))
138
+
139
+ // ---- which tools
140
+ let chosen
141
+ if (opts.tools.length) {
142
+ chosen = []
143
+ for (const id of opts.tools) {
144
+ const c = clients.find((x) => x.id === id)
145
+ if (!c) { out(t('unknownTool', { id })); return 2 }
146
+ chosen.push(c)
147
+ }
148
+ } else {
149
+ // project-level tools are picked up only when the current folder already uses them
150
+ const found = clients.filter((c) => isDetected(c, place))
151
+ if (!found.length) { out(t('noneFound')); out(t('noneFoundHint')); return 1 }
152
+ out(t('found'))
153
+ found.forEach((c, i) => out(` ${i + 1}. ${c.name}`))
154
+ if (opts.yes || !interactive) {
155
+ if (!opts.yes) { out(t('needTty')); return 2 }
156
+ chosen = found
157
+ } else {
158
+ chosen = pickByNumbers(await ask(t('choose')), found)
159
+ if (!chosen) { out(t('badChoice')); return 2 }
160
+ }
161
+ }
162
+
163
+ // ---- credential (only tools that put it somewhere need it)
164
+ const wantMcp = !opts.skillOnly
165
+ const wantSkill = !opts.mcpOnly
166
+ let credential = ''
167
+ const needsCred = wantMcp && chosen.some((c) => c.mcp_snippet.includes(PLACEHOLDER) || strategyOf(c) === 'toml')
168
+ if (needsCred) {
169
+ credential = opts.credential || env[ENV_VAR] || ''
170
+ if (!credential && opts.dryRun) credential = PLACEHOLDER // a dry run changes nothing, so it needs no secret
171
+ if (!credential) {
172
+ if (!interactive) { out(t('needTty')); return 2 }
173
+ credential = await ask(t('askCredential', { base }), { hidden: true })
174
+ }
175
+ if (credential !== PLACEHOLDER) {
176
+ if (!isCredential(credential)) { out(t('badCredential')); return 2 }
177
+ out(t('checking'))
178
+ const chk = await (io.checkCredential || checkCredential)(data.endpoint, credential)
179
+ if (chk.state === 'rejected') { out(t('credRejected')); return 1 }
180
+ if (chk.state === 'ok') out(chk.count > 0 ? t('credOk', { n: chk.count }) : t('credOkNoAbility', { base }))
181
+ else out(t('credUnknown', { err: chk.err }))
182
+ }
183
+ }
184
+
185
+ // ---- skill files (fetched once, written per distinct folder)
186
+ let skill = null
187
+ if (wantSkill && chosen.some((c) => c.skill_dir)) {
188
+ try {
189
+ skill = await getJSON(`${base}/api/public/agent/skill.json`)
190
+ } catch (e) {
191
+ out(t('fetchFailed', { base, err: e.message }))
192
+ return 1
193
+ }
194
+ }
195
+
196
+ const run = io.spawn || ((bin, args) => spawnSync(bin, args, { encoding: 'utf8', shell: process.platform === 'win32' }))
197
+ const doneFiles = new Set()
198
+ const doneSkillDirs = new Set()
199
+ let changed = 0
200
+ const would = opts.dryRun ? `${t('would')}: ` : ''
201
+
202
+ for (const c of chosen) {
203
+ out('')
204
+ out(`${c.name}`)
205
+ const strat = strategyOf(c)
206
+
207
+ if (wantMcp) {
208
+ if (strat === 'shell') {
209
+ const words = commandFor(c, credential)
210
+ const bin = words[0]
211
+ const shown = shellWords(c.mcp_snippet).join(' ')
212
+ if (!which(bin, place)) {
213
+ out(` ${t('cmdMissing', { bin })}`)
214
+ out(` ${c.mcp_snippet}`)
215
+ } else if (opts.dryRun) {
216
+ out(` ${would}${shown}`)
217
+ } else {
218
+ let r = run(bin, words.slice(1))
219
+ if (r.status !== 0 && /exist/i.test(`${r.stdout || ''}${r.stderr || ''}`)) {
220
+ // registered before: replace it so a new credential takes effect
221
+ run(bin, ['mcp', 'remove', SERVER_NAME])
222
+ r = run(bin, words.slice(1))
223
+ }
224
+ if (r.status === 0) { out(` ${t('mcpDone')}`); changed++ } else {
225
+ const err = String(r.stderr || r.stdout || r.error?.message || '').trim().split('\n')[0]
226
+ out(` ${t('cmdFailed', { err: err.split(credential).join('***') })}`)
227
+ }
228
+ }
229
+ } else if (strat === 'json' || strat === 'project' || strat === 'toml') {
230
+ const file = configPath(c, place)
231
+ if (doneFiles.has(file)) {
232
+ out(` ${t('mcpUnchanged')}`)
233
+ } else {
234
+ doneFiles.add(file)
235
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null
236
+ const plan = strat === 'toml' ? planToml(existing, c.mcp_snippet) : planJson(existing, c.mcp_snippet, credential)
237
+ const note = strat === 'project' ? ` ${t('projectNote')}` : ''
238
+ if (plan.action === 'manual') {
239
+ out(` ${t(plan.reason === 'comments' ? 'mcpManualComments' : 'mcpManualInvalid', { file })}`)
240
+ for (const l of c.mcp_snippet.split('\n')) out(` ${l}`)
241
+ } else if (plan.action === 'unchanged') {
242
+ out(` ${t('mcpUnchanged')}`)
243
+ } else if (opts.dryRun) {
244
+ out(` ${would}${t('mcpFile', { file })}${note}`)
245
+ } else {
246
+ const backup = writeConfig(file, plan.text)
247
+ out(` ${t('mcpFile', { file })}${note}`)
248
+ if (backup) out(` ${t('backup', { file: backup })}`)
249
+ if (strat === 'project' && c.credential_in_file) out(` ${t('gitignoreWarn', { file: c.mcp_file })}`)
250
+ changed++
251
+ }
252
+ if (strat === 'toml' && plan.action !== 'manual') {
253
+ out(` ${t('envHint')}`)
254
+ // shown with the placeholder: the credential was typed hidden, do not echo it back
255
+ out(` export ${data.credential_env_var || ENV_VAR}='${PLACEHOLDER}'`)
256
+ }
257
+ }
258
+ } else {
259
+ out(` ${t('mcpManual')}`)
260
+ out(` ${c.docs || `${base}/docs/mcp`}`)
261
+ }
262
+ }
263
+
264
+ if (wantSkill) {
265
+ const dir = skillTarget(c, place)
266
+ if (!dir) {
267
+ if (opts.skillOnly) out(` ${t('skillNone')}`)
268
+ } else if (doneSkillDirs.has(dir)) {
269
+ out(` ${t('skillDone', { dir: path.join(dir, skill.name) })}`)
270
+ } else {
271
+ doneSkillDirs.add(dir)
272
+ if (opts.dryRun) out(` ${would}${t('skillDone', { dir: path.join(dir, skill.name) })}`)
273
+ else { out(` ${t('skillDone', { dir: installSkill(dir, skill) })}`); changed++ }
274
+ }
275
+ }
276
+ }
277
+
278
+ out('')
279
+ out(opts.dryRun ? t('dryRun') : (changed ? t('summaryOk') : t('summaryNothing')))
280
+ return 0
281
+ }
package/src/core.js ADDED
@@ -0,0 +1,247 @@
1
+ // Pure logic of the installer: how each tool is configured and how files are changed.
2
+ // Nothing here talks to the network or the terminal, so it can be tested directly.
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+
7
+ export const PLACEHOLDER = '<YOUR_MCP_CREDENTIAL>'
8
+ export const SERVER_NAME = 'tovanix'
9
+
10
+ // A Tovanix MCP credential: "mcp_live_" + 64 hex characters.
11
+ // Strict on purpose — the value is substituted into shell commands and config files.
12
+ export function isCredential (v) {
13
+ return typeof v === 'string' && /^mcp_live_[0-9a-f]{64}$/.test(v)
14
+ }
15
+
16
+ export function expandHome (p, home = os.homedir()) {
17
+ if (p === '~') return home
18
+ if (p.startsWith('~/')) return path.join(home, p.slice(2))
19
+ return p
20
+ }
21
+
22
+ // How the installer handles a tool. Derived from the published client list, not from tool ids,
23
+ // so tools added on the server side work without a new release of this package.
24
+ // shell run the tool's own "mcp add" command
25
+ // json merge into a JSON config file under the home directory
26
+ // project merge into a JSON config file inside the current project
27
+ // toml append a section to a TOML config file under the home directory
28
+ // manual cannot be done safely from here (UI-only tools, YAML, per-user file locations)
29
+ export function strategyOf (client) {
30
+ const file = client.mcp_file || ''
31
+ if (client.mcp_format === 'shell') return 'shell'
32
+ if (client.mcp_format === 'json' && file) {
33
+ if (client.mcp_file_in_project) return 'project'
34
+ if (file.startsWith('~/')) return 'json'
35
+ }
36
+ if (client.mcp_format === 'toml' && file.startsWith('~/')) return 'toml'
37
+ return 'manual'
38
+ }
39
+
40
+ export function configPath (client, { home = os.homedir(), cwd = process.cwd() } = {}) {
41
+ const s = strategyOf(client)
42
+ if (s === 'json' || s === 'toml') return expandHome(client.mcp_file, home)
43
+ if (s === 'project') return path.join(cwd, client.mcp_file)
44
+ return ''
45
+ }
46
+
47
+ export function skillTarget (client, { home = os.homedir(), cwd = process.cwd() } = {}) {
48
+ if (!client.skill_dir) return ''
49
+ return client.skill_dir_in_project ? path.join(cwd, client.skill_dir) : expandHome(client.skill_dir, home)
50
+ }
51
+
52
+ // ---------------------------------------------------------------- detection
53
+
54
+ export function which (bin, { env = process.env, platform = process.platform } = {}) {
55
+ const dirs = (env.PATH || env.Path || '').split(path.delimiter).filter(Boolean)
56
+ const exts = platform === 'win32' ? (env.PATHEXT || '.EXE;.CMD;.BAT').split(';') : ['']
57
+ for (const d of dirs) {
58
+ for (const e of exts) {
59
+ const full = path.join(d, bin + e)
60
+ try {
61
+ if (fs.statSync(full).isFile()) return full
62
+ } catch { /* not here */ }
63
+ }
64
+ }
65
+ return ''
66
+ }
67
+
68
+ // Is this tool present on the machine? Only tools we can configure are reported.
69
+ export function isDetected (client, opts = {}) {
70
+ const s = strategyOf(client)
71
+ if (s === 'shell') return which(shellWords(client.mcp_snippet)[0] || '', opts) !== ''
72
+ if (s === 'json' || s === 'toml') return dirExists(path.dirname(configPath(client, opts)))
73
+ // A project file that holds the credential is never picked automatically: it would put a secret
74
+ // into whatever repository the command happens to be run in. Such tools need an explicit --tool.
75
+ if (s === 'project') return !client.credential_in_file && dirExists(path.dirname(configPath(client, opts)))
76
+ return false
77
+ }
78
+
79
+ function dirExists (p) {
80
+ try { return fs.statSync(p).isDirectory() } catch { return false }
81
+ }
82
+
83
+ // ---------------------------------------------------------------- shell commands
84
+
85
+ // Split a command line into words, honouring single and double quotes.
86
+ // The published commands are plain "tool mcp add ..." lines; no variables, pipes or globs.
87
+ export function shellWords (line) {
88
+ const out = []
89
+ let cur = ''
90
+ let quote = ''
91
+ let has = false
92
+ for (const ch of String(line)) {
93
+ if (quote) {
94
+ if (ch === quote) quote = ''
95
+ else cur += ch
96
+ } else if (ch === '"' || ch === "'") {
97
+ quote = ch
98
+ has = true
99
+ } else if (/\s/.test(ch)) {
100
+ if (has || cur) out.push(cur)
101
+ cur = ''
102
+ has = false
103
+ } else cur += ch
104
+ }
105
+ if (quote) throw new Error('unbalanced quote in command')
106
+ if (has || cur) out.push(cur)
107
+ return out
108
+ }
109
+
110
+ export function commandFor (client, credential) {
111
+ return shellWords(client.mcp_snippet).map((w) => w.split(PLACEHOLDER).join(credential))
112
+ }
113
+
114
+ // ---------------------------------------------------------------- JSON configs
115
+
116
+ // Remove // and /* */ comments and trailing commas, leaving string contents untouched.
117
+ // Used only to decide whether a file is "JSON with comments"; such files are never rewritten.
118
+ export function stripJsonc (text) {
119
+ let out = ''
120
+ let i = 0
121
+ let inStr = false
122
+ while (i < text.length) {
123
+ const ch = text[i]
124
+ const next = text[i + 1]
125
+ if (inStr) {
126
+ out += ch
127
+ if (ch === '\\') { out += next ?? ''; i += 2; continue }
128
+ if (ch === '"') inStr = false
129
+ i++
130
+ } else if (ch === '"') {
131
+ inStr = true
132
+ out += ch
133
+ i++
134
+ } else if (ch === '/' && next === '/') {
135
+ while (i < text.length && text[i] !== '\n') i++
136
+ } else if (ch === '/' && next === '*') {
137
+ i += 2
138
+ while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++
139
+ i += 2
140
+ } else {
141
+ out += ch
142
+ i++
143
+ }
144
+ }
145
+ return out.replace(/,(\s*[}\]])/g, '$1')
146
+ }
147
+
148
+ const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
149
+
150
+ // Merge `add` into `base` without dropping anything that is already there.
151
+ // Objects merge key by key; arrays of {id} objects are upserted by id (VS Code "inputs");
152
+ // any other array is left alone when it already exists.
153
+ export function mergeConfig (base, add) {
154
+ if (!isObj(base)) return add
155
+ const out = { ...base }
156
+ for (const [k, v] of Object.entries(add)) {
157
+ const cur = out[k]
158
+ if (isObj(v) && isObj(cur)) out[k] = mergeConfig(cur, v)
159
+ else if (Array.isArray(v) && Array.isArray(cur)) {
160
+ const byId = v.every((x) => isObj(x) && 'id' in x) && cur.every((x) => isObj(x) && 'id' in x)
161
+ out[k] = byId ? [...cur.filter((x) => !v.some((y) => y.id === x.id)), ...v] : cur
162
+ } else out[k] = v
163
+ }
164
+ return out
165
+ }
166
+
167
+ function detectIndent (text) {
168
+ const m = /\n([ \t]+)"/.exec(text)
169
+ return m ? m[1] : ' '
170
+ }
171
+
172
+ // Plan the change to a JSON config file. Returns:
173
+ // { action: 'create' | 'update' | 'unchanged', text } — safe to write
174
+ // { action: 'manual', reason } — do not touch the file
175
+ export function planJson (existingText, snippetText, credential) {
176
+ const add = JSON.parse(snippetText.split(PLACEHOLDER).join(credential))
177
+ if (existingText == null || existingText.trim() === '') {
178
+ return { action: 'create', text: JSON.stringify(add, null, 2) + '\n' }
179
+ }
180
+ let base
181
+ try {
182
+ base = JSON.parse(existingText)
183
+ } catch {
184
+ try {
185
+ JSON.parse(stripJsonc(existingText))
186
+ return { action: 'manual', reason: 'comments' } // rewriting would delete the user's comments
187
+ } catch {
188
+ return { action: 'manual', reason: 'invalid' }
189
+ }
190
+ }
191
+ if (!isObj(base)) return { action: 'manual', reason: 'invalid' }
192
+ const merged = mergeConfig(base, add)
193
+ const text = JSON.stringify(merged, null, detectIndent(existingText)) + (existingText.endsWith('\n') ? '\n' : '')
194
+ if (JSON.stringify(merged) === JSON.stringify(base)) return { action: 'unchanged', text: existingText }
195
+ return { action: 'update', text }
196
+ }
197
+
198
+ // ---------------------------------------------------------------- TOML configs
199
+
200
+ // The published TOML snippet is one table, e.g. [mcp_servers.tovanix]. Append it when absent;
201
+ // when the table is already there the file is left alone (editing TOML in place is not worth the risk).
202
+ export function planToml (existingText, snippetText) {
203
+ const header = (/^\s*\[[^\]]+\]/m.exec(snippetText) || [''])[0].trim()
204
+ if (!header) return { action: 'manual', reason: 'invalid' }
205
+ if (existingText == null || existingText.trim() === '') return { action: 'create', text: snippetText.trim() + '\n' }
206
+ const has = existingText.split('\n').some((l) => l.trim() === header)
207
+ if (has) return { action: 'unchanged', text: existingText }
208
+ return { action: 'update', text: existingText.replace(/\n*$/, '\n\n') + snippetText.trim() + '\n' }
209
+ }
210
+
211
+ // ---------------------------------------------------------------- writing files
212
+
213
+ // Write a config file, keeping a copy of the previous content next to it.
214
+ // New files are created private (0600): several tools keep the credential in this file.
215
+ export function writeConfig (file, text) {
216
+ fs.mkdirSync(path.dirname(file), { recursive: true })
217
+ let backup = ''
218
+ if (fs.existsSync(file)) {
219
+ backup = `${file}.tovanix-backup`
220
+ fs.copyFileSync(file, backup)
221
+ fs.writeFileSync(file, text)
222
+ } else {
223
+ fs.writeFileSync(file, text, { mode: 0o600 })
224
+ }
225
+ return backup
226
+ }
227
+
228
+ // Put the skill's files under <dir>/<name>/, replacing a previous copy of the same skill.
229
+ // File paths come from the server; anything that would land outside the skill folder is refused.
230
+ export function installSkill (dir, skill) {
231
+ if (!/^[a-z0-9-]+$/.test(skill.name || '')) throw new Error('unexpected skill name')
232
+ const root = path.join(dir, skill.name)
233
+ const plan = []
234
+ for (const f of skill.files || []) {
235
+ const rel = String(f.path || '')
236
+ const dest = path.resolve(root, rel)
237
+ if (!rel || path.isAbsolute(rel) || !dest.startsWith(root + path.sep)) throw new Error(`unexpected file path in skill: ${rel}`)
238
+ plan.push([dest, String(f.content ?? '')])
239
+ }
240
+ if (!plan.some(([d]) => path.basename(d) === 'SKILL.md')) throw new Error('skill has no SKILL.md')
241
+ fs.rmSync(root, { recursive: true, force: true })
242
+ for (const [dest, content] of plan) {
243
+ fs.mkdirSync(path.dirname(dest), { recursive: true })
244
+ fs.writeFileSync(dest, content)
245
+ }
246
+ return root
247
+ }
@@ -0,0 +1,135 @@
1
+ // Terminal text in English and Simplified Chinese.
2
+ const en = {
3
+ title: 'Tovanix MCP installer',
4
+ help: `Usage: npx tovanix-mcp [options]
5
+
6
+ Connects your AI tools to your Tovanix account over MCP and installs the Tovanix agent skill.
7
+
8
+ Options:
9
+ --tool <ids> Tools to set up, comma separated (see --list). Default: every tool found.
10
+ --yes, -y Do not ask; set up every tool found.
11
+ --credential <v> MCP credential. Prefer the TOVANIX_MCP_CREDENTIAL environment variable.
12
+ --skill-only Install the agent skill only.
13
+ --mcp-only Register the MCP server only.
14
+ --list Show every supported tool and how it is set up, then exit.
15
+ --dry-run Show what would change without changing anything.
16
+ --base <url> Tovanix site to use (default https://tovanix.com).
17
+ --lang <en|zh> Language of this output.
18
+ --version, -v Print the version.
19
+ --help, -h Print this help.
20
+
21
+ Create a credential at {base}/m/mcp first. Your credential is only ever sent to {base}.`,
22
+ fetching: 'Reading the tool list from {base} ...',
23
+ fetchFailed: 'Could not reach {base}: {err}',
24
+ noneFound: 'No supported AI tool was found on this machine.',
25
+ noneFoundHint: 'Run with --list to see every supported tool, or --tool <id> to set one up anyway.',
26
+ found: 'Found on this machine:',
27
+ choose: 'Press Enter to set up all of them, or type the numbers to pick (e.g. 1,3): ',
28
+ badChoice: 'Could not read that choice.',
29
+ unknownTool: 'Unknown tool: {id}. Run with --list to see the ids.',
30
+ needTty: 'No terminal to ask on. Pass --yes (or --tool) and set TOVANIX_MCP_CREDENTIAL.',
31
+ askCredential: 'Paste your MCP credential (create one at {base}/m/mcp; input is hidden): ',
32
+ badCredential: 'That does not look like a Tovanix MCP credential (it starts with mcp_live_).',
33
+ checking: 'Checking the credential ...',
34
+ credOk: 'Credential accepted — {n} tools available to it.',
35
+ credOkNoAbility: 'Credential accepted, but no capability is switched on for it yet. Open {base}/m/mcp to switch on what the AI may do.',
36
+ credRejected: 'The platform rejected this credential (wrong, disabled or expired). Nothing was changed.',
37
+ credUnknown: 'Could not verify the credential ({err}). Continuing.',
38
+ dryRun: 'Dry run — nothing will be changed.',
39
+ mcpDone: 'MCP server registered',
40
+ mcpUnchanged: 'MCP server already set up',
41
+ mcpFile: 'MCP server written to {file}',
42
+ backup: 'previous file kept as {file}',
43
+ mcpManualComments: 'Left {file} untouched because it contains comments. Add this block yourself:',
44
+ mcpManualInvalid: 'Left {file} untouched because it is not valid JSON. Add this block yourself:',
45
+ mcpManual: 'This tool is set up inside its own settings. Follow:',
46
+ cmdMissing: 'Its command-line tool "{bin}" is not on PATH. Run this yourself:',
47
+ cmdFailed: 'Its command-line tool refused the command: {err}',
48
+ envHint: 'This tool reads the credential from an environment variable. Add to your shell profile:',
49
+ skillDone: 'Skill installed to {dir}',
50
+ skillNone: 'No skill folder for this tool',
51
+ projectNote: '(inside the current project folder)',
52
+ gitignoreWarn: 'This file now holds your credential. Add {file} to .gitignore so it is not committed.',
53
+ would: 'would',
54
+ summaryOk: 'Done. Restart the AI tool (or reload its MCP servers) to pick up the change.',
55
+ summaryNothing: 'Nothing was set up.',
56
+ listHead: 'Supported tools:',
57
+ listAuto: 'automatic',
58
+ listManual: 'manual steps',
59
+ }
60
+
61
+ const zh = {
62
+ title: 'Tovanix MCP 安装器',
63
+ help: `用法: npx tovanix-mcp [选项]
64
+
65
+ 把你的 AI 工具通过 MCP 接入 Tovanix 账户,并安装配套的 Agent Skill。
66
+
67
+ 选项:
68
+ --tool <ids> 要配置的工具,逗号分隔(见 --list)。默认:本机找到的全部工具。
69
+ --yes, -y 不询问,直接配置找到的全部工具。
70
+ --credential <v> MCP 凭据。建议改用环境变量 TOVANIX_MCP_CREDENTIAL。
71
+ --skill-only 只安装 Agent Skill。
72
+ --mcp-only 只注册 MCP 服务器。
73
+ --list 列出全部支持的工具与配置方式后退出。
74
+ --dry-run 只显示会改什么,不做任何修改。
75
+ --base <url> 使用的 Tovanix 站点(默认 https://tovanix.com)。
76
+ --lang <en|zh> 输出语言。
77
+ --version, -v 显示版本。
78
+ --help, -h 显示本帮助。
79
+
80
+ 请先在 {base}/m/mcp 创建凭据。凭据只会发送到 {base}。`,
81
+ fetching: '正在从 {base} 读取工具清单 ...',
82
+ fetchFailed: '无法连接 {base}:{err}',
83
+ noneFound: '本机没有找到受支持的 AI 工具。',
84
+ noneFoundHint: '用 --list 查看全部支持的工具,或用 --tool <id> 指定要配置的工具。',
85
+ found: '本机找到:',
86
+ choose: '直接回车配置全部,或输入序号选择(如 1,3): ',
87
+ badChoice: '没看懂这个选择。',
88
+ unknownTool: '未知的工具:{id}。用 --list 查看可用的 id。',
89
+ needTty: '当前没有可交互的终端。请加 --yes(或 --tool)并设置 TOVANIX_MCP_CREDENTIAL。',
90
+ askCredential: '粘贴你的 MCP 凭据(在 {base}/m/mcp 创建;输入不显示): ',
91
+ badCredential: '这不像 Tovanix 的 MCP 凭据(应以 mcp_live_ 开头)。',
92
+ checking: '正在校验凭据 ...',
93
+ credOk: '凭据有效 —— 它可以使用 {n} 个工具。',
94
+ credOkNoAbility: '凭据有效,但还没有开启任何功能。请到 {base}/m/mcp 勾选允许 AI 使用的功能。',
95
+ credRejected: '平台拒绝了这个凭据(填错、已停用或已过期)。没有做任何修改。',
96
+ credUnknown: '无法校验凭据({err}),继续安装。',
97
+ dryRun: '演练模式 —— 不会做任何修改。',
98
+ mcpDone: '已注册 MCP 服务器',
99
+ mcpUnchanged: 'MCP 服务器已经配置过',
100
+ mcpFile: 'MCP 服务器已写入 {file}',
101
+ backup: '原文件保留为 {file}',
102
+ mcpManualComments: '{file} 里有注释,为了不丢注释没有改动它。请自行加入这一段:',
103
+ mcpManualInvalid: '{file} 不是合法的 JSON,没有改动它。请自行加入这一段:',
104
+ mcpManual: '这个工具要在它自己的设置里配置,请按这里操作:',
105
+ cmdMissing: '它的命令行工具 "{bin}" 不在 PATH 里。请自行执行:',
106
+ cmdFailed: '它的命令行工具拒绝了这条命令:{err}',
107
+ envHint: '这个工具从环境变量读取凭据。请加到你的 shell 配置里:',
108
+ skillDone: 'Skill 已安装到 {dir}',
109
+ skillNone: '这个工具没有技能目录',
110
+ projectNote: '(在当前项目目录下)',
111
+ gitignoreWarn: '这个文件里现在有你的凭据。请把 {file} 加进 .gitignore,避免提交到仓库。',
112
+ would: '将会',
113
+ summaryOk: '完成。重启 AI 工具(或重新加载它的 MCP 服务器)后生效。',
114
+ summaryNothing: '没有配置任何工具。',
115
+ listHead: '支持的工具:',
116
+ listAuto: '自动配置',
117
+ listManual: '需手动操作',
118
+ }
119
+
120
+ export function pickLang (flag, env = process.env) {
121
+ if (flag === 'zh' || flag === 'en') return flag
122
+ const v = `${env.LC_ALL || ''}${env.LC_MESSAGES || ''}${env.LANG || ''}`.toLowerCase()
123
+ if (v.includes('zh')) return 'zh'
124
+ try {
125
+ if (Intl.DateTimeFormat().resolvedOptions().locale.toLowerCase().startsWith('zh')) return 'zh'
126
+ } catch { /* keep English */ }
127
+ return 'en'
128
+ }
129
+
130
+ export function translator (lang) {
131
+ const table = lang === 'zh' ? zh : en
132
+ return (key, vars = {}) => String(table[key] ?? en[key] ?? key).replace(/\{(\w+)\}/g, (_, k) => (k in vars ? String(vars[k]) : `{${k}}`))
133
+ }
134
+
135
+ export const _tables = { en, zh }