@mohou/host 1.0.20
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 +10 -0
- package/package.json +65 -0
- package/src/apps/monogram.ts +15 -0
- package/src/apps/registry.ts +238 -0
- package/src/apps/startup-seed.ts +95 -0
- package/src/bash/client.ts +21 -0
- package/src/bash/codes.ts +21 -0
- package/src/compile/allowlist.ts +89 -0
- package/src/compile/asset-path.ts +48 -0
- package/src/compile/autogen.ts +71 -0
- package/src/compile/build-vendor-cli.ts +8 -0
- package/src/compile/build-vendor.ts +160 -0
- package/src/compile/bundle-ui.ts +137 -0
- package/src/compile/codes.ts +22 -0
- package/src/compile/failure.ts +38 -0
- package/src/compile/imports.ts +43 -0
- package/src/compile/leave.ts +47 -0
- package/src/compile/load-backend.ts +95 -0
- package/src/compile/review.ts +342 -0
- package/src/compile/runner.ts +205 -0
- package/src/compile/sheet.ts +174 -0
- package/src/compile/wrapper.ts +297 -0
- package/src/credentials/codes.ts +26 -0
- package/src/credentials/create.ts +69 -0
- package/src/credentials/file.ts +113 -0
- package/src/credentials/provider.ts +66 -0
- package/src/events/app-events.ts +214 -0
- package/src/events/error-ring.ts +109 -0
- package/src/events/host-events.ts +39 -0
- package/src/events/view-bridge.ts +402 -0
- package/src/events/view-eval.ts +213 -0
- package/src/files/codes.ts +28 -0
- package/src/files/skip.ts +23 -0
- package/src/files/tools.ts +215 -0
- package/src/history/codes.ts +22 -0
- package/src/history/store.ts +287 -0
- package/src/host/activity.ts +110 -0
- package/src/host/author-mcp.ts +248 -0
- package/src/host/author-skill.ts +196 -0
- package/src/host/codes.ts +21 -0
- package/src/host/config.ts +239 -0
- package/src/host/heat.ts +35 -0
- package/src/host/layout.ts +81 -0
- package/src/host/log.ts +142 -0
- package/src/host/mcp-editor.ts +205 -0
- package/src/host/mcp-import.ts +141 -0
- package/src/host/mcp.ts +109 -0
- package/src/host/port.ts +83 -0
- package/src/host/session.ts +358 -0
- package/src/host/workbench.ts +38 -0
- package/src/http/app.ts +46 -0
- package/src/http/asset.ts +49 -0
- package/src/http/author.ts +157 -0
- package/src/http/bind.ts +217 -0
- package/src/http/client.ts +175 -0
- package/src/http/codes.ts +29 -0
- package/src/http/env.ts +3 -0
- package/src/http/guard.ts +20 -0
- package/src/http/iframe.ts +212 -0
- package/src/http/layout.ts +63 -0
- package/src/http/owner.ts +380 -0
- package/src/http/ports.ts +114 -0
- package/src/http/reply.ts +53 -0
- package/src/http/route-codes.ts +19 -0
- package/src/http/updates.ts +258 -0
- package/src/index.ts +201 -0
- package/src/install/codes.ts +21 -0
- package/src/install/install.ts +230 -0
- package/src/install/layout.ts +16 -0
- package/src/kernel/bind-brain.ts +117 -0
- package/src/kernel/call.ts +134 -0
- package/src/kernel/codes.ts +21 -0
- package/src/kernel/index.ts +13 -0
- package/src/kernel/model-policy.ts +83 -0
- package/src/kernel/model-stream.ts +86 -0
- package/src/kernel/present.ts +4 -0
- package/src/kernel/working-directory.ts +35 -0
- package/src/metrics/codes.ts +22 -0
- package/src/metrics/read.ts +48 -0
- package/src/owner/read.ts +85 -0
- package/src/pwsh/client.ts +47 -0
- package/src/pwsh/codes.ts +21 -0
- package/src/shell/command.ts +172 -0
- package/src/storage/codes.ts +33 -0
- package/src/storage/layout.ts +44 -0
- package/src/storage/open.ts +461 -0
- package/src/storage/schema.ts +176 -0
- package/src/storage/sql.ts +40 -0
- package/src/theme/codes.ts +20 -0
- package/src/theme/layout.ts +42 -0
- package/src/theme/paint.ts +108 -0
- package/src/theme/parse.ts +58 -0
- package/src/theme/pin.ts +197 -0
- package/src/theme/tokens.ts +30 -0
- package/src/tools/author.ts +970 -0
- package/src/tools/codes.ts +21 -0
- package/src/tools/diagnostics.ts +53 -0
- package/src/tools/http.ts +48 -0
- package/src/tools/mcp-list.ts +90 -0
- package/src/tools/schemas.ts +185 -0
- package/themes/theme-default.css +4 -0
- package/themes/theme-forest.css +4 -0
- package/themes/theme-hokage.css +4 -0
- package/themes/theme-matcha.css +4 -0
- package/themes/theme-slate.css +4 -0
- package/themes/theme-tokyo.css +4 -0
- package/themes/theme-yellow.css +4 -0
- package/themes/theme-zoro.css +4 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { ConfigError } from './codes.ts'
|
|
6
|
+
|
|
7
|
+
export const authorMcpServerId = 'mini-app'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `dsh` is not a JSON file: DSH reads MCP servers from the YAML plugin list in the profile's
|
|
11
|
+
* `cordis.patch.yml`, so that format is merged as text (see `writeDshPatch`).
|
|
12
|
+
*/
|
|
13
|
+
export type AuthorMcpFormat = 'mcpServers' | 'claude' | 'opencode' | 'dsh'
|
|
14
|
+
|
|
15
|
+
/** One assistant file Shell named. */
|
|
16
|
+
export interface AuthorMcpAgent {
|
|
17
|
+
readonly id: string
|
|
18
|
+
readonly label: string
|
|
19
|
+
readonly file: string
|
|
20
|
+
readonly detectDir: string
|
|
21
|
+
readonly format: AuthorMcpFormat
|
|
22
|
+
readonly adapter?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface AuthorMcpLayout {
|
|
26
|
+
readonly agents: readonly AuthorMcpAgent[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AuthorMcpLive {
|
|
30
|
+
readonly url: string
|
|
31
|
+
readonly token: string
|
|
32
|
+
readonly description: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AuthorMcpAgentStatus extends AuthorMcpAgent {
|
|
36
|
+
readonly dest: string
|
|
37
|
+
readonly homePresent: boolean
|
|
38
|
+
readonly installed: boolean
|
|
39
|
+
readonly updateAvailable: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface AuthorMcpStatus {
|
|
43
|
+
readonly agents: readonly AuthorMcpAgentStatus[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function readAuthorMcp(layout: AuthorMcpLayout, live: AuthorMcpLive): Promise<AuthorMcpStatus> {
|
|
47
|
+
const agents = await Promise.all(layout.agents.map(async (agent) => {
|
|
48
|
+
const installed = await hasServer(agent, live)
|
|
49
|
+
return {
|
|
50
|
+
...agent,
|
|
51
|
+
dest: agent.file,
|
|
52
|
+
homePresent: await present(agent.detectDir),
|
|
53
|
+
installed: installed.ok,
|
|
54
|
+
updateAvailable: installed.ok && !installed.current,
|
|
55
|
+
}
|
|
56
|
+
}))
|
|
57
|
+
return { agents }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Merge the live authoring server into each selected assistant file.
|
|
62
|
+
* Other servers in that file stay.
|
|
63
|
+
*/
|
|
64
|
+
export async function writeAuthorMcp(
|
|
65
|
+
layout: AuthorMcpLayout,
|
|
66
|
+
agentIds: readonly string[],
|
|
67
|
+
live: AuthorMcpLive,
|
|
68
|
+
): Promise<AuthorMcpStatus> {
|
|
69
|
+
const selected = layout.agents.filter(agent => agentIds.includes(agent.id))
|
|
70
|
+
for (const agent of selected) await writeAgent(agent, live)
|
|
71
|
+
return readAuthorMcp(layout, live)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Open a dest this layout already owns. */
|
|
75
|
+
export async function revealAuthorMcp(
|
|
76
|
+
layout: AuthorMcpLayout,
|
|
77
|
+
dest: string,
|
|
78
|
+
platform = process.platform,
|
|
79
|
+
): Promise<void> {
|
|
80
|
+
const resolved = path.resolve(dest)
|
|
81
|
+
const allowed = new Set(layout.agents.map(agent => path.resolve(agent.file)))
|
|
82
|
+
if (!allowed.has(resolved)) throw new ConfigError('config-invalid', 'mcp file is not in the list')
|
|
83
|
+
await access(resolved)
|
|
84
|
+
await openPath(resolved, platform)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function writeAgent(agent: AuthorMcpAgent, live: AuthorMcpLive): Promise<void> {
|
|
88
|
+
if (agent.format === 'dsh') {
|
|
89
|
+
await writeDshPatch(agent.file, live)
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
const root = await readRoot(agent.file)
|
|
93
|
+
const key = nestKey(agent.format)
|
|
94
|
+
const nest = isRecord(root[key]) ? { ...root[key] } : {}
|
|
95
|
+
nest[authorMcpServerId] = entryOf(agent.format, live)
|
|
96
|
+
const next = { ...root, [key]: nest }
|
|
97
|
+
await mkdir(path.dirname(agent.file), { recursive: true })
|
|
98
|
+
await writeFile(agent.file, `${JSON.stringify(next, null, 2)}\n`, 'utf8')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** First and last line of the block this product owns inside a DSH patch file. */
|
|
102
|
+
const dshMarkStart = `# >>> mohou:${authorMcpServerId}`
|
|
103
|
+
const dshMarkEnd = `# <<< mohou:${authorMcpServerId}`
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The plugin entry DSH needs. `serverName` is what DSH turns into `mcp__<serverName>__<tool>`.
|
|
107
|
+
* A failed connection must not stop the harness from booting.
|
|
108
|
+
*/
|
|
109
|
+
function dshBlock(live: AuthorMcpLive): string {
|
|
110
|
+
return [
|
|
111
|
+
`${dshMarkStart} — written by Mohou Settings → Agent. Delete this whole block to uninstall.`,
|
|
112
|
+
'- insert:',
|
|
113
|
+
` - id: mcp-${authorMcpServerId}`,
|
|
114
|
+
' name: "@deepseek-ai/dsh-mcp-client"',
|
|
115
|
+
' config:',
|
|
116
|
+
` serverName: ${authorMcpServerId}`,
|
|
117
|
+
' transport: streamable-http',
|
|
118
|
+
` url: ${live.url}`,
|
|
119
|
+
' headers:',
|
|
120
|
+
` Authorization: "Bearer ${live.token}"`,
|
|
121
|
+
' failOnStartupError: false',
|
|
122
|
+
dshMarkEnd,
|
|
123
|
+
'',
|
|
124
|
+
].join('\n')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Merge the entry into the profile's patch layer as text. The file may hold hand-written YAML
|
|
129
|
+
* (`!!js` tags included), so it is never re-serialised: everything outside our two markers is
|
|
130
|
+
* copied through byte for byte.
|
|
131
|
+
*/
|
|
132
|
+
async function writeDshPatch(file: string, live: AuthorMcpLive): Promise<void> {
|
|
133
|
+
const text = await readText(file)
|
|
134
|
+
const stripped = dropDshBlock(text)
|
|
135
|
+
const head = stripped.trimEnd()
|
|
136
|
+
const next = head.length === 0 ? dshBlock(live) : `${head}\n\n${dshBlock(live)}`
|
|
137
|
+
await mkdir(path.dirname(file), { recursive: true })
|
|
138
|
+
await writeFile(file, next, 'utf8')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function dropDshBlock(text: string): string {
|
|
142
|
+
const start = text.indexOf(dshMarkStart)
|
|
143
|
+
if (start < 0) return text
|
|
144
|
+
const end = text.indexOf(dshMarkEnd, start)
|
|
145
|
+
if (end < 0) return text.slice(0, start)
|
|
146
|
+
const after = text.indexOf('\n', end)
|
|
147
|
+
return `${text.slice(0, start)}${after < 0 ? '' : text.slice(after + 1)}`
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function readText(file: string): Promise<string> {
|
|
151
|
+
return await readFile(file, 'utf8').catch((error: unknown) => {
|
|
152
|
+
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') return ''
|
|
153
|
+
throw error
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function hasServer(agent: AuthorMcpAgent, live: AuthorMcpLive): Promise<{ ok: boolean; current: boolean }> {
|
|
158
|
+
if (agent.format === 'dsh') return hasDshBlock(agent.file, live)
|
|
159
|
+
const root = await readRoot(agent.file).catch(() => undefined)
|
|
160
|
+
if (root === undefined) return { ok: false, current: false }
|
|
161
|
+
const nest = root[nestKey(agent.format)]
|
|
162
|
+
if (!isRecord(nest)) return { ok: false, current: false }
|
|
163
|
+
const row = nest[authorMcpServerId]
|
|
164
|
+
if (!isRecord(row)) return { ok: false, current: false }
|
|
165
|
+
const headers = isRecord(row.headers) ? row.headers : {}
|
|
166
|
+
const current = row.url === live.url && headers.Authorization === `Bearer ${live.token}`
|
|
167
|
+
return { ok: true, current }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Installed when our markers are there; current only when the block matches the live server. */
|
|
171
|
+
async function hasDshBlock(file: string, live: AuthorMcpLive): Promise<{ ok: boolean; current: boolean }> {
|
|
172
|
+
const text = await readText(file)
|
|
173
|
+
const start = text.indexOf(dshMarkStart)
|
|
174
|
+
if (start < 0) return { ok: false, current: false }
|
|
175
|
+
const end = text.indexOf(dshMarkEnd, start)
|
|
176
|
+
if (end < 0) return { ok: true, current: false }
|
|
177
|
+
const lineEnd = text.indexOf('\n', end)
|
|
178
|
+
const block = lineEnd < 0 ? text.slice(start) : text.slice(start, lineEnd)
|
|
179
|
+
return { ok: true, current: block === dshBlock(live).trimEnd() }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function readRoot(file: string): Promise<Record<string, unknown>> {
|
|
183
|
+
const text = await readFile(file, 'utf8').catch((error: unknown) => {
|
|
184
|
+
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') return ''
|
|
185
|
+
throw error
|
|
186
|
+
})
|
|
187
|
+
if (text.trim().length === 0) return {}
|
|
188
|
+
let parsed: unknown
|
|
189
|
+
try {
|
|
190
|
+
parsed = JSON.parse(stripJsonc(text))
|
|
191
|
+
} catch {
|
|
192
|
+
throw new ConfigError('config-invalid', `mcp file is not JSON: ${file}`)
|
|
193
|
+
}
|
|
194
|
+
if (!isRecord(parsed)) throw new ConfigError('config-invalid', `mcp file is not an object: ${file}`)
|
|
195
|
+
return parsed
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function entryOf(format: AuthorMcpFormat, live: AuthorMcpLive): Record<string, unknown> {
|
|
199
|
+
const headers = { Authorization: `Bearer ${live.token}` }
|
|
200
|
+
if (format === 'opencode') return { type: 'remote', url: live.url, enabled: true, headers }
|
|
201
|
+
if (format === 'claude') return { type: 'http', url: live.url, headers }
|
|
202
|
+
return {
|
|
203
|
+
url: live.url,
|
|
204
|
+
transport: 'streamable-http',
|
|
205
|
+
description: live.description,
|
|
206
|
+
headers,
|
|
207
|
+
_monkeyagent: { description: live.description },
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function nestKey(format: AuthorMcpFormat): 'mcpServers' | 'mcp' {
|
|
212
|
+
return format === 'opencode' ? 'mcp' : 'mcpServers'
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function stripJsonc(text: string): string {
|
|
216
|
+
return text
|
|
217
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
218
|
+
.replace(/^\s*\/\/.*$/gm, '')
|
|
219
|
+
.replace(/,(\s*[}\]])/g, '$1')
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
223
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function present(dir: string): Promise<boolean> {
|
|
227
|
+
try {
|
|
228
|
+
await access(dir)
|
|
229
|
+
return true
|
|
230
|
+
} catch {
|
|
231
|
+
return false
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function openPath(target: string, platform: string): Promise<void> {
|
|
236
|
+
const launched = platform === 'win32'
|
|
237
|
+
? { command: 'explorer', args: [target] }
|
|
238
|
+
: platform === 'darwin'
|
|
239
|
+
? { command: 'open', args: [target] }
|
|
240
|
+
: undefined
|
|
241
|
+
if (launched === undefined) throw new ConfigError('config-invalid', `open folder is not implemented on ${platform}`)
|
|
242
|
+
return new Promise((resolve, reject) => {
|
|
243
|
+
const child = spawn(launched.command, launched.args, { stdio: 'ignore', detached: true })
|
|
244
|
+
child.once('error', reject)
|
|
245
|
+
child.unref()
|
|
246
|
+
resolve()
|
|
247
|
+
})
|
|
248
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { access, cp, mkdir, readFile } from 'node:fs/promises'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { ConfigError } from './codes.ts'
|
|
7
|
+
|
|
8
|
+
/** One assistant folder Shell named. `skillsDir` is the parent `skills` directory. */
|
|
9
|
+
export interface AuthorSkillAgent {
|
|
10
|
+
readonly id: string
|
|
11
|
+
readonly label: string
|
|
12
|
+
readonly skillsDir: string
|
|
13
|
+
readonly detectDir: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AuthorSkillLayout {
|
|
17
|
+
readonly source: string
|
|
18
|
+
readonly agents: readonly AuthorSkillAgent[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface AuthorSkillCopyStatus {
|
|
22
|
+
readonly dest: string
|
|
23
|
+
readonly installed: boolean
|
|
24
|
+
readonly version: string | null
|
|
25
|
+
readonly updateAvailable: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AuthorSkillAgentStatus extends AuthorSkillAgent, AuthorSkillCopyStatus {
|
|
29
|
+
readonly homePresent: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AuthorSkillStatus {
|
|
33
|
+
readonly skillId: string
|
|
34
|
+
readonly version: string | null
|
|
35
|
+
readonly agents: readonly AuthorSkillAgentStatus[]
|
|
36
|
+
readonly customs: readonly (AuthorSkillCopyStatus & { readonly dir: string })[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Skill folder name taken from the source directory. */
|
|
40
|
+
export function skillIdOf(source: string): string {
|
|
41
|
+
const id = path.basename(source).trim()
|
|
42
|
+
if (id.length === 0) throw new ConfigError('config-invalid', 'author skill source is invalid')
|
|
43
|
+
return id
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Admit a custom skills parent. The last segment must be `skills`.
|
|
48
|
+
* @param raw - path the panel sent
|
|
49
|
+
* @param home - for expanding `~`
|
|
50
|
+
*/
|
|
51
|
+
export function admitSkillsDir(raw: string, home: string): string {
|
|
52
|
+
const trimmed = raw.trim()
|
|
53
|
+
if (trimmed.length === 0) throw new ConfigError('config-invalid', 'skill directory is empty')
|
|
54
|
+
const expanded = trimmed.startsWith('~/') || trimmed === '~'
|
|
55
|
+
? path.join(home, trimmed.slice(1).replace(/^[\\/]/, ''))
|
|
56
|
+
: trimmed
|
|
57
|
+
const resolved = path.resolve(expanded)
|
|
58
|
+
if (path.basename(resolved) !== 'skills') {
|
|
59
|
+
throw new ConfigError('config-invalid', 'skill directory must end with skills')
|
|
60
|
+
}
|
|
61
|
+
return resolved
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `x.y.z` from the first YAML block of SKILL.md. Anything else is absent. */
|
|
65
|
+
export function skillVersionOf(markdown: string): string | null {
|
|
66
|
+
if (!markdown.startsWith('---')) return null
|
|
67
|
+
const end = markdown.indexOf('\n---', 3)
|
|
68
|
+
if (end < 0) return null
|
|
69
|
+
const match = /^version:\s*["']?([0-9]+\.[0-9]+\.[0-9]+)["']?\s*$/m.exec(markdown.slice(3, end))
|
|
70
|
+
return match?.[1] ?? null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function readAuthorSkill(
|
|
74
|
+
layout: AuthorSkillLayout,
|
|
75
|
+
customDirs: readonly string[] = [],
|
|
76
|
+
home = homedir(),
|
|
77
|
+
): Promise<AuthorSkillStatus> {
|
|
78
|
+
const skillId = skillIdOf(layout.source)
|
|
79
|
+
const version = await readSkillVersion(path.join(layout.source, 'SKILL.md'))
|
|
80
|
+
const agents = await Promise.all(layout.agents.map(async (agent) => {
|
|
81
|
+
const dest = path.join(agent.skillsDir, skillId)
|
|
82
|
+
const copy = await copyStatus(dest, version)
|
|
83
|
+
return {
|
|
84
|
+
...agent,
|
|
85
|
+
...copy,
|
|
86
|
+
homePresent: await present(agent.detectDir),
|
|
87
|
+
}
|
|
88
|
+
}))
|
|
89
|
+
const customs = await Promise.all(customDirs.map(async (raw) => {
|
|
90
|
+
const dir = admitSkillsDir(raw, home)
|
|
91
|
+
const dest = path.join(dir, skillId)
|
|
92
|
+
return { dir, ...await copyStatus(dest, version) }
|
|
93
|
+
}))
|
|
94
|
+
return { skillId, version, agents, customs }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Copy the skill into each selected assistant or custom skills dir.
|
|
99
|
+
*/
|
|
100
|
+
export async function writeAuthorSkill(
|
|
101
|
+
layout: AuthorSkillLayout,
|
|
102
|
+
agentIds: readonly string[],
|
|
103
|
+
customDirs: readonly string[],
|
|
104
|
+
home = homedir(),
|
|
105
|
+
): Promise<AuthorSkillStatus> {
|
|
106
|
+
await access(path.join(layout.source, 'SKILL.md'))
|
|
107
|
+
const skillId = skillIdOf(layout.source)
|
|
108
|
+
const selected = layout.agents.filter(agent => agentIds.includes(agent.id))
|
|
109
|
+
const dirs = [...selected.map(agent => agent.skillsDir), ...customDirs.map(raw => admitSkillsDir(raw, home))]
|
|
110
|
+
for (const dir of dirs) {
|
|
111
|
+
const dest = path.join(dir, skillId)
|
|
112
|
+
await mkdir(dir, { recursive: true })
|
|
113
|
+
await cp(layout.source, dest, { recursive: true, force: true })
|
|
114
|
+
}
|
|
115
|
+
return readAuthorSkill(layout, customDirs, home)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Open a dest this layout already owns. */
|
|
119
|
+
export async function revealAuthorSkill(
|
|
120
|
+
layout: AuthorSkillLayout,
|
|
121
|
+
dest: string,
|
|
122
|
+
home = homedir(),
|
|
123
|
+
platform = process.platform,
|
|
124
|
+
): Promise<void> {
|
|
125
|
+
const status = await readAuthorSkill(layout, [], home)
|
|
126
|
+
const allowed = new Set(status.agents.map(agent => agent.dest))
|
|
127
|
+
const resolved = path.resolve(dest)
|
|
128
|
+
if (!allowed.has(resolved) && path.basename(path.dirname(resolved)) !== 'skills') {
|
|
129
|
+
throw new ConfigError('config-invalid', 'skill folder is not in the list')
|
|
130
|
+
}
|
|
131
|
+
if (path.basename(path.dirname(resolved)) === 'skills') admitSkillsDir(path.dirname(resolved), home)
|
|
132
|
+
await access(path.join(resolved, 'SKILL.md'))
|
|
133
|
+
await openDir(resolved, platform)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function copyStatus(dest: string, sourceVersion: string | null): Promise<AuthorSkillCopyStatus> {
|
|
137
|
+
const installed = await hasSkill(dest)
|
|
138
|
+
const version = installed ? await readSkillVersion(path.join(dest, 'SKILL.md')) : null
|
|
139
|
+
return {
|
|
140
|
+
dest,
|
|
141
|
+
installed,
|
|
142
|
+
version,
|
|
143
|
+
updateAvailable: installed && sourceVersion !== null && (version === null || compareSkillVersion(sourceVersion, version) > 0),
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function readSkillVersion(file: string): Promise<string | null> {
|
|
148
|
+
try {
|
|
149
|
+
return skillVersionOf(await readFile(file, 'utf8'))
|
|
150
|
+
} catch {
|
|
151
|
+
return null
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function compareSkillVersion(left: string, right: string): number {
|
|
156
|
+
const a = left.split('.').map(Number)
|
|
157
|
+
const b = right.split('.').map(Number)
|
|
158
|
+
for (let i = 0; i < 3; i++) {
|
|
159
|
+
const delta = (a[i] ?? 0) - (b[i] ?? 0)
|
|
160
|
+
if (delta !== 0) return delta > 0 ? 1 : -1
|
|
161
|
+
}
|
|
162
|
+
return 0
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function hasSkill(dest: string): Promise<boolean> {
|
|
166
|
+
try {
|
|
167
|
+
await access(path.join(dest, 'SKILL.md'))
|
|
168
|
+
return true
|
|
169
|
+
} catch {
|
|
170
|
+
return false
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function present(dir: string): Promise<boolean> {
|
|
175
|
+
try {
|
|
176
|
+
await access(dir)
|
|
177
|
+
return true
|
|
178
|
+
} catch {
|
|
179
|
+
return false
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function openDir(dir: string, platform: string): Promise<void> {
|
|
184
|
+
const launched = platform === 'win32'
|
|
185
|
+
? { command: 'explorer', args: [dir] }
|
|
186
|
+
: platform === 'darwin'
|
|
187
|
+
? { command: 'open', args: [dir] }
|
|
188
|
+
: undefined
|
|
189
|
+
if (launched === undefined) throw new ConfigError('config-invalid', `open folder is not implemented on ${platform}`)
|
|
190
|
+
return new Promise((resolve, reject) => {
|
|
191
|
+
const child = spawn(launched.command, launched.args, { stdio: 'ignore', detached: true })
|
|
192
|
+
child.once('error', reject)
|
|
193
|
+
child.unref()
|
|
194
|
+
resolve()
|
|
195
|
+
})
|
|
196
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Codes the host config emits. Callers match `code`. */
|
|
2
|
+
|
|
3
|
+
export const configCodes = ['config-invalid', 'config-missing'] as const
|
|
4
|
+
|
|
5
|
+
export type ConfigCode = (typeof configCodes)[number]
|
|
6
|
+
|
|
7
|
+
/** A present config failed. The file is unchanged. */
|
|
8
|
+
export class ConfigError extends Error {
|
|
9
|
+
readonly code: ConfigCode
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param code - one of {@link configCodes}
|
|
13
|
+
* @param message - human text; not the match key
|
|
14
|
+
* @param options - optional `cause`
|
|
15
|
+
*/
|
|
16
|
+
constructor(code: ConfigCode, message: string, options?: { cause?: unknown }) {
|
|
17
|
+
super(message, options)
|
|
18
|
+
this.name = 'ConfigError'
|
|
19
|
+
this.code = code
|
|
20
|
+
}
|
|
21
|
+
}
|