deepseek-harness-skill-mcp 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 +201 -0
- package/README.md +152 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +675 -0
- package/package.json +70 -0
- package/src/client/controller.js +41 -0
- package/src/client/index.jsx +262 -0
- package/src/client/locales.js +65 -0
- package/src/client/styles.css +34 -0
- package/src/index.js +54 -0
- package/src/skills.js +187 -0
- package/src/store.js +216 -0
- package/src/web.js +78 -0
package/src/skills.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/** Filesystem inventory and focused authoring operations for managed Skill sources. */
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
3
|
+
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { basename, dirname, extname, join, relative, resolve } from 'node:path'
|
|
5
|
+
import { parseDocument, stringify } from 'yaml'
|
|
6
|
+
|
|
7
|
+
const skillNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
8
|
+
|
|
9
|
+
function splitSkill(content) {
|
|
10
|
+
const match = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(content)
|
|
11
|
+
if (!match) throw new Error('missing-frontmatter')
|
|
12
|
+
return { yaml: match[1], body: content.slice(match[0].length) }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function frontmatterBoolean(value, fallback) {
|
|
16
|
+
if (value === undefined) return fallback
|
|
17
|
+
if (typeof value === 'boolean') return value
|
|
18
|
+
if (typeof value === 'number' && (value === 0 || value === 1)) return value === 1
|
|
19
|
+
if (typeof value === 'string') {
|
|
20
|
+
const normalized = value.toLowerCase()
|
|
21
|
+
if (['true', 'yes', 'on', '1'].includes(normalized)) return true
|
|
22
|
+
if (['false', 'no', 'off', '0'].includes(normalized)) return false
|
|
23
|
+
}
|
|
24
|
+
throw new Error('invalid-invocation')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseFrontmatter(content) {
|
|
28
|
+
const parts = splitSkill(content)
|
|
29
|
+
const document = parseDocument(parts.yaml)
|
|
30
|
+
if (document.errors.length) throw new Error('invalid-yaml')
|
|
31
|
+
const data = document.toJS()
|
|
32
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('invalid-frontmatter')
|
|
33
|
+
const issues = []
|
|
34
|
+
if (typeof data.name !== 'string' || !skillNamePattern.test(data.name)) issues.push('invalid-name')
|
|
35
|
+
if (typeof data.description !== 'string' || !data.description.trim()) issues.push('missing-description')
|
|
36
|
+
let modelInvocable = true
|
|
37
|
+
let userInvocable = true
|
|
38
|
+
try {
|
|
39
|
+
modelInvocable = !frontmatterBoolean(data['disable-model-invocation'], false)
|
|
40
|
+
userInvocable = frontmatterBoolean(data['user-invocable'], true)
|
|
41
|
+
} catch { issues.push('invalid-invocation') }
|
|
42
|
+
return {
|
|
43
|
+
data,
|
|
44
|
+
body: parts.body,
|
|
45
|
+
issues,
|
|
46
|
+
name: typeof data.name === 'string' ? data.name : '',
|
|
47
|
+
description: typeof data.description === 'string' ? data.description.trim() : '',
|
|
48
|
+
whenToUse: typeof data.whenToUse === 'string' ? data.whenToUse.trim() : '',
|
|
49
|
+
modelInvocable,
|
|
50
|
+
userInvocable,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function listResources(directory) {
|
|
55
|
+
const resources = []
|
|
56
|
+
async function walk(current, depth) {
|
|
57
|
+
if (depth > 3 || resources.length >= 100) return
|
|
58
|
+
const entries = await readdir(current, { withFileTypes: true })
|
|
59
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
60
|
+
if (resources.length >= 100) break
|
|
61
|
+
if (entry.isSymbolicLink()) continue
|
|
62
|
+
const path = join(current, entry.name)
|
|
63
|
+
if (entry.isDirectory()) await walk(path, depth + 1)
|
|
64
|
+
else if (entry.isFile() && entry.name !== 'SKILL.md') resources.push(relative(directory, path).replaceAll('\\', '/'))
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
await walk(directory, 0)
|
|
68
|
+
return resources
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function inspectSkill(source, path, format) {
|
|
72
|
+
const relativePath = relative(source.location, path).replaceAll('\\', '/')
|
|
73
|
+
try {
|
|
74
|
+
const content = await readFile(path, 'utf8')
|
|
75
|
+
const parsed = parseFrontmatter(content)
|
|
76
|
+
const resources = format === 'bundle' ? await listResources(dirname(path)) : []
|
|
77
|
+
return {
|
|
78
|
+
sourceId: source.id,
|
|
79
|
+
sourceEnabled: source.enabled,
|
|
80
|
+
relativePath,
|
|
81
|
+
format,
|
|
82
|
+
name: parsed.name || basename(path, extname(path)),
|
|
83
|
+
description: parsed.description,
|
|
84
|
+
whenToUse: parsed.whenToUse,
|
|
85
|
+
modelInvocable: parsed.modelInvocable,
|
|
86
|
+
userInvocable: parsed.userInvocable,
|
|
87
|
+
valid: parsed.issues.length === 0,
|
|
88
|
+
issues: parsed.issues,
|
|
89
|
+
resources,
|
|
90
|
+
revision: createHash('sha256').update(content).digest('hex'),
|
|
91
|
+
}
|
|
92
|
+
} catch (error) {
|
|
93
|
+
const issue = ['missing-frontmatter', 'invalid-yaml', 'invalid-frontmatter'].includes(error.message)
|
|
94
|
+
? error.message : 'unreadable'
|
|
95
|
+
return {
|
|
96
|
+
sourceId: source.id, sourceEnabled: source.enabled, relativePath, format,
|
|
97
|
+
name: basename(path, extname(path)), description: '', whenToUse: '',
|
|
98
|
+
modelInvocable: false, userInvocable: false, valid: false, issues: [issue], resources: [], revision: '',
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Inspect direct Skill entries in every managed source without loading instructions into a session. */
|
|
104
|
+
export async function inspectSkills(rows) {
|
|
105
|
+
const sources = rows.filter(row => !row.config.serverName).map(row => ({
|
|
106
|
+
id: row.id, enabled: !row.disabled, location: row.config.customSkillDirs[0],
|
|
107
|
+
group: typeof row.config.extensionManagerGroup === 'string' ? row.config.extensionManagerGroup : '',
|
|
108
|
+
}))
|
|
109
|
+
const skills = []
|
|
110
|
+
for (const source of sources) {
|
|
111
|
+
try {
|
|
112
|
+
const entries = await readdir(source.location, { withFileTypes: true })
|
|
113
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
114
|
+
if (entry.isDirectory()) {
|
|
115
|
+
const path = join(source.location, entry.name, 'SKILL.md')
|
|
116
|
+
try { if ((await stat(path)).isFile()) skills.push(await inspectSkill(source, path, 'bundle')) } catch { /* not a Skill bundle */ }
|
|
117
|
+
} else if (entry.isFile() && extname(entry.name).toLowerCase() === '.md') {
|
|
118
|
+
skills.push(await inspectSkill(source, join(source.location, entry.name), 'flat'))
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
source.issue = ''
|
|
122
|
+
} catch { source.issue = 'source-unreadable' }
|
|
123
|
+
}
|
|
124
|
+
const winners = new Set()
|
|
125
|
+
for (const skill of skills) {
|
|
126
|
+
skill.shadowed = skill.valid && winners.has(skill.name)
|
|
127
|
+
if (skill.valid && !skill.shadowed) winners.add(skill.name)
|
|
128
|
+
}
|
|
129
|
+
return { sources, skills }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function skillSource(rows, sourceId) {
|
|
133
|
+
const row = rows.find(row => row.id === sourceId && !row.config.serverName)
|
|
134
|
+
if (!row) throw new Error('Unknown Skill source.')
|
|
135
|
+
return row.config.customSkillDirs[0]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Create an atomic directory Skill bundle inside one managed source. */
|
|
139
|
+
export async function createSkill(rows, input) {
|
|
140
|
+
if (!skillNamePattern.test(input.name)) throw new Error('Invalid Skill name.')
|
|
141
|
+
if (!input.description.trim()) throw new Error('Skill description is required.')
|
|
142
|
+
if (!['minimal', 'standard'].includes(input.structure)) throw new Error('Invalid Skill structure.')
|
|
143
|
+
const root = skillSource(rows, input.sourceId)
|
|
144
|
+
const target = resolve(root, input.name)
|
|
145
|
+
if (dirname(target) !== resolve(root)) throw new Error('Skill path escapes its source.')
|
|
146
|
+
const temporary = resolve(root, `.${input.name}.${randomUUID()}.tmp`)
|
|
147
|
+
const frontmatter = {
|
|
148
|
+
name: input.name,
|
|
149
|
+
description: input.description.trim(),
|
|
150
|
+
...(input.whenToUse.trim() ? { whenToUse: input.whenToUse.trim() } : {}),
|
|
151
|
+
...(input.modelInvocable ? {} : { 'disable-model-invocation': true }),
|
|
152
|
+
...(input.userInvocable ? {} : { 'user-invocable': false }),
|
|
153
|
+
}
|
|
154
|
+
await mkdir(temporary)
|
|
155
|
+
try {
|
|
156
|
+
await writeFile(join(temporary, 'SKILL.md'), `---\n${stringify(frontmatter)}---\n\n# ${input.name}\n\n${input.description.trim()}\n`)
|
|
157
|
+
if (input.structure === 'standard') {
|
|
158
|
+
await Promise.all(['scripts', 'references', 'assets'].map(name => mkdir(join(temporary, name))))
|
|
159
|
+
}
|
|
160
|
+
await rename(temporary, target)
|
|
161
|
+
} catch (error) {
|
|
162
|
+
await rm(temporary, { recursive: true, force: true })
|
|
163
|
+
throw error
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Update only invocation frontmatter after checking the exact inspected file revision. */
|
|
168
|
+
export async function setSkillInvocation(rows, input) {
|
|
169
|
+
const root = skillSource(rows, input.sourceId)
|
|
170
|
+
const inventory = await inspectSkills(rows)
|
|
171
|
+
const skill = inventory.skills.find(item => item.sourceId === input.sourceId && item.relativePath === input.relativePath)
|
|
172
|
+
if (!skill?.valid) throw new Error('Unknown or invalid Skill.')
|
|
173
|
+
const path = resolve(root, input.relativePath)
|
|
174
|
+
if (relative(root, path).startsWith('..')) throw new Error('Skill path escapes its source.')
|
|
175
|
+
const content = await readFile(path, 'utf8')
|
|
176
|
+
if (createHash('sha256').update(content).digest('hex') !== input.skillRevision) {
|
|
177
|
+
throw Object.assign(new Error('Skill changed. Refresh before saving.'), { code: 'CONFLICT' })
|
|
178
|
+
}
|
|
179
|
+
const parsed = parseFrontmatter(content)
|
|
180
|
+
if (input.modelInvocable) delete parsed.data['disable-model-invocation']
|
|
181
|
+
else parsed.data['disable-model-invocation'] = true
|
|
182
|
+
if (input.userInvocable) delete parsed.data['user-invocable']
|
|
183
|
+
else parsed.data['user-invocable'] = false
|
|
184
|
+
const temporary = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`)
|
|
185
|
+
await writeFile(temporary, `---\n${stringify(parsed.data)}---\n${parsed.body}`)
|
|
186
|
+
await rename(temporary, path)
|
|
187
|
+
}
|
package/src/store.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/** Owns a marked block inside a profile Cordis patch while retaining legacy JSON stores. */
|
|
2
|
+
import { open, readFile, rename, unlink, stat } from 'node:fs/promises'
|
|
3
|
+
import { basename, isAbsolute, resolve, win32 } from 'node:path'
|
|
4
|
+
import { randomUUID, createHash } from 'node:crypto'
|
|
5
|
+
import { Config as McpConfig } from '@deepseek-ai/dsh-mcp-client'
|
|
6
|
+
import { Config as SkillConfig } from '@deepseek-ai/dsh-skill-filesystem'
|
|
7
|
+
import { parse, stringify } from 'yaml'
|
|
8
|
+
|
|
9
|
+
const moduleNames = {
|
|
10
|
+
skill: '@deepseek-ai/dsh-skill-filesystem',
|
|
11
|
+
mcp: '@deepseek-ai/dsh-mcp-client',
|
|
12
|
+
}
|
|
13
|
+
const managerModule = new URL('./index.js', import.meta.url).href
|
|
14
|
+
const resolvedModules = Object.fromEntries(Object.entries(moduleNames).map(([kind, name]) => [kind, import.meta.resolve(name)]))
|
|
15
|
+
const isModule = (value, kind) => [moduleNames[kind], resolvedModules[kind], new URL('../src/index.ts', resolvedModules[kind]).href, new URL('../lib/index.js', resolvedModules[kind]).href].includes(value)
|
|
16
|
+
const idPattern = /^[A-Za-z0-9_-]{1,32}$/
|
|
17
|
+
const groupField = 'extensionManagerGroup'
|
|
18
|
+
const blockStart = '# dsh-skill-mcp:managed-start'
|
|
19
|
+
const blockEnd = '# dsh-skill-mcp:managed-end'
|
|
20
|
+
|
|
21
|
+
function normalizeWindowsPath(value) {
|
|
22
|
+
const trimmed = value.trim()
|
|
23
|
+
return /^[A-Za-z]:[\\/]/.test(trimmed) ? win32.normalize(trimmed) : trimmed
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function samePath(left, right) {
|
|
27
|
+
const windows = value => /^[A-Za-z]:[\\/]/.test(value)
|
|
28
|
+
if (windows(left) && windows(right)) return win32.normalize(left).toLowerCase() === win32.normalize(right).toLowerCase()
|
|
29
|
+
return resolve(left) === resolve(right)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function revisionOf(rows) {
|
|
33
|
+
return createHash('sha256').update(JSON.stringify(rows)).digest('hex')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function validateId(id) {
|
|
37
|
+
if (typeof id !== 'string' || !idPattern.test(id)) throw new Error('Id must contain 1–32 letters, digits, underscores or hyphens.')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parsePatch(value) {
|
|
41
|
+
if (!Array.isArray(value) || value.length !== 1 || !Array.isArray(value[0]?.insert)
|
|
42
|
+
|| Object.keys(value[0]).join() !== 'insert') throw new Error('Expected one managed insert operation.')
|
|
43
|
+
const ids = new Set()
|
|
44
|
+
const managed = []
|
|
45
|
+
const preserved = []
|
|
46
|
+
for (const row of value[0].insert) {
|
|
47
|
+
if (row?.id === 'extension-manager' && row.name === managerModule) {
|
|
48
|
+
if (ids.has(row.id)) throw new Error('Duplicate extension id.')
|
|
49
|
+
ids.add(row.id)
|
|
50
|
+
preserved.push(row)
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
if (!row || typeof row !== 'object' || Object.keys(row).sort().join() !== 'config,disabled,id,name') throw new Error('Invalid managed row.')
|
|
54
|
+
validateId(row.id)
|
|
55
|
+
if (ids.has(row.id)) throw new Error('Duplicate extension id.')
|
|
56
|
+
ids.add(row.id)
|
|
57
|
+
if (typeof row.disabled !== 'boolean') throw new Error('Invalid enabled state.')
|
|
58
|
+
if (isModule(row.name, 'skill')) {
|
|
59
|
+
SkillConfig(row.config)
|
|
60
|
+
if (row.config.providerName !== `managed-${row.id}` || row.config.includeDefaultRoots !== false
|
|
61
|
+
|| row.config.customSkillDirs?.length !== 1 || !isAbsolute(row.config.customSkillDirs[0])) throw new Error('Invalid managed skill source.')
|
|
62
|
+
} else if (isModule(row.name, 'mcp')) {
|
|
63
|
+
McpConfig(row.config)
|
|
64
|
+
if (row.config.serverName !== row.id) throw new Error('MCP namespace must match its id.')
|
|
65
|
+
} else throw new Error('Unknown managed plugin module.')
|
|
66
|
+
managed.push(row)
|
|
67
|
+
}
|
|
68
|
+
return { managed, preserved }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Validate the patch and return only extension rows owned by the manager. */
|
|
72
|
+
export function validatePatch(value) {
|
|
73
|
+
return parsePatch(value).managed
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function markedRows(content) {
|
|
77
|
+
const start = content.indexOf(blockStart)
|
|
78
|
+
const end = content.indexOf(blockEnd)
|
|
79
|
+
if (start < 0 && end < 0) return undefined
|
|
80
|
+
if (start < 0 || end < start || content.indexOf(blockStart, start + blockStart.length) >= 0
|
|
81
|
+
|| content.indexOf(blockEnd, end + blockEnd.length) >= 0) throw new Error('Invalid dsh-skill-mcp managed block.')
|
|
82
|
+
const bodyStart = start + blockStart.length
|
|
83
|
+
return { start, end: end + blockEnd.length, rows: validatePatch(parse(content.slice(bodyStart, end))) }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function legacyPatch(content) {
|
|
87
|
+
try { return parsePatch(JSON.parse(content)) } catch { return undefined }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function renderBlock(rows) {
|
|
91
|
+
validatePatch([{ insert: rows }])
|
|
92
|
+
return `${blockStart}\n${stringify([{ insert: rows }]).trimEnd()}\n${blockEnd}`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function writeManagedRows(content, rows) {
|
|
96
|
+
const marked = markedRows(content)
|
|
97
|
+
const block = renderBlock(rows)
|
|
98
|
+
if (marked) return `${content.slice(0, marked.start)}${block}${content.slice(marked.end)}`
|
|
99
|
+
const legacy = legacyPatch(content)
|
|
100
|
+
if (legacy) return `${JSON.stringify([{ insert: [...legacy.preserved, ...rows] }], null, 2)}\n`
|
|
101
|
+
if (/^[ \t]*\[\][ \t]*$/m.test(content)) return content.replace(/^[ \t]*\[\][ \t]*$/m, block)
|
|
102
|
+
return `${content.trimEnd()}\n\n${block}\n`
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A store uses an exclusive cross-process lock and atomically replaces its patch. */
|
|
106
|
+
export class ExtensionStore {
|
|
107
|
+
constructor(path) {
|
|
108
|
+
if (!isAbsolute(path)) throw new Error('patchPath must be absolute.')
|
|
109
|
+
this.path = path
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async read() {
|
|
113
|
+
const content = await readFile(this.path, 'utf8')
|
|
114
|
+
return markedRows(content)?.rows ?? legacyPatch(content)?.managed ?? []
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async update(change, signal, expectedRevision) {
|
|
118
|
+
signal?.throwIfAborted()
|
|
119
|
+
const lockPath = `${this.path}.lock`
|
|
120
|
+
const lock = await open(lockPath, 'wx', 0o600)
|
|
121
|
+
const temp = `${this.path}.${randomUUID()}.tmp`
|
|
122
|
+
let tempExists = false
|
|
123
|
+
try {
|
|
124
|
+
const content = await readFile(this.path, 'utf8')
|
|
125
|
+
const rows = markedRows(content)?.rows ?? legacyPatch(content)?.managed ?? []
|
|
126
|
+
if (expectedRevision !== undefined && revisionOf(rows) !== expectedRevision) {
|
|
127
|
+
throw Object.assign(new Error('Configuration changed. Refresh before saving.'), { code: 'CONFLICT' })
|
|
128
|
+
}
|
|
129
|
+
await change(rows)
|
|
130
|
+
const document = writeManagedRows(content, rows)
|
|
131
|
+
signal?.throwIfAborted()
|
|
132
|
+
const file = await open(temp, 'wx', 0o600)
|
|
133
|
+
tempExists = true
|
|
134
|
+
try {
|
|
135
|
+
await file.writeFile(document)
|
|
136
|
+
await file.sync()
|
|
137
|
+
} finally { await file.close() }
|
|
138
|
+
signal?.throwIfAborted()
|
|
139
|
+
await rename(temp, this.path)
|
|
140
|
+
tempExists = false
|
|
141
|
+
} finally {
|
|
142
|
+
try { if (tempExists) await unlink(temp) }
|
|
143
|
+
finally { await lock.close(); await unlink(lockPath) }
|
|
144
|
+
}
|
|
145
|
+
return { saved: true, activation: 'pending-profile-reload', patchPath: this.path }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async addSkill(id, directory, signal, expectedRevision) {
|
|
149
|
+
validateId(id)
|
|
150
|
+
const normalizedDirectory = normalizeWindowsPath(directory)
|
|
151
|
+
if (!isAbsolute(normalizedDirectory) || !(await stat(normalizedDirectory)).isDirectory()) throw new Error('Skill root must be an existing absolute directory containing skill bundles.')
|
|
152
|
+
return this.add({ id, name: moduleNames.skill, disabled: true, config: {
|
|
153
|
+
providerName: `managed-${id}`, includeDefaultRoots: false, customSkillDirs: [normalizedDirectory],
|
|
154
|
+
} }, signal, expectedRevision)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async addSkillPath(directory, group, signal, expectedRevision) {
|
|
158
|
+
const normalizedDirectory = normalizeWindowsPath(directory)
|
|
159
|
+
if (!isAbsolute(normalizedDirectory) || !(await stat(normalizedDirectory)).isDirectory()) throw new Error('Skill root must be an existing absolute directory containing skill bundles.')
|
|
160
|
+
return this.update(rows => {
|
|
161
|
+
if (rows.some(row => !row.config.serverName && samePath(row.config.customSkillDirs[0], normalizedDirectory))) {
|
|
162
|
+
throw new Error('Skill path already exists.')
|
|
163
|
+
}
|
|
164
|
+
const stem = basename(normalizedDirectory).replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'skills'
|
|
165
|
+
let id = stem
|
|
166
|
+
for (let suffix = 2; rows.some(row => row.id === id); suffix++) id = `${stem.slice(0, 27)}-${suffix}`
|
|
167
|
+
const normalizedGroup = typeof group === 'string' ? group.trim().slice(0, 64) : ''
|
|
168
|
+
rows.push({ id, name: moduleNames.skill, disabled: true, config: {
|
|
169
|
+
providerName: `managed-${id}`, includeDefaultRoots: false, customSkillDirs: [normalizedDirectory],
|
|
170
|
+
...(normalizedGroup ? { [groupField]: normalizedGroup } : {}),
|
|
171
|
+
} })
|
|
172
|
+
}, signal, expectedRevision)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async setSkillGroup(id, group, signal, expectedRevision) {
|
|
176
|
+
return this.update(rows => {
|
|
177
|
+
const row = rows.find(row => row.id === id && !row.config.serverName)
|
|
178
|
+
if (!row) throw new Error('Unknown Skill path.')
|
|
179
|
+
const normalized = group.trim().slice(0, 64)
|
|
180
|
+
if (normalized) row.config[groupField] = normalized
|
|
181
|
+
else delete row.config[groupField]
|
|
182
|
+
}, signal, expectedRevision)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async addMcp(id, input, signal, expectedRevision) {
|
|
186
|
+
validateId(id)
|
|
187
|
+
// Authentication values belong in the host credential setup, not tool arguments or logs.
|
|
188
|
+
if (input.env || input.headers) throw new Error('This version does not accept environment values or authentication headers.')
|
|
189
|
+
const normalized = input.transport === 'stdio' ? {
|
|
190
|
+
...input,
|
|
191
|
+
command: typeof input.command === 'string' ? normalizeWindowsPath(input.command) : input.command,
|
|
192
|
+
cwd: typeof input.cwd === 'string' && input.cwd ? normalizeWindowsPath(input.cwd) : input.cwd,
|
|
193
|
+
} : input
|
|
194
|
+
const config = McpConfig({ ...normalized, serverName: id, failOnStartupError: true })
|
|
195
|
+
if (config.transport === 'streamable-http') {
|
|
196
|
+
const url = new URL(config.url)
|
|
197
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('Use an HTTP(S) endpoint without credentials, query or fragment.')
|
|
198
|
+
} else if (!config.command.trim()) throw new Error('MCP command cannot be empty.')
|
|
199
|
+
return this.add({ id, name: moduleNames.mcp, disabled: true, config }, signal, expectedRevision)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async add(row, signal, expectedRevision) {
|
|
203
|
+
return this.update(rows => {
|
|
204
|
+
if (rows.some(existing => existing.id === row.id)) throw new Error('Extension id already exists.')
|
|
205
|
+
rows.push(row)
|
|
206
|
+
}, signal, expectedRevision)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async setEnabled(id, enabled, signal, expectedRevision) {
|
|
210
|
+
return this.update(rows => {
|
|
211
|
+
const row = rows.find(row => row.id === id)
|
|
212
|
+
if (!row) throw new Error('Unknown extension id.')
|
|
213
|
+
row.disabled = !enabled
|
|
214
|
+
}, signal, expectedRevision)
|
|
215
|
+
}
|
|
216
|
+
}
|
package/src/web.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Authenticated Connection channel for the independent extension settings page. */
|
|
2
|
+
import Schema from '@deepseek-ai/schemastery'
|
|
3
|
+
import { revisionOf } from './store.js'
|
|
4
|
+
import { createSkill, inspectSkills, setSkillInvocation } from './skills.js'
|
|
5
|
+
|
|
6
|
+
const text = Schema.string().required()
|
|
7
|
+
const schemas = {
|
|
8
|
+
list: Schema.object({}),
|
|
9
|
+
'add-skill': Schema.object({ directory: text, group: Schema.string().required(), revision: text }),
|
|
10
|
+
'add-mcp': Schema.object({ id: text, configuration: text, revision: text }),
|
|
11
|
+
enable: Schema.object({ id: text, enabled: Schema.boolean().required(), revision: text }),
|
|
12
|
+
'set-skill-group': Schema.object({ id: text, group: Schema.string().required(), revision: text }),
|
|
13
|
+
'create-skill': Schema.object({
|
|
14
|
+
sourceId: text, name: text, description: text, whenToUse: Schema.string().required(),
|
|
15
|
+
modelInvocable: Schema.boolean().required(), userInvocable: Schema.boolean().required(),
|
|
16
|
+
structure: text, revision: text,
|
|
17
|
+
}),
|
|
18
|
+
'set-skill-invocation': Schema.object({
|
|
19
|
+
sourceId: text, relativePath: text, skillRevision: text,
|
|
20
|
+
modelInvocable: Schema.boolean().required(), userInvocable: Schema.boolean().required(), revision: text,
|
|
21
|
+
}),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Return redacted configuration and observed global tool availability. */
|
|
25
|
+
export async function describeExtensions(store, ctx) {
|
|
26
|
+
const rows = await store.read()
|
|
27
|
+
const toolNames = ctx.tools.schemas().map(tool => tool.name)
|
|
28
|
+
const inventory = await inspectSkills(rows)
|
|
29
|
+
return {
|
|
30
|
+
revision: revisionOf(rows),
|
|
31
|
+
...inventory,
|
|
32
|
+
extensions: rows.map(row => ({
|
|
33
|
+
id: row.id,
|
|
34
|
+
kind: row.config.serverName ? 'mcp' : 'skill',
|
|
35
|
+
enabled: !row.disabled,
|
|
36
|
+
location: row.config.transport === 'stdio' ? row.config.command
|
|
37
|
+
: row.config.transport === 'streamable-http' ? new URL(row.config.url).origin
|
|
38
|
+
: row.config.customSkillDirs[0],
|
|
39
|
+
transport: row.config.transport ?? '',
|
|
40
|
+
tools: row.config.serverName
|
|
41
|
+
? toolNames.filter(name => name.startsWith(`mcp__${row.id}__`)).sort() : [],
|
|
42
|
+
})),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** All writes carry the exact revision shown to the user. */
|
|
47
|
+
export function createWebHandler(store, ctx) {
|
|
48
|
+
return async (endpoint, payload, signal) => {
|
|
49
|
+
try {
|
|
50
|
+
if (!Object.hasOwn(schemas, endpoint)) throw new Error('Unknown operation.')
|
|
51
|
+
const args = schemas[endpoint](payload)
|
|
52
|
+
signal.throwIfAborted()
|
|
53
|
+
if (endpoint === 'add-skill') await store.addSkillPath(args.directory, args.group, signal, args.revision)
|
|
54
|
+
if (endpoint === 'add-mcp') {
|
|
55
|
+
const input = JSON.parse(args.configuration)
|
|
56
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Expected an MCP configuration object.')
|
|
57
|
+
await store.addMcp(args.id, input, signal, args.revision)
|
|
58
|
+
}
|
|
59
|
+
if (endpoint === 'enable') await store.setEnabled(args.id, args.enabled, signal, args.revision)
|
|
60
|
+
if (endpoint === 'set-skill-group') await store.setSkillGroup(args.id, args.group, signal, args.revision)
|
|
61
|
+
if (endpoint === 'create-skill' || endpoint === 'set-skill-invocation') {
|
|
62
|
+
const rows = await store.read()
|
|
63
|
+
if (revisionOf(rows) !== args.revision) {
|
|
64
|
+
throw Object.assign(new Error('Configuration changed. Refresh before saving.'), { code: 'CONFLICT' })
|
|
65
|
+
}
|
|
66
|
+
if (endpoint === 'create-skill') await createSkill(rows, args)
|
|
67
|
+
else await setSkillInvocation(rows, args)
|
|
68
|
+
}
|
|
69
|
+
return { ok: true, value: await describeExtensions(store, ctx) }
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return { ok: false, error: {
|
|
72
|
+
code: error.code === 'CONFLICT' ? 'extensions/conflict' : 'extensions/rejected',
|
|
73
|
+
// Validation errors can quote the submitted configuration; keep it off the response.
|
|
74
|
+
message: 'Extension operation failed.', details: {},
|
|
75
|
+
} }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|