dsh-plugin-capabilities 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 +21 -0
- package/README.md +52 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +863 -0
- package/lib/client.js.map +7 -0
- package/lib/index.js +1514 -0
- package/lib/index.js.map +7 -0
- package/package.json +64 -0
- package/src/agents.test.ts +105 -0
- package/src/agents.ts +135 -0
- package/src/client/McpTab.tsx +411 -0
- package/src/client/SkillsTab.tsx +301 -0
- package/src/client/css.ts +47 -0
- package/src/client/desktop-bridge.d.ts +12 -0
- package/src/client/index.ts +108 -0
- package/src/client/locales.ts +131 -0
- package/src/client/primitives.d.ts +57 -0
- package/src/http.ts +42 -0
- package/src/index.ts +56 -0
- package/src/mcp.test.ts +85 -0
- package/src/mcp.ts +170 -0
- package/src/profile.ts +17 -0
- package/src/routes.ts +285 -0
- package/src/skills.test.ts +73 -0
- package/src/skills.ts +81 -0
- package/src/smoke.test.ts +150 -0
- package/src/types.ts +34 -0
package/src/http.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** HTTP helpers: JSON body reading, same-origin check, JSON responses. */
|
|
2
|
+
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
4
|
+
|
|
5
|
+
/** Read and JSON-parse a request body, bounded to 1 MiB (skill bodies live here). */
|
|
6
|
+
export async function readJsonBody(request: IncomingMessage): Promise<unknown> {
|
|
7
|
+
const chunks: Buffer[] = []
|
|
8
|
+
let received = 0
|
|
9
|
+
for await (const chunk of request) {
|
|
10
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
11
|
+
received += buffer.length
|
|
12
|
+
if (received > 1024 * 1024) throw new Error('request body too large')
|
|
13
|
+
chunks.push(buffer)
|
|
14
|
+
}
|
|
15
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* True when the request is a same-origin POST a browser page could have made.
|
|
20
|
+
* CSRF fence (the loopback server already trusts its local peer for reads).
|
|
21
|
+
*/
|
|
22
|
+
export function sameOrigin(request: IncomingMessage): boolean {
|
|
23
|
+
const origin = request.headers.origin
|
|
24
|
+
const host = request.headers.host
|
|
25
|
+
if (origin === undefined || host === undefined) return false
|
|
26
|
+
try {
|
|
27
|
+
const parsed = new URL(origin)
|
|
28
|
+
return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.host === host
|
|
29
|
+
} catch {
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Write a JSON response. */
|
|
35
|
+
export function sendJson(response: ServerResponse, status: number, body: unknown): void {
|
|
36
|
+
const payload = JSON.stringify(body)
|
|
37
|
+
response.writeHead(status, {
|
|
38
|
+
'content-type': 'application/json; charset=utf-8',
|
|
39
|
+
'cache-control': 'no-store',
|
|
40
|
+
})
|
|
41
|
+
response.end(payload)
|
|
42
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** dsh-plugin-capabilities host entry: mount the manager's HTTP routes once
|
|
2
|
+
* the profile composes both the web server and the skill registry, and mount
|
|
3
|
+
* a host-plane filesystem skill provider so the Settings page sees a live
|
|
4
|
+
* catalog (the web composition deliberately leaves the host row to presets). */
|
|
5
|
+
|
|
6
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
7
|
+
import { agentSkillRoots } from './agents.ts'
|
|
8
|
+
import { argvProfile, profileDir } from './profile.ts'
|
|
9
|
+
import { mountCapabilitiesRoutes } from './routes.ts'
|
|
10
|
+
import type { CapabilitiesHost } from './types.ts'
|
|
11
|
+
|
|
12
|
+
export const name = 'dsh-plugin-capabilities'
|
|
13
|
+
|
|
14
|
+
/** Optional cordis.yml configuration; profile defaults to the booted one. */
|
|
15
|
+
export interface Config {
|
|
16
|
+
/** Profile whose patch layer holds the MCP rows; defaults to argv or `web`. */
|
|
17
|
+
profile?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const inject = ['webServer', 'skills']
|
|
21
|
+
|
|
22
|
+
/** The provider plugin's structural shape (name/apply export). */
|
|
23
|
+
interface FilesystemSkillPlugin {
|
|
24
|
+
name: string
|
|
25
|
+
apply(context: Context, config?: unknown): void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function apply(ctx: Context, config?: Config): void {
|
|
29
|
+
const profile = config?.profile ?? argvProfile() ?? 'web'
|
|
30
|
+
ctx.inject(['webServer', 'skills'], (hostCtx: Context) => {
|
|
31
|
+
// The web bundle disables the host-plane `skill-filesystem` row on
|
|
32
|
+
// purpose (presets own per-session discovery). The Settings manager
|
|
33
|
+
// mounts its own host-plane provider as a CHILD of this plugin: it dies
|
|
34
|
+
// with us, registers into the registry's global layer, and preset layers
|
|
35
|
+
// keep their semantics (nearest layer still wins duplicate names). Other
|
|
36
|
+
// agents' skill roots (~/.claude/skills, ~/.codex/skills) join as custom
|
|
37
|
+
// dirs — zero-copy, live-synced both ways. A failed load only means an
|
|
38
|
+
// empty catalog — the routes keep serving.
|
|
39
|
+
void (async () => {
|
|
40
|
+
try {
|
|
41
|
+
const mod = (await import('@deepseek-ai/dsh-skill-filesystem')) as unknown as
|
|
42
|
+
(FilesystemSkillPlugin & { default?: FilesystemSkillPlugin })
|
|
43
|
+
const plugin = mod.default ?? mod
|
|
44
|
+
const roots = agentSkillRoots()
|
|
45
|
+
hostCtx.plugin(plugin, roots.length > 0 ? { customSkillDirs: roots } : {})
|
|
46
|
+
} catch {
|
|
47
|
+
// Unresolvable provider: skills list stays empty; MCP tab unaffected.
|
|
48
|
+
}
|
|
49
|
+
})()
|
|
50
|
+
|
|
51
|
+
ctx.effect(
|
|
52
|
+
() => mountCapabilitiesRoutes(hostCtx as unknown as CapabilitiesHost, { profileDirPath: profileDir(profile) }),
|
|
53
|
+
'dsh-plugin-capabilities: http routes',
|
|
54
|
+
)
|
|
55
|
+
})
|
|
56
|
+
}
|
package/src/mcp.test.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterAll, describe, expect, it } from 'vitest'
|
|
5
|
+
import { listMcp, removeMcp, setMcpDisabled, upsertMcp, validateMcpInput, type McpInput } from './mcp.ts'
|
|
6
|
+
|
|
7
|
+
const root = mkdtempSync(join(tmpdir(), 'dsh-caps-mcp-'))
|
|
8
|
+
const profile = join(root, 'profiles', 'web')
|
|
9
|
+
afterAll(() => rmSync(root, { recursive: true, force: true }))
|
|
10
|
+
|
|
11
|
+
const patch = () => join(profile, 'cordis.patch.yml')
|
|
12
|
+
|
|
13
|
+
const stdio: McpInput = {
|
|
14
|
+
id: '',
|
|
15
|
+
serverName: 'github',
|
|
16
|
+
transport: 'stdio',
|
|
17
|
+
command: 'npx',
|
|
18
|
+
args: ['-y', '@modelcontextprotocol/server-github'],
|
|
19
|
+
env: { GITHUB_TOKEN: 'secret' },
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('validateMcpInput', () => {
|
|
23
|
+
it('enforces serverName grammar and per-transport requirements', () => {
|
|
24
|
+
expect(validateMcpInput(stdio)).toBeNull()
|
|
25
|
+
expect(validateMcpInput({ ...stdio, serverName: 'has space' })).toContain('serverName')
|
|
26
|
+
expect(validateMcpInput({ ...stdio, command: ' ' })).toContain('command')
|
|
27
|
+
expect(validateMcpInput({ id: '', serverName: 'web', transport: 'streamable-http' })).toContain('url')
|
|
28
|
+
expect(validateMcpInput({ id: '', serverName: 'web', transport: 'streamable-http', url: 'ftp://x' })).toContain('url')
|
|
29
|
+
expect(validateMcpInput({ id: 'a/b', serverName: 'web', transport: 'streamable-http', url: 'http://x' })).toContain('id')
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('profile patch CRUD', () => {
|
|
34
|
+
it('creates rows in a missing file, dedupes ids, and reads them back', () => {
|
|
35
|
+
const id1 = upsertMcp(profile, stdio)
|
|
36
|
+
expect(id1).toBe('mcp-github')
|
|
37
|
+
const id2 = upsertMcp(profile, { ...stdio, env: undefined })
|
|
38
|
+
expect(id2).toBe('mcp-github-2')
|
|
39
|
+
|
|
40
|
+
const rows = listMcp(profile)
|
|
41
|
+
expect(rows).toHaveLength(2)
|
|
42
|
+
expect(rows[0]).toMatchObject({ id: 'mcp-github', serverName: 'github', transport: 'stdio', command: 'npx', disabled: false })
|
|
43
|
+
expect(rows[0].args).toEqual(['-y', '@modelcontextprotocol/server-github'])
|
|
44
|
+
expect(rows[0].env).toEqual({ GITHUB_TOKEN: 'secret' })
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('preserves foreign rows and comments across edits', () => {
|
|
48
|
+
writeFileSync(patch(), [
|
|
49
|
+
'# user comment',
|
|
50
|
+
'- id: something-else',
|
|
51
|
+
" name: 'other-plugin'",
|
|
52
|
+
' config:',
|
|
53
|
+
' a: 1',
|
|
54
|
+
'',
|
|
55
|
+
].join('\n'))
|
|
56
|
+
upsertMcp(profile, { ...stdio, serverName: 'web', transport: 'streamable-http', url: 'http://localhost:3000/mcp' })
|
|
57
|
+
const text = readFileSync(patch(), 'utf8')
|
|
58
|
+
expect(text).toContain('# user comment')
|
|
59
|
+
expect(text).toContain('something-else')
|
|
60
|
+
expect(text).toContain('streamable-http')
|
|
61
|
+
expect(listMcp(profile)).toHaveLength(1)
|
|
62
|
+
expect(listMcp(profile)[0]).toMatchObject({ transport: 'streamable-http', url: 'http://localhost:3000/mcp' })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('updates an existing row in place when the id matches', () => {
|
|
66
|
+
upsertMcp(profile, stdio)
|
|
67
|
+
expect(listMcp(profile)).toHaveLength(2)
|
|
68
|
+
upsertMcp(profile, { ...stdio, id: 'mcp-github', command: 'pnpm' })
|
|
69
|
+
const rows = listMcp(profile)
|
|
70
|
+
expect(rows).toHaveLength(2)
|
|
71
|
+
expect(rows.find(row => row.id === 'mcp-github')?.command).toBe('pnpm')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('toggles disabled and removes rows', () => {
|
|
75
|
+
expect(setMcpDisabled(profile, 'mcp-github', true)).toBe(true)
|
|
76
|
+
expect(listMcp(profile).find(row => row.id === 'mcp-github')?.disabled).toBe(true)
|
|
77
|
+
expect(setMcpDisabled(profile, 'mcp-github', false)).toBe(true)
|
|
78
|
+
expect(listMcp(profile).find(row => row.id === 'mcp-github')?.disabled).toBe(false)
|
|
79
|
+
expect(setMcpDisabled(profile, 'no-such', true)).toBe(false)
|
|
80
|
+
|
|
81
|
+
expect(removeMcp(profile, 'mcp-github')).toBe(true)
|
|
82
|
+
expect(listMcp(profile).find(row => row.id === 'mcp-github')).toBeUndefined()
|
|
83
|
+
expect(removeMcp(profile, 'mcp-github')).toBe(false)
|
|
84
|
+
})
|
|
85
|
+
})
|
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server rows in the profile's own patch layer: one
|
|
3
|
+
* `@deepseek-ai/dsh-mcp-client` row per server. The YAML document API keeps
|
|
4
|
+
* foreign rows and comments intact across edits. Row changes need a dsh
|
|
5
|
+
* restart to compose — callers surface that as a pending-restart notice.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { parseDocument, Document, type YAMLMap, type YAMLSeq } from 'yaml'
|
|
11
|
+
/** The plugin every managed row instantiates. */
|
|
12
|
+
export const MCP_PLUGIN = '@deepseek-ai/dsh-mcp-client'
|
|
13
|
+
|
|
14
|
+
/** MCP serverName grammar (dsh-mcp-client's contract). */
|
|
15
|
+
export const SERVER_NAME_RE = /^[A-Za-z0-9_-]{1,32}$/
|
|
16
|
+
|
|
17
|
+
/** Transport choices the client supports. */
|
|
18
|
+
export type McpTransport = 'stdio' | 'streamable-http'
|
|
19
|
+
|
|
20
|
+
/** One managed row, as shown to the browser. */
|
|
21
|
+
export interface McpRow {
|
|
22
|
+
id: string
|
|
23
|
+
serverName: string
|
|
24
|
+
transport: McpTransport
|
|
25
|
+
disabled: boolean
|
|
26
|
+
command?: string
|
|
27
|
+
args?: string[]
|
|
28
|
+
env?: Record<string, string>
|
|
29
|
+
cwd?: string
|
|
30
|
+
url?: string
|
|
31
|
+
headers?: Record<string, string>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Write request for one server row (id empty = create). */
|
|
35
|
+
export type McpInput = Omit<McpRow, 'disabled'> & { disabled?: boolean }
|
|
36
|
+
|
|
37
|
+
/** Load the profile patch as a YAML document; `[]` for a missing file. */
|
|
38
|
+
function loadPatch(profileDirPath: string): Document {
|
|
39
|
+
const path = join(profileDirPath, 'cordis.patch.yml')
|
|
40
|
+
const text = existsSync(path) ? readFileSync(path, 'utf8') : '[]'
|
|
41
|
+
return parseDocument(text)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function savePatch(profileDirPath: string, doc: Document): void {
|
|
45
|
+
mkdirSync(profileDirPath, { recursive: true })
|
|
46
|
+
writeFileSync(join(profileDirPath, 'cordis.patch.yml'), String(doc), 'utf8')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Wrap a plain value into a YAML node (yaml v2 exposes no standalone createNode). */
|
|
50
|
+
function toNode<T>(value: unknown): T {
|
|
51
|
+
return new Document(value as never).contents as T
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The patch row sequence; an empty file's null root becomes an empty seq. */
|
|
55
|
+
function rowSeq(doc: Document): YAMLSeq<YAMLMap> {
|
|
56
|
+
if (doc.contents === null) doc.contents = toNode<YAMLSeq<YAMLMap>>([])
|
|
57
|
+
return doc.contents as YAMLSeq<YAMLMap>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Rows whose `name` is the MCP client plugin. */
|
|
61
|
+
function mcpRows(doc: Document): YAMLMap[] {
|
|
62
|
+
return (rowSeq(doc).items ?? []).filter(item => item.get('name') === MCP_PLUGIN)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isStringMap(value: unknown): value is Record<string, string> {
|
|
66
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
|
67
|
+
return Object.values(value).every(entry => typeof entry === 'string')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Read every mcp-client row in the profile layer. */
|
|
71
|
+
export function listMcp(profileDirPath: string): McpRow[] {
|
|
72
|
+
const doc = loadPatch(profileDirPath)
|
|
73
|
+
return mcpRows(doc).map(item => {
|
|
74
|
+
// config is a YAMLMap node — materialize it before property access.
|
|
75
|
+
const configNode = item.get('config') as unknown
|
|
76
|
+
const plain = (typeof configNode === 'object' && configNode !== null && typeof (configNode as { toJS?: unknown }).toJS === 'function'
|
|
77
|
+
? (configNode as { toJS(document: Document): unknown }).toJS(doc)
|
|
78
|
+
: {}) as Record<string, unknown>
|
|
79
|
+
return {
|
|
80
|
+
id: String(item.get('id') ?? ''),
|
|
81
|
+
serverName: String(plain.serverName ?? ''),
|
|
82
|
+
transport: plain.transport === 'streamable-http' ? 'streamable-http' : 'stdio',
|
|
83
|
+
disabled: item.get('disabled') === true,
|
|
84
|
+
...(typeof plain.command === 'string' && plain.command !== '' ? { command: plain.command } : {}),
|
|
85
|
+
...(Array.isArray(plain.args) ? { args: plain.args.map(String) } : {}),
|
|
86
|
+
...(isStringMap(plain.env) ? { env: plain.env } : {}),
|
|
87
|
+
...(typeof plain.cwd === 'string' && plain.cwd !== '' ? { cwd: plain.cwd } : {}),
|
|
88
|
+
...(typeof plain.url === 'string' && plain.url !== '' ? { url: plain.url } : {}),
|
|
89
|
+
...(isStringMap(plain.headers) ? { headers: plain.headers } : {}),
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Validate one write request; returns the rejection reason or null. */
|
|
95
|
+
export function validateMcpInput(input: McpInput): string | null {
|
|
96
|
+
if (!SERVER_NAME_RE.test(input.serverName)) return 'serverName must be 1-32 chars of A-Z a-z 0-9 _ -'
|
|
97
|
+
if (input.id.includes('/') || input.id.includes('..')) return 'invalid id'
|
|
98
|
+
if (input.transport === 'stdio') {
|
|
99
|
+
if (input.command === undefined || input.command.trim() === '') return 'stdio transport requires a command'
|
|
100
|
+
} else if (input.url === undefined || !/^https?:\/\//.test(input.url)) {
|
|
101
|
+
return 'http transport requires an http(s) url'
|
|
102
|
+
}
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Add or replace one server row. Returns the (possibly deduplicated) id. */
|
|
107
|
+
export function upsertMcp(profileDirPath: string, input: McpInput): string {
|
|
108
|
+
const doc = loadPatch(profileDirPath)
|
|
109
|
+
const seq = rowSeq(doc)
|
|
110
|
+
|
|
111
|
+
const existing = input.id !== ''
|
|
112
|
+
? mcpRows(doc).find(item => item.get('id') === input.id)
|
|
113
|
+
: undefined
|
|
114
|
+
|
|
115
|
+
let id = input.id !== '' ? input.id : `mcp-${input.serverName}`
|
|
116
|
+
if (existing === undefined) {
|
|
117
|
+
const taken = new Set(
|
|
118
|
+
(seq.items ?? []).map(item => String(item.get('id') ?? '')).filter(id => id !== ''),
|
|
119
|
+
)
|
|
120
|
+
let suffix = 2
|
|
121
|
+
while (taken.has(id)) id = `mcp-${input.serverName}-${suffix++}`
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const config: Record<string, unknown> = input.transport === 'stdio'
|
|
125
|
+
? {
|
|
126
|
+
serverName: input.serverName,
|
|
127
|
+
transport: input.transport,
|
|
128
|
+
command: input.command,
|
|
129
|
+
...(input.args !== undefined && input.args.length > 0 ? { args: input.args } : {}),
|
|
130
|
+
...(input.env !== undefined && Object.keys(input.env).length > 0 ? { env: input.env } : {}),
|
|
131
|
+
...(input.cwd !== undefined && input.cwd !== '' ? { cwd: input.cwd } : {}),
|
|
132
|
+
}
|
|
133
|
+
: {
|
|
134
|
+
serverName: input.serverName,
|
|
135
|
+
transport: input.transport,
|
|
136
|
+
url: input.url,
|
|
137
|
+
...(input.headers !== undefined && Object.keys(input.headers).length > 0 ? { headers: input.headers } : {}),
|
|
138
|
+
}
|
|
139
|
+
const row: Record<string, unknown> = { id, name: MCP_PLUGIN, config }
|
|
140
|
+
if (input.disabled === true) row.disabled = true
|
|
141
|
+
|
|
142
|
+
const node = toNode<YAMLMap>(row)
|
|
143
|
+
if (existing === undefined) seq.add(node)
|
|
144
|
+
else seq.items[seq.items.indexOf(existing)] = node
|
|
145
|
+
|
|
146
|
+
savePatch(profileDirPath, doc)
|
|
147
|
+
return id
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Flip one row's disabled flag (absent = enabled). Returns false when missing. */
|
|
151
|
+
export function setMcpDisabled(profileDirPath: string, id: string, disabled: boolean): boolean {
|
|
152
|
+
const doc = loadPatch(profileDirPath)
|
|
153
|
+
const item = mcpRows(doc).find(row => row.get('id') === id)
|
|
154
|
+
if (item === undefined) return false
|
|
155
|
+
if (disabled) item.set('disabled', true)
|
|
156
|
+
else item.delete('disabled')
|
|
157
|
+
savePatch(profileDirPath, doc)
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Remove one server row. Returns false when missing. */
|
|
162
|
+
export function removeMcp(profileDirPath: string, id: string): boolean {
|
|
163
|
+
const doc = loadPatch(profileDirPath)
|
|
164
|
+
const item = mcpRows(doc).find(row => row.get('id') === id)
|
|
165
|
+
if (item === undefined) return false
|
|
166
|
+
const seq = rowSeq(doc)
|
|
167
|
+
seq.items.splice(seq.items.indexOf(item), 1)
|
|
168
|
+
savePatch(profileDirPath, doc)
|
|
169
|
+
return true
|
|
170
|
+
}
|
package/src/profile.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Profile discovery (pure reads; same contract as dsh-plugin-install). */
|
|
2
|
+
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
/** Profile that boots this UI: `--profile <name>` on the CLI invocation. */
|
|
7
|
+
export function argvProfile(argv: readonly string[] = process.argv): string | undefined {
|
|
8
|
+
const flag = argv.indexOf('--profile')
|
|
9
|
+
if (flag !== -1 && flag + 1 < argv.length && !argv[flag + 1].startsWith('-')) return argv[flag + 1]
|
|
10
|
+
return undefined
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Directory of a profile under DSH_HOME (default `~/.dsh`). */
|
|
14
|
+
export function profileDir(profile: string, dshHome: string | undefined = process.env.DSH_HOME): string {
|
|
15
|
+
const home = dshHome ?? join(homedir(), '.dsh')
|
|
16
|
+
return join(home, 'profiles', profile)
|
|
17
|
+
}
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/** HTTP routes bridging the Settings UI to the capabilities manager. */
|
|
2
|
+
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
4
|
+
import { scanAllMcp } from './agents.ts'
|
|
5
|
+
import { readJsonBody, sameOrigin, sendJson } from './http.ts'
|
|
6
|
+
import { deleteSkill, validateSkillInput, writeSkill, type SkillInput } from './skills.ts'
|
|
7
|
+
import { listMcp, removeMcp, setMcpDisabled, upsertMcp, validateMcpInput, type McpInput } from './mcp.ts'
|
|
8
|
+
import type { CapabilitiesHost } from './types.ts'
|
|
9
|
+
|
|
10
|
+
/** Only this source is writable from the Settings page (provider rank 400). */
|
|
11
|
+
const EDITABLE_SOURCE = 'user-dsh'
|
|
12
|
+
|
|
13
|
+
/** Register the manager's routes; returns the disposer removing them all. */
|
|
14
|
+
export function mountCapabilitiesRoutes(host: CapabilitiesHost, config: { profileDirPath: string }): () => void {
|
|
15
|
+
const disposers = [
|
|
16
|
+
host.webServer.register({
|
|
17
|
+
kind: 'exact',
|
|
18
|
+
path: '/dsh-plugin-capabilities/skills',
|
|
19
|
+
handler: async (request: IncomingMessage, response: ServerResponse) => {
|
|
20
|
+
if (request.method !== 'GET') {
|
|
21
|
+
response.writeHead(405, { allow: 'GET' })
|
|
22
|
+
response.end()
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const skills = await host.skills.list()
|
|
27
|
+
sendJson(response, 200, {
|
|
28
|
+
skills: skills.map(skill => ({ ...skill, editable: skill.source === EDITABLE_SOURCE })),
|
|
29
|
+
})
|
|
30
|
+
} catch (error) {
|
|
31
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
|
|
36
|
+
host.webServer.register({
|
|
37
|
+
kind: 'exact',
|
|
38
|
+
path: '/dsh-plugin-capabilities/skill',
|
|
39
|
+
handler: async (request: IncomingMessage, response: ServerResponse) => {
|
|
40
|
+
if (request.method !== 'GET') {
|
|
41
|
+
response.writeHead(405, { allow: 'GET' })
|
|
42
|
+
response.end()
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
const url = new URL(request.url ?? '/', 'http://localhost')
|
|
46
|
+
const name = url.searchParams.get('name') ?? ''
|
|
47
|
+
try {
|
|
48
|
+
const definition = await host.skills.get(name)
|
|
49
|
+
sendJson(response, 200, { name: definition.name, content: definition.content })
|
|
50
|
+
} catch (error) {
|
|
51
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
}),
|
|
55
|
+
|
|
56
|
+
host.webServer.register({
|
|
57
|
+
kind: 'exact',
|
|
58
|
+
path: '/dsh-plugin-capabilities/skill/save',
|
|
59
|
+
handler: async (request: IncomingMessage, response: ServerResponse) => {
|
|
60
|
+
if (request.method !== 'POST') {
|
|
61
|
+
response.writeHead(405, { allow: 'POST' })
|
|
62
|
+
response.end()
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
if (!sameOrigin(request)) {
|
|
66
|
+
sendJson(response, 403, { error: 'untrusted origin' })
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const body = (await readJsonBody(request)) as Partial<SkillInput>
|
|
71
|
+
const input: SkillInput = {
|
|
72
|
+
name: typeof body.name === 'string' ? body.name : '',
|
|
73
|
+
description: typeof body.description === 'string' ? body.description : '',
|
|
74
|
+
whenToUse: typeof body.whenToUse === 'string' ? body.whenToUse : undefined,
|
|
75
|
+
modelInvocable: body.modelInvocable !== false,
|
|
76
|
+
userInvocable: body.userInvocable !== false,
|
|
77
|
+
content: typeof body.content === 'string' ? body.content : '',
|
|
78
|
+
}
|
|
79
|
+
const invalid = validateSkillInput(input)
|
|
80
|
+
if (invalid !== null) {
|
|
81
|
+
sendJson(response, 400, { error: invalid })
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
writeSkill(input)
|
|
85
|
+
sendJson(response, 200, { ok: true, name: input.name })
|
|
86
|
+
} catch (error) {
|
|
87
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
|
|
92
|
+
host.webServer.register({
|
|
93
|
+
kind: 'exact',
|
|
94
|
+
path: '/dsh-plugin-capabilities/skill/delete',
|
|
95
|
+
handler: async (request: IncomingMessage, response: ServerResponse) => {
|
|
96
|
+
if (request.method !== 'POST') {
|
|
97
|
+
response.writeHead(405, { allow: 'POST' })
|
|
98
|
+
response.end()
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
if (!sameOrigin(request)) {
|
|
102
|
+
sendJson(response, 403, { error: 'untrusted origin' })
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const body = (await readJsonBody(request)) as { name?: unknown }
|
|
107
|
+
const name = typeof body.name === 'string' ? body.name : ''
|
|
108
|
+
const removed = deleteSkill(name)
|
|
109
|
+
sendJson(response, removed ? 200 : 404, removed ? { ok: true, name } : { error: 'skill not found' })
|
|
110
|
+
} catch (error) {
|
|
111
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
}),
|
|
115
|
+
|
|
116
|
+
host.webServer.register({
|
|
117
|
+
kind: 'exact',
|
|
118
|
+
path: '/dsh-plugin-capabilities/mcp',
|
|
119
|
+
handler: async (request: IncomingMessage, response: ServerResponse) => {
|
|
120
|
+
if (request.method !== 'GET') {
|
|
121
|
+
response.writeHead(405, { allow: 'GET' })
|
|
122
|
+
response.end()
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
sendJson(response, 200, { servers: listMcp(config.profileDirPath), restartNeeded: true })
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
|
|
129
|
+
host.webServer.register({
|
|
130
|
+
kind: 'exact',
|
|
131
|
+
path: '/dsh-plugin-capabilities/mcp/save',
|
|
132
|
+
handler: async (request: IncomingMessage, response: ServerResponse) => {
|
|
133
|
+
if (request.method !== 'POST') {
|
|
134
|
+
response.writeHead(405, { allow: 'POST' })
|
|
135
|
+
response.end()
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
if (!sameOrigin(request)) {
|
|
139
|
+
sendJson(response, 403, { error: 'untrusted origin' })
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const input = (await readJsonBody(request)) as McpInput
|
|
144
|
+
const invalid = validateMcpInput(input)
|
|
145
|
+
if (invalid !== null) {
|
|
146
|
+
sendJson(response, 400, { error: invalid })
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
const id = upsertMcp(config.profileDirPath, input)
|
|
150
|
+
sendJson(response, 200, { ok: true, id, restartNeeded: true })
|
|
151
|
+
} catch (error) {
|
|
152
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
}),
|
|
156
|
+
|
|
157
|
+
host.webServer.register({
|
|
158
|
+
kind: 'exact',
|
|
159
|
+
path: '/dsh-plugin-capabilities/mcp/toggle',
|
|
160
|
+
handler: async (request: IncomingMessage, response: ServerResponse) => {
|
|
161
|
+
if (request.method !== 'POST') {
|
|
162
|
+
response.writeHead(405, { allow: 'POST' })
|
|
163
|
+
response.end()
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
if (!sameOrigin(request)) {
|
|
167
|
+
sendJson(response, 403, { error: 'untrusted origin' })
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
const body = (await readJsonBody(request)) as { id?: unknown; disabled?: unknown }
|
|
172
|
+
if (typeof body.id !== 'string' || typeof body.disabled !== 'boolean') {
|
|
173
|
+
sendJson(response, 400, { error: 'id and disabled are required' })
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
const ok = setMcpDisabled(config.profileDirPath, body.id, body.disabled)
|
|
177
|
+
sendJson(response, ok ? 200 : 404, ok ? { ok: true, restartNeeded: true } : { error: 'server row not found' })
|
|
178
|
+
} catch (error) {
|
|
179
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
}),
|
|
183
|
+
|
|
184
|
+
host.webServer.register({
|
|
185
|
+
kind: 'exact',
|
|
186
|
+
path: '/dsh-plugin-capabilities/mcp/remove',
|
|
187
|
+
handler: async (request: IncomingMessage, response: ServerResponse) => {
|
|
188
|
+
if (request.method !== 'POST') {
|
|
189
|
+
response.writeHead(405, { allow: 'POST' })
|
|
190
|
+
response.end()
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
if (!sameOrigin(request)) {
|
|
194
|
+
sendJson(response, 403, { error: 'untrusted origin' })
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const body = (await readJsonBody(request)) as { id?: unknown }
|
|
199
|
+
if (typeof body.id !== 'string') {
|
|
200
|
+
sendJson(response, 400, { error: 'id is required' })
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
const ok = removeMcp(config.profileDirPath, body.id)
|
|
204
|
+
sendJson(response, ok ? 200 : 404, ok ? { ok: true, restartNeeded: true } : { error: 'server row not found' })
|
|
205
|
+
} catch (error) {
|
|
206
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
}),
|
|
210
|
+
host.webServer.register({
|
|
211
|
+
kind: 'exact',
|
|
212
|
+
path: '/dsh-plugin-capabilities/import/scan',
|
|
213
|
+
handler: async (request: IncomingMessage, response: ServerResponse) => {
|
|
214
|
+
if (request.method !== 'GET') {
|
|
215
|
+
response.writeHead(405, { allow: 'GET' })
|
|
216
|
+
response.end()
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
sendJson(response, 200, {
|
|
221
|
+
servers: scanAllMcp(),
|
|
222
|
+
// Profile serverNames, so the browser can grey out existing ones.
|
|
223
|
+
existing: listMcp(config.profileDirPath).map(row => row.serverName),
|
|
224
|
+
})
|
|
225
|
+
} catch (error) {
|
|
226
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
}),
|
|
230
|
+
|
|
231
|
+
host.webServer.register({
|
|
232
|
+
kind: 'exact',
|
|
233
|
+
path: '/dsh-plugin-capabilities/import/apply',
|
|
234
|
+
handler: async (request: IncomingMessage, response: ServerResponse) => {
|
|
235
|
+
if (request.method !== 'POST') {
|
|
236
|
+
response.writeHead(405, { allow: 'POST' })
|
|
237
|
+
response.end()
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
if (!sameOrigin(request)) {
|
|
241
|
+
sendJson(response, 403, { error: 'untrusted origin' })
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
const body = (await readJsonBody(request)) as { items?: unknown }
|
|
246
|
+
const wanted = new Set(
|
|
247
|
+
(Array.isArray(body.items) ? body.items : [])
|
|
248
|
+
.filter((item): item is { agent: string; name: string } =>
|
|
249
|
+
typeof item === 'object' && item !== null && typeof (item as { agent?: unknown }).agent === 'string' && typeof (item as { name?: unknown }).name === 'string')
|
|
250
|
+
.map(item => `${item.agent}/${item.name}`),
|
|
251
|
+
)
|
|
252
|
+
const results: Array<{ name: string; ok: boolean; error?: string }> = []
|
|
253
|
+
for (const server of scanAllMcp()) {
|
|
254
|
+
if (!wanted.has(`${server.agent}/${server.name}`)) continue
|
|
255
|
+
const existing = listMcp(config.profileDirPath).some(row => row.serverName === server.name)
|
|
256
|
+
if (existing) {
|
|
257
|
+
results.push({ name: server.name, ok: false, error: 'already in profile' })
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
const input: McpInput = {
|
|
261
|
+
id: '',
|
|
262
|
+
serverName: server.name,
|
|
263
|
+
transport: server.transport,
|
|
264
|
+
...(server.transport === 'stdio'
|
|
265
|
+
? { command: server.command, args: server.args, env: server.env }
|
|
266
|
+
: { url: server.url, headers: server.headers }),
|
|
267
|
+
}
|
|
268
|
+
const invalid = validateMcpInput(input)
|
|
269
|
+
if (invalid !== null) {
|
|
270
|
+
results.push({ name: server.name, ok: false, error: invalid })
|
|
271
|
+
continue
|
|
272
|
+
}
|
|
273
|
+
upsertMcp(config.profileDirPath, input)
|
|
274
|
+
results.push({ name: server.name, ok: true })
|
|
275
|
+
}
|
|
276
|
+
sendJson(response, 200, { ok: results.every(item => item.ok), results, restartNeeded: true })
|
|
277
|
+
} catch (error) {
|
|
278
|
+
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
}),
|
|
282
|
+
]
|
|
283
|
+
|
|
284
|
+
return () => { for (const dispose of disposers) dispose() }
|
|
285
|
+
}
|