dsh-oc-tui 0.2.0 → 0.3.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 +21 -563
- package/bin/mcp-sse-bridge.mjs +499 -499
- package/docs/assets/conversation.png +0 -0
- package/docs/assets/cover.png +0 -0
- package/docs/assets/home.png +0 -0
- package/docs/assets/settings.png +0 -0
- package/docs/dsh-std-/346/216/245/345/205/245/350/257/264/346/230/216.md +566 -0
- package/docs/user-guide.md +616 -0
- package/docs//347/224/250/346/210/267/346/211/213/345/206/214.md +607 -593
- package/dsh-plugin.json +69 -69
- package/lib/boot-block.js +129 -0
- package/lib/diagnostics.js +162 -120
- package/lib/dsh-version.js +161 -0
- package/lib/git-branch.js +157 -157
- package/lib/i18n.js +283 -283
- package/lib/image.js +2 -1
- package/lib/index.js +5183 -4812
- package/lib/locales/ar.js +670 -635
- package/lib/locales/de.js +639 -604
- package/lib/locales/en.js +35 -0
- package/lib/locales/es.js +639 -604
- package/lib/locales/fr.js +639 -604
- package/lib/locales/id.js +641 -606
- package/lib/locales/it.js +639 -604
- package/lib/locales/ja.js +639 -604
- package/lib/locales/ko.js +639 -604
- package/lib/locales/nl.js +639 -604
- package/lib/locales/pl.js +653 -618
- package/lib/locales/pt-BR.js +639 -604
- package/lib/locales/ru.js +653 -618
- package/lib/locales/tr.js +639 -604
- package/lib/locales/vi.js +641 -606
- package/lib/locales/zh-CN.js +640 -605
- package/lib/locales/zh-TW.js +640 -605
- package/lib/markdown.js +101 -27
- package/lib/mcp-monitor.js +189 -0
- package/lib/mcp-skill.js +93 -93
- package/lib/mcp.js +865 -838
- package/lib/model-route.js +66 -0
- package/lib/providers.js +712 -712
- package/lib/screenshot.js +140 -0
- package/lib/splash-opencode.js +29 -26
- package/lib/startup.js +66 -48
- package/lib/term.js +53 -11
- package/lib/themes.js +101 -0
- package/lib/ui.js +685 -443
- package/lib/updates.js +5 -2
- package/lib/web-settings.js +52 -21
- package/package.json +160 -146
- package/skills/mcp-import/SKILL.md +216 -216
package/lib/markdown.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import MarkdownIt from 'markdown-it'
|
|
12
12
|
import hljs from 'highlight.js'
|
|
13
13
|
import { makeStyle, mergeStyle } from './term.js'
|
|
14
|
-
import { runeWidth, displayWidth } from './util.js'
|
|
14
|
+
import { runeWidth, displayWidth, truncateWidth } from './util.js'
|
|
15
15
|
|
|
16
16
|
const md = new MarkdownIt({ html: false, linkify: true, typographer: false })
|
|
17
17
|
|
|
@@ -54,8 +54,11 @@ function parseHljsHtml(html, theme, base) {
|
|
|
54
54
|
if (m[1] !== undefined) classes.push(m[1])
|
|
55
55
|
else if (m[0] === '</span>') classes.pop()
|
|
56
56
|
else {
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
let key
|
|
58
|
+
for (let i = classes.length - 1; i >= 0 && !key; i--) {
|
|
59
|
+
const scope = classes[i].split(/\s+/)[0].replace(/^hljs-/, '')
|
|
60
|
+
key = HLJS_COLOR_KEYS[scope]
|
|
61
|
+
}
|
|
59
62
|
const fg = key ? (theme[key] ?? theme.markdownCodeBlock) : theme.markdownCodeBlock
|
|
60
63
|
segs.push({ text: decodeEntities(m[2]), style: mergeStyle(base, { fg, bg: theme.codeBg }) })
|
|
61
64
|
}
|
|
@@ -86,6 +89,85 @@ function highlightLines(code, lang, theme, base) {
|
|
|
86
89
|
return lines
|
|
87
90
|
}
|
|
88
91
|
|
|
92
|
+
function expandCodeTabs(code) {
|
|
93
|
+
let column = 0
|
|
94
|
+
let out = ''
|
|
95
|
+
for (const ch of code) {
|
|
96
|
+
if (ch === '\n') {
|
|
97
|
+
out += ch
|
|
98
|
+
column = 0
|
|
99
|
+
} else if (ch === '\t') {
|
|
100
|
+
const spaces = 4 - column % 4
|
|
101
|
+
out += ' '.repeat(spaces)
|
|
102
|
+
column += spaces
|
|
103
|
+
} else {
|
|
104
|
+
out += ch
|
|
105
|
+
column += runeWidth(ch)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return out
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Wrap by cells without dropping indentation or spaces between styled runs.
|
|
112
|
+
const codeGraphemes = new Intl.Segmenter('en', { granularity: 'grapheme' })
|
|
113
|
+
|
|
114
|
+
function wrapCodeLine(segs, width) {
|
|
115
|
+
const lines = [[]]
|
|
116
|
+
let used = 0
|
|
117
|
+
for (const seg of segs) {
|
|
118
|
+
let text = ''
|
|
119
|
+
const flush = () => {
|
|
120
|
+
if (text) lines[lines.length - 1].push({ ...seg, text })
|
|
121
|
+
text = ''
|
|
122
|
+
}
|
|
123
|
+
const cells = /[\p{Mark}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}]/u.test(seg.text)
|
|
124
|
+
? Array.from(codeGraphemes.segment(seg.text), (item) => item.segment)
|
|
125
|
+
: seg.text
|
|
126
|
+
for (const segment of cells) {
|
|
127
|
+
let w = segWidth(segment)
|
|
128
|
+
if (used + w > width && used > 0) {
|
|
129
|
+
flush()
|
|
130
|
+
lines.push([])
|
|
131
|
+
used = 0
|
|
132
|
+
}
|
|
133
|
+
if (w > width) {
|
|
134
|
+
text += '�'
|
|
135
|
+
w = 1
|
|
136
|
+
} else text += segment
|
|
137
|
+
used += w
|
|
138
|
+
}
|
|
139
|
+
flush()
|
|
140
|
+
}
|
|
141
|
+
return lines
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function renderCodeBlock(ctx, state, code, lang) {
|
|
145
|
+
const theme = ctx.theme
|
|
146
|
+
const codeBase = makeStyle({ fg: theme.markdownCodeBlock, bg: theme.codeBg })
|
|
147
|
+
const prefix = truncateWidth(prefixOf(state), Math.max(0, ctx.width - 1))
|
|
148
|
+
const prefixWidth = displayWidth(prefix)
|
|
149
|
+
const inset = ' '.repeat(Math.min(2, Math.max(0, ctx.width - prefixWidth - 2)))
|
|
150
|
+
const innerWidth = ctx.width - prefixWidth - inset.length
|
|
151
|
+
const contentWidth = innerWidth > 2 ? innerWidth - 1 : innerWidth
|
|
152
|
+
const emit = (line) => {
|
|
153
|
+
const used = line.reduce((sum, seg) => sum + segWidth(seg.text), 0)
|
|
154
|
+
const row = []
|
|
155
|
+
if (prefix) row.push({ text: prefix, style: state.quoteStyle ?? null })
|
|
156
|
+
if (inset) row.push({ text: inset, style: codeBase })
|
|
157
|
+
row.push(...line)
|
|
158
|
+
if (innerWidth > used) row.push({ text: ' '.repeat(innerWidth - used), style: codeBase })
|
|
159
|
+
ctx.lines.push(row)
|
|
160
|
+
}
|
|
161
|
+
ctx.lines.push([])
|
|
162
|
+
if (lang) {
|
|
163
|
+
emit([{ text: truncateWidth(lang, contentWidth), style: makeStyle({ fg: theme.textMuted, bg: theme.codeBg, bold: true }) }])
|
|
164
|
+
}
|
|
165
|
+
for (const line of highlightLines(expandCodeTabs(code.replace(/\n$/, '')), lang, theme, codeBase)) {
|
|
166
|
+
for (const wrapped of wrapCodeLine(line, contentWidth)) emit(wrapped)
|
|
167
|
+
}
|
|
168
|
+
ctx.lines.push([])
|
|
169
|
+
}
|
|
170
|
+
|
|
89
171
|
// Render an inline token's children to flat segments. Style state (bold, em,
|
|
90
172
|
// strike) and the enclosing link ride a stack; link segments carry the href on
|
|
91
173
|
// a `link` property, which paint() turns into an OSC 8 hyperlink.
|
|
@@ -170,26 +252,18 @@ function clipSegLine(segs, width) {
|
|
|
170
252
|
const out = []
|
|
171
253
|
let used = 0
|
|
172
254
|
for (const seg of segs) {
|
|
173
|
-
if (used >= width) break
|
|
174
|
-
const w = segWidth(seg.text)
|
|
175
|
-
if (used + w <= width) {
|
|
176
|
-
out.push(seg)
|
|
177
|
-
used += w
|
|
178
|
-
continue
|
|
179
|
-
}
|
|
180
|
-
const room = width - used - 1
|
|
181
|
-
if (room < 0) {
|
|
182
|
-
if (out.length === 0) out.push({ ...seg, text: '…' })
|
|
183
|
-
break
|
|
184
|
-
}
|
|
185
255
|
let text = ''
|
|
186
256
|
for (const ch of seg.text) {
|
|
187
|
-
|
|
257
|
+
const w = runeWidth(ch)
|
|
258
|
+
if (used + w > width - 1) {
|
|
259
|
+
if (text) out.push({ ...seg, text })
|
|
260
|
+
out.push({ ...seg, text: '…' })
|
|
261
|
+
return out
|
|
262
|
+
}
|
|
188
263
|
text += ch
|
|
264
|
+
used += w
|
|
189
265
|
}
|
|
190
266
|
if (text) out.push({ ...seg, text })
|
|
191
|
-
out.push({ ...seg, text: '…' })
|
|
192
|
-
used = width
|
|
193
267
|
}
|
|
194
268
|
return out
|
|
195
269
|
}
|
|
@@ -214,8 +288,9 @@ function tableBoxRow(cells, widths, theme) {
|
|
|
214
288
|
const segs = [{ text: '│', style }]
|
|
215
289
|
cells.forEach((cell, i) => {
|
|
216
290
|
segs.push({ text: ' ', style })
|
|
217
|
-
|
|
218
|
-
|
|
291
|
+
const clipped = clipSegLine(cell, widths[i])
|
|
292
|
+
segs.push(...clipped)
|
|
293
|
+
const pad = widths[i] - clipped.reduce((w, s) => w + segWidth(s.text), 0)
|
|
219
294
|
if (pad > 0) segs.push({ text: ' '.repeat(pad), style })
|
|
220
295
|
segs.push({ text: ' ', style })
|
|
221
296
|
if (i < cells.length - 1) segs.push({ text: '│', style })
|
|
@@ -356,13 +431,12 @@ function renderBlocks(tokens, start, end, ctx, state) {
|
|
|
356
431
|
i += 1
|
|
357
432
|
continue
|
|
358
433
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
ctx.lines.push([])
|
|
434
|
+
renderCodeBlock(ctx, state, t.content, lang)
|
|
435
|
+
i += 1
|
|
436
|
+
continue
|
|
437
|
+
}
|
|
438
|
+
case 'code_block': {
|
|
439
|
+
renderCodeBlock(ctx, state, t.content, '')
|
|
366
440
|
i += 1
|
|
367
441
|
continue
|
|
368
442
|
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// MCP connection state for the TUI, and the output isolation that keeps a
|
|
2
|
+
// failed MCP server off the screen.
|
|
3
|
+
//
|
|
4
|
+
// The host's MCP client reports a server it cannot reach by writing to the
|
|
5
|
+
// terminal, and a stdio server inherits the terminal's stderr, so adding an
|
|
6
|
+
// MCP server whose program is not running leaves stray text on the frame the
|
|
7
|
+
// renderer owns — the TUI then paints over a screen that never settled. This
|
|
8
|
+
// module diverts that output and reports the state instead: once per state,
|
|
9
|
+
// as a short notice in the corner.
|
|
10
|
+
import childProcess from 'node:child_process'
|
|
11
|
+
|
|
12
|
+
// The host prints one multiline audit after startup: the plugins that did not
|
|
13
|
+
// activate, each with its reason and a stack. Only the MCP entries are
|
|
14
|
+
// dropped; any other plugin's diagnostics are left alone.
|
|
15
|
+
export function filterMcpStartupAudit(text) {
|
|
16
|
+
const lines = text.split('\n')
|
|
17
|
+
const header = /^(.*?: warning: )\d+ entr(?:y|ies) did not activate$/.exec(lines[0])
|
|
18
|
+
if (!header || !text.includes('@deepseek-ai/dsh-mcp-client')) return text
|
|
19
|
+
const blocks = []
|
|
20
|
+
for (const line of lines.slice(1)) {
|
|
21
|
+
if (!line && blocks.length === 0) continue
|
|
22
|
+
if (/^\S.* \([^)]+\): /.test(line)) blocks.push([line])
|
|
23
|
+
else if (blocks.length) blocks.at(-1).push(line)
|
|
24
|
+
else return text
|
|
25
|
+
}
|
|
26
|
+
if (!blocks.some((block) => block[0].includes('(@deepseek-ai/dsh-mcp-client)'))) return text
|
|
27
|
+
const kept = blocks.filter((block) => !block[0].includes('(@deepseek-ai/dsh-mcp-client)'))
|
|
28
|
+
if (!kept.length) return ''
|
|
29
|
+
return `${header[1]}${kept.length} ${kept.length === 1 ? 'entry' : 'entries'} did not activate\n${kept.map((block) => block.join('\n').replace(/\n+$/, '')).join('\n')}\n`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Wrap the current `process.stderr.write` so an MCP startup audit never
|
|
33
|
+
// reaches the terminal. The wrapper is installed around whatever writer is
|
|
34
|
+
// current — the raw stream before the alt screen, the diagnostics capture
|
|
35
|
+
// after it — so it composes instead of competing with it.
|
|
36
|
+
export function filterMcpStderrWrites() {
|
|
37
|
+
const original = process.stderr.write
|
|
38
|
+
function stderrWrite(chunk, encoding, callback) {
|
|
39
|
+
if (typeof chunk !== 'string' && !Buffer.isBuffer(chunk)) return original.apply(this, arguments)
|
|
40
|
+
const text = Buffer.isBuffer(chunk) ? chunk.toString(typeof encoding === 'string' ? encoding : 'utf8') : chunk
|
|
41
|
+
const filtered = filterMcpStartupAudit(text)
|
|
42
|
+
if (filtered === text) return original.apply(this, arguments)
|
|
43
|
+
if (filtered) return original.call(this, filtered, encoding, callback)
|
|
44
|
+
const done = typeof encoding === 'function' ? encoding : callback
|
|
45
|
+
if (typeof done === 'function') queueMicrotask(done)
|
|
46
|
+
return true
|
|
47
|
+
}
|
|
48
|
+
process.stderr.write = stderrWrite
|
|
49
|
+
return () => {
|
|
50
|
+
if (process.stderr.write === stderrWrite) process.stderr.write = original
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Keep MCP diagnostics out of the terminal:
|
|
55
|
+
//
|
|
56
|
+
// * a stdio server spawned by the SDK inherits stderr, so its own output
|
|
57
|
+
// lands on the frame. Only the SDK's transport spawn is redirected, never
|
|
58
|
+
// an ordinary tool process;
|
|
59
|
+
// * the SDK writes its own diagnostics straight to the console (for example
|
|
60
|
+
// when a server advertises no tools).
|
|
61
|
+
export function isolateMcpOutput() {
|
|
62
|
+
const original = childProcess.spawn
|
|
63
|
+
const consoleMethods = new Map()
|
|
64
|
+
for (const name of ['log', 'info', 'warn', 'error', 'debug']) {
|
|
65
|
+
const previous = console[name]
|
|
66
|
+
function filtered(...args) {
|
|
67
|
+
if (/[\\/]@modelcontextprotocol[\\/]/.test(new Error().stack || '')) return
|
|
68
|
+
return previous.apply(this, args)
|
|
69
|
+
}
|
|
70
|
+
consoleMethods.set(name, { previous, filtered })
|
|
71
|
+
console[name] = filtered
|
|
72
|
+
}
|
|
73
|
+
function spawn(command, args, options) {
|
|
74
|
+
const stdio = options?.stdio
|
|
75
|
+
const isMcp = Array.isArray(stdio) && stdio[0] === 'pipe' && stdio[1] === 'pipe'
|
|
76
|
+
&& stdio[2] === 'inherit' && /StdioClientTransport\.start/.test(new Error().stack || '')
|
|
77
|
+
if (!isMcp) return original.apply(this, arguments)
|
|
78
|
+
const child = original.call(this, command, args, { ...options, stdio: [stdio[0], stdio[1], 'pipe', ...stdio.slice(3)] })
|
|
79
|
+
// Drain immediately: an unread pipe can block a noisy server. Its output
|
|
80
|
+
// is diagnostic text, not a reliable indication of connection state.
|
|
81
|
+
child.stderr?.resume()
|
|
82
|
+
return child
|
|
83
|
+
}
|
|
84
|
+
childProcess.spawn = spawn
|
|
85
|
+
return () => {
|
|
86
|
+
if (childProcess.spawn === spawn) childProcess.spawn = original
|
|
87
|
+
for (const [name, { previous, filtered }] of consoleMethods) {
|
|
88
|
+
if (console[name] === filtered) console[name] = previous
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Watch the host's MCP client and report each server's state. The state is
|
|
94
|
+
// read from two places: the host's log lines, and the plugin fibers of
|
|
95
|
+
// `@deepseek-ai/dsh-mcp-client` (which know whether a server came up).
|
|
96
|
+
export function createMcpMonitor(ctx) {
|
|
97
|
+
const state = new Map()
|
|
98
|
+
const seenLoading = new WeakSet()
|
|
99
|
+
const probes = new Map()
|
|
100
|
+
const pending = []
|
|
101
|
+
let listener = null
|
|
102
|
+
const report = (name, status) => {
|
|
103
|
+
if (!name || state.get(name) === status) return
|
|
104
|
+
state.set(name, status)
|
|
105
|
+
const event = { name, status }
|
|
106
|
+
if (listener) listener(event)
|
|
107
|
+
else pending.push(event)
|
|
108
|
+
}
|
|
109
|
+
const handleLog = (message) => {
|
|
110
|
+
const text = String(message?.args?.[0] ?? '')
|
|
111
|
+
const match = /^mcp-client\(([^)]+)\):\s*(.*)/s.exec(text)
|
|
112
|
+
if (!match) return
|
|
113
|
+
const detail = match[2]
|
|
114
|
+
if (/connection attempt failed|connection failed|connection lost|giving up|failed generation|tool registration failed/i.test(detail)) report(match[1], 'failed')
|
|
115
|
+
else if (/reconnected and re-synced/i.test(detail)) report(match[1], 'connected')
|
|
116
|
+
}
|
|
117
|
+
const handleFiber = (fiber) => {
|
|
118
|
+
const entry = fiber?.entry
|
|
119
|
+
if (!entry || !String(entry.options?.name ?? '').includes('dsh-mcp-client')) return
|
|
120
|
+
// Effects created by a plugin inherit its entry but become active before
|
|
121
|
+
// the plugin's asynchronous connection attempt has settled.
|
|
122
|
+
if (entry.fiber !== fiber) return
|
|
123
|
+
const server = entry.options?.config?.serverName
|
|
124
|
+
if (!server) return
|
|
125
|
+
if (fiber.state === 1) {
|
|
126
|
+
seenLoading.add(fiber)
|
|
127
|
+
probes.delete(server)
|
|
128
|
+
state.delete(server)
|
|
129
|
+
}
|
|
130
|
+
// A host MCP plugin can activate after a failed first attempt while its
|
|
131
|
+
// retry supervisor runs, so a failure log takes precedence over ACTIVE.
|
|
132
|
+
if (fiber.state === 2 && !state.has(server)) {
|
|
133
|
+
const resources = ctx.get('mcpResources')
|
|
134
|
+
if (typeof resources?.request === 'function') {
|
|
135
|
+
// The provider checks its connection before handling a request.
|
|
136
|
+
// An already-aborted signal keeps this probe local to the host.
|
|
137
|
+
const probe = {}
|
|
138
|
+
probes.set(server, probe)
|
|
139
|
+
const signal = AbortSignal.abort()
|
|
140
|
+
Promise.resolve().then(() => resources.request(server, { method: 'resources/read', uri: 'tui-status://probe' }, { agent: undefined, signal }))
|
|
141
|
+
.then(() => 'connected', (error) => {
|
|
142
|
+
const text = String(error)
|
|
143
|
+
if (/server is disconnected/i.test(text)) return 'failed'
|
|
144
|
+
if (/unavailable in this agent's scope/i.test(text)) return null
|
|
145
|
+
return 'connected'
|
|
146
|
+
}).then((status) => {
|
|
147
|
+
if (probes.get(server) === probe && !state.has(server) && status) report(server, status)
|
|
148
|
+
})
|
|
149
|
+
} else if (seenLoading.has(fiber)) {
|
|
150
|
+
// If observation began before this fiber loaded, its failure logs
|
|
151
|
+
// would have arrived before the ACTIVE transition.
|
|
152
|
+
report(server, 'connected')
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (fiber.state === 3) report(server, 'failed')
|
|
156
|
+
}
|
|
157
|
+
ctx.logger.exporter({ levels: { default: 2 }, export: handleLog })
|
|
158
|
+
ctx.on('internal/status', handleFiber, { global: true })
|
|
159
|
+
for (const entry of ctx.get('loader')?.entries?.() ?? []) if (entry.fiber) handleFiber(entry.fiber)
|
|
160
|
+
return {
|
|
161
|
+
attach(fn) { listener = fn; for (const event of pending.splice(0)) fn(event) },
|
|
162
|
+
detach() { listener = null },
|
|
163
|
+
handleLog, handleFiber,
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// One gate for every source of MCP state (the host's log, the host's plugin
|
|
168
|
+
// fibers, the TUI's own mounts): a server that reports the same state twice
|
|
169
|
+
// is reported once. The state changes on the next attempt, so a reconnect
|
|
170
|
+
// that succeeds is reported again.
|
|
171
|
+
export function createMcpNotifier() {
|
|
172
|
+
const state = new Map()
|
|
173
|
+
const listeners = new Set()
|
|
174
|
+
return {
|
|
175
|
+
report(name, status) {
|
|
176
|
+
if (!name) return
|
|
177
|
+
const key = String(name)
|
|
178
|
+
if (state.get(key) === status) return
|
|
179
|
+
state.set(key, status)
|
|
180
|
+
for (const listener of listeners) {
|
|
181
|
+
try { listener(key, status) } catch { /* a listener must not break the notifier */ }
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
subscribe(fn) {
|
|
185
|
+
listeners.add(fn)
|
|
186
|
+
return () => { listeners.delete(fn) }
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
}
|
package/lib/mcp-skill.js
CHANGED
|
@@ -1,93 +1,93 @@
|
|
|
1
|
-
// The bundled `mcp-import` skill: the shipped SKILL.md parsed into the shape the
|
|
2
|
-
// host's skill registry accepts, and registered on the optional `skills` service.
|
|
3
|
-
//
|
|
4
|
-
// The skill service is a harness capability, not a dependency of this plugin: a
|
|
5
|
-
// host without a skill registry must still boot the TUI, so every entry point
|
|
6
|
-
// here answers with a verdict instead of throwing, and nothing imports
|
|
7
|
-
// `@deepseek-ai/*` at module scope — that specifier may not resolve at all.
|
|
8
|
-
|
|
9
|
-
import { readFileSync } from 'node:fs'
|
|
10
|
-
import { fileURLToPath } from 'node:url'
|
|
11
|
-
import { formatError } from './util.js'
|
|
12
|
-
|
|
13
|
-
export const MCP_SKILL_NAME = 'mcp-import'
|
|
14
|
-
|
|
15
|
-
// Resolved from this module, not from process.cwd(), so the skill is found
|
|
16
|
-
// wherever the tarball was unpacked.
|
|
17
|
-
const SKILL_FILE = new URL('../skills/mcp-import/SKILL.md', import.meta.url)
|
|
18
|
-
const SKILL_DIR = fileURLToPath(new URL('../skills/mcp-import/', import.meta.url))
|
|
19
|
-
|
|
20
|
-
// The frontmatter this project writes: a `---` fence, `key: value` scalars, a
|
|
21
|
-
// closing fence. Only those scalars are read; the host parses the real YAML when
|
|
22
|
-
// the skill is discovered from disk, this parser only feeds the runtime channel.
|
|
23
|
-
function scalar(text) {
|
|
24
|
-
const value = text.trim()
|
|
25
|
-
if (value.length >= 2 && (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"))) {
|
|
26
|
-
const inner = value.slice(1, -1)
|
|
27
|
-
return value[0] === '"' ? inner.replace(/\\(["\\])/g, '$1') : inner.replace(/''/g, "'")
|
|
28
|
-
}
|
|
29
|
-
return value
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function parseSkillFile(text) {
|
|
33
|
-
if (typeof text !== 'string') return null
|
|
34
|
-
const lines = text.replace(/^\uFEFF/, '').split(/\r?\n/)
|
|
35
|
-
if (lines[0]?.trim() !== '---') return null
|
|
36
|
-
let end = -1
|
|
37
|
-
for (let index = 1; index < lines.length; index++) {
|
|
38
|
-
if (lines[index].trim() === '---') { end = index; break }
|
|
39
|
-
}
|
|
40
|
-
if (end < 0) return null
|
|
41
|
-
const fields = {}
|
|
42
|
-
for (const line of lines.slice(1, end)) {
|
|
43
|
-
const match = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line.trim())
|
|
44
|
-
if (match) fields[match[1]] = scalar(match[2])
|
|
45
|
-
}
|
|
46
|
-
if (!fields.name || !fields.description) return null
|
|
47
|
-
const skill = {
|
|
48
|
-
name: fields.name,
|
|
49
|
-
description: fields.description,
|
|
50
|
-
content: lines.slice(end + 1).join('\n').trim(),
|
|
51
|
-
}
|
|
52
|
-
if (fields.whenToUse) skill.whenToUse = fields.whenToUse
|
|
53
|
-
return skill
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function loadBundledMcpSkill() {
|
|
57
|
-
try {
|
|
58
|
-
return parseSkillFile(readFileSync(SKILL_FILE, 'utf8'))
|
|
59
|
-
} catch {
|
|
60
|
-
return null
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Returns a verdict rather than throwing: a host without the service, a broken
|
|
65
|
-
// bundled file and a rejecting registry all have to leave the TUI running.
|
|
66
|
-
export function registerMcpSkill(ctx) {
|
|
67
|
-
let skills
|
|
68
|
-
try {
|
|
69
|
-
skills = ctx?.get?.('skills')
|
|
70
|
-
} catch (error) {
|
|
71
|
-
return { ok: false, reason: 'skills lookup failed: ' + formatError(error) }
|
|
72
|
-
}
|
|
73
|
-
if (!skills || typeof skills.register !== 'function') {
|
|
74
|
-
return { ok: false, reason: 'the host skill service is unavailable' }
|
|
75
|
-
}
|
|
76
|
-
const skill = loadBundledMcpSkill()
|
|
77
|
-
if (!skill) return { ok: false, reason: 'the bundled ' + MCP_SKILL_NAME + ' skill could not be read' }
|
|
78
|
-
const registration = {
|
|
79
|
-
name: skill.name,
|
|
80
|
-
description: skill.description,
|
|
81
|
-
content: skill.content,
|
|
82
|
-
source: 'bundled',
|
|
83
|
-
resourceBase: { kind: 'directory', path: SKILL_DIR },
|
|
84
|
-
invocation: { modelInvocable: true, userInvocable: true },
|
|
85
|
-
}
|
|
86
|
-
if (skill.whenToUse) registration.whenToUse = skill.whenToUse
|
|
87
|
-
try {
|
|
88
|
-
skills.register(registration)
|
|
89
|
-
return { ok: true, name: skill.name }
|
|
90
|
-
} catch (error) {
|
|
91
|
-
return { ok: false, reason: 'skill registration failed: ' + formatError(error) }
|
|
92
|
-
}
|
|
93
|
-
}
|
|
1
|
+
// The bundled `mcp-import` skill: the shipped SKILL.md parsed into the shape the
|
|
2
|
+
// host's skill registry accepts, and registered on the optional `skills` service.
|
|
3
|
+
//
|
|
4
|
+
// The skill service is a harness capability, not a dependency of this plugin: a
|
|
5
|
+
// host without a skill registry must still boot the TUI, so every entry point
|
|
6
|
+
// here answers with a verdict instead of throwing, and nothing imports
|
|
7
|
+
// `@deepseek-ai/*` at module scope — that specifier may not resolve at all.
|
|
8
|
+
|
|
9
|
+
import { readFileSync } from 'node:fs'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
import { formatError } from './util.js'
|
|
12
|
+
|
|
13
|
+
export const MCP_SKILL_NAME = 'mcp-import'
|
|
14
|
+
|
|
15
|
+
// Resolved from this module, not from process.cwd(), so the skill is found
|
|
16
|
+
// wherever the tarball was unpacked.
|
|
17
|
+
const SKILL_FILE = new URL('../skills/mcp-import/SKILL.md', import.meta.url)
|
|
18
|
+
const SKILL_DIR = fileURLToPath(new URL('../skills/mcp-import/', import.meta.url))
|
|
19
|
+
|
|
20
|
+
// The frontmatter this project writes: a `---` fence, `key: value` scalars, a
|
|
21
|
+
// closing fence. Only those scalars are read; the host parses the real YAML when
|
|
22
|
+
// the skill is discovered from disk, this parser only feeds the runtime channel.
|
|
23
|
+
function scalar(text) {
|
|
24
|
+
const value = text.trim()
|
|
25
|
+
if (value.length >= 2 && (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"))) {
|
|
26
|
+
const inner = value.slice(1, -1)
|
|
27
|
+
return value[0] === '"' ? inner.replace(/\\(["\\])/g, '$1') : inner.replace(/''/g, "'")
|
|
28
|
+
}
|
|
29
|
+
return value
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function parseSkillFile(text) {
|
|
33
|
+
if (typeof text !== 'string') return null
|
|
34
|
+
const lines = text.replace(/^\uFEFF/, '').split(/\r?\n/)
|
|
35
|
+
if (lines[0]?.trim() !== '---') return null
|
|
36
|
+
let end = -1
|
|
37
|
+
for (let index = 1; index < lines.length; index++) {
|
|
38
|
+
if (lines[index].trim() === '---') { end = index; break }
|
|
39
|
+
}
|
|
40
|
+
if (end < 0) return null
|
|
41
|
+
const fields = {}
|
|
42
|
+
for (const line of lines.slice(1, end)) {
|
|
43
|
+
const match = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line.trim())
|
|
44
|
+
if (match) fields[match[1]] = scalar(match[2])
|
|
45
|
+
}
|
|
46
|
+
if (!fields.name || !fields.description) return null
|
|
47
|
+
const skill = {
|
|
48
|
+
name: fields.name,
|
|
49
|
+
description: fields.description,
|
|
50
|
+
content: lines.slice(end + 1).join('\n').trim(),
|
|
51
|
+
}
|
|
52
|
+
if (fields.whenToUse) skill.whenToUse = fields.whenToUse
|
|
53
|
+
return skill
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function loadBundledMcpSkill() {
|
|
57
|
+
try {
|
|
58
|
+
return parseSkillFile(readFileSync(SKILL_FILE, 'utf8'))
|
|
59
|
+
} catch {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Returns a verdict rather than throwing: a host without the service, a broken
|
|
65
|
+
// bundled file and a rejecting registry all have to leave the TUI running.
|
|
66
|
+
export function registerMcpSkill(ctx) {
|
|
67
|
+
let skills
|
|
68
|
+
try {
|
|
69
|
+
skills = ctx?.get?.('skills')
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return { ok: false, reason: 'skills lookup failed: ' + formatError(error) }
|
|
72
|
+
}
|
|
73
|
+
if (!skills || typeof skills.register !== 'function') {
|
|
74
|
+
return { ok: false, reason: 'the host skill service is unavailable' }
|
|
75
|
+
}
|
|
76
|
+
const skill = loadBundledMcpSkill()
|
|
77
|
+
if (!skill) return { ok: false, reason: 'the bundled ' + MCP_SKILL_NAME + ' skill could not be read' }
|
|
78
|
+
const registration = {
|
|
79
|
+
name: skill.name,
|
|
80
|
+
description: skill.description,
|
|
81
|
+
content: skill.content,
|
|
82
|
+
source: 'bundled',
|
|
83
|
+
resourceBase: { kind: 'directory', path: SKILL_DIR },
|
|
84
|
+
invocation: { modelInvocable: true, userInvocable: true },
|
|
85
|
+
}
|
|
86
|
+
if (skill.whenToUse) registration.whenToUse = skill.whenToUse
|
|
87
|
+
try {
|
|
88
|
+
skills.register(registration)
|
|
89
|
+
return { ok: true, name: skill.name }
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return { ok: false, reason: 'skill registration failed: ' + formatError(error) }
|
|
92
|
+
}
|
|
93
|
+
}
|