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.
Files changed (50) hide show
  1. package/README.md +21 -563
  2. package/bin/mcp-sse-bridge.mjs +499 -499
  3. package/docs/assets/conversation.png +0 -0
  4. package/docs/assets/cover.png +0 -0
  5. package/docs/assets/home.png +0 -0
  6. package/docs/assets/settings.png +0 -0
  7. package/docs/dsh-std-/346/216/245/345/205/245/350/257/264/346/230/216.md +566 -0
  8. package/docs/user-guide.md +616 -0
  9. package/docs//347/224/250/346/210/267/346/211/213/345/206/214.md +607 -593
  10. package/dsh-plugin.json +69 -69
  11. package/lib/boot-block.js +129 -0
  12. package/lib/diagnostics.js +162 -120
  13. package/lib/dsh-version.js +161 -0
  14. package/lib/git-branch.js +157 -157
  15. package/lib/i18n.js +283 -283
  16. package/lib/image.js +2 -1
  17. package/lib/index.js +5183 -4812
  18. package/lib/locales/ar.js +670 -635
  19. package/lib/locales/de.js +639 -604
  20. package/lib/locales/en.js +35 -0
  21. package/lib/locales/es.js +639 -604
  22. package/lib/locales/fr.js +639 -604
  23. package/lib/locales/id.js +641 -606
  24. package/lib/locales/it.js +639 -604
  25. package/lib/locales/ja.js +639 -604
  26. package/lib/locales/ko.js +639 -604
  27. package/lib/locales/nl.js +639 -604
  28. package/lib/locales/pl.js +653 -618
  29. package/lib/locales/pt-BR.js +639 -604
  30. package/lib/locales/ru.js +653 -618
  31. package/lib/locales/tr.js +639 -604
  32. package/lib/locales/vi.js +641 -606
  33. package/lib/locales/zh-CN.js +640 -605
  34. package/lib/locales/zh-TW.js +640 -605
  35. package/lib/markdown.js +101 -27
  36. package/lib/mcp-monitor.js +189 -0
  37. package/lib/mcp-skill.js +93 -93
  38. package/lib/mcp.js +865 -838
  39. package/lib/model-route.js +66 -0
  40. package/lib/providers.js +712 -712
  41. package/lib/screenshot.js +140 -0
  42. package/lib/splash-opencode.js +29 -26
  43. package/lib/startup.js +66 -48
  44. package/lib/term.js +53 -11
  45. package/lib/themes.js +101 -0
  46. package/lib/ui.js +685 -443
  47. package/lib/updates.js +5 -2
  48. package/lib/web-settings.js +52 -21
  49. package/package.json +160 -146
  50. 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
- const scope = (classes.find((c) => c && c !== 'hljs') ?? '').replace(/^hljs-/, '').replace(/_.*$/, '')
58
- const key = HLJS_COLOR_KEYS[scope]
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
- if (segWidth(text) + runeWidth(ch) > room) break
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
- segs.push(...clipSegLine(cell, widths[i]))
218
- const pad = widths[i] - cell.reduce((w, s) => w + segWidth(s.text), 0)
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
- const codeBase = makeStyle({ fg: theme.markdownCodeBlock, bg: theme.codeBg })
360
- const prefix = prefixOf(state) + ' '
361
- ctx.lines.push([])
362
- for (const line of highlightLines(t.content.replace(/\n$/, ''), lang, theme, codeBase)) {
363
- ctx.lines.push([{ text: prefix, style: codeBase }, ...line])
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
+ }