@shawnstack/quickforge 1.3.24 → 1.3.26
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 +24 -18
- package/dist/assets/anthropic-BcnDL7hi.js +39 -0
- package/dist/assets/azure-openai-responses-BEfdv0qd.js +1 -0
- package/dist/assets/google-C2y985rW.js +1 -0
- package/dist/assets/google-shared-Cqjw1plk.js +11 -0
- package/dist/assets/google-vertex-Jf9zNsCF.js +1 -0
- package/dist/assets/{icons-DmRYmmql.js → icons-BVM5--R9.js} +1 -1
- package/dist/assets/{index-s72bxhrh.js → index-8Q1Ovled.js} +604 -550
- package/dist/assets/index-ZYbEKGUp.css +3 -0
- package/dist/assets/{mistral-DCZ8VphX.js → mistral-qYbgRY3z.js} +1 -1
- package/dist/assets/openai-codex-responses--aAgyYJM.js +7 -0
- package/dist/assets/openai-completions-CHDluyXM.js +5 -0
- package/dist/assets/openai-prompt-cache-CErE62Yt.js +1 -0
- package/dist/assets/openai-responses-UtRriBXu.js +1 -0
- package/dist/assets/{openai-responses-shared-RzgnIlMf.js → openai-responses-shared-G6WDDqJ8.js} +1 -1
- package/dist/assets/openrouter-Dz9zwzUG.js +1 -0
- package/dist/assets/{react-vendor-BsV2HYbc.js → react-vendor-DAoL5p8_.js} +1 -1
- package/dist/assets/sanitize-unicode-BhyPmlyt.js +1 -0
- package/dist/assets/transform-messages-Dhj_4OTw.js +1 -0
- package/dist/index.html +4 -4
- package/package.json +4 -3
- package/server/agent-manager.mjs +162 -176
- package/server/ai-http-logger.mjs +20 -5
- package/server/approval-store.mjs +63 -0
- package/server/custom-commands.mjs +67 -9
- package/server/index.mjs +7 -0
- package/server/message-converters.mjs +79 -0
- package/server/plugins/loader.mjs +56 -0
- package/server/plugins/manifest.mjs +174 -0
- package/server/plugins/registry.mjs +304 -0
- package/server/project-config.mjs +53 -4
- package/server/routes/agent-profiles.mjs +1 -1
- package/server/routes/agent.mjs +1 -16
- package/server/routes/filesystem.mjs +18 -2
- package/server/routes/plugins.mjs +63 -0
- package/server/routes/project.mjs +2 -0
- package/server/routes/scheduled-tasks.mjs +1 -1
- package/server/routes/storage.mjs +66 -31
- package/server/routes/tools.mjs +12 -1
- package/server/session-utils.mjs +1 -1
- package/server/skills.mjs +64 -5
- package/server/storage.mjs +91 -8
- package/server/system-prompt.mjs +27 -5
- package/server/tool-wiring.mjs +113 -0
- package/server/utils/workspace.mjs +20 -1
- package/dist/assets/anthropic-BrbLtQkg.js +0 -39
- package/dist/assets/azure-openai-responses-q9QFpQk3.js +0 -1
- package/dist/assets/google-Bv6IeSRf.js +0 -1
- package/dist/assets/google-shared-CLc4ziON.js +0 -11
- package/dist/assets/google-vertex-Cwpe8vbn.js +0 -1
- package/dist/assets/index-C4m48ndP.css +0 -3
- package/dist/assets/openai-codex-responses-Bx7iyHzd.js +0 -7
- package/dist/assets/openai-completions-CihVV11E.js +0 -5
- package/dist/assets/openai-responses-BigEdUNS.js +0 -1
- package/dist/assets/transform-messages-CmnxG9RB.js +0 -1
- /package/dist/assets/{hash-Bt1aVMQ3.js → hash-kZ2KD_no.js} +0 -0
- /package/dist/assets/{openai-Cn7eGqwa.js → openai-Bf1npfRy.js} +0 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
+
import { getEnabledPluginCommandSources } from './plugins/registry.mjs'
|
|
3
4
|
|
|
5
|
+
const commandsRelativeDirs = ['.claude/commands', '.opencode/commands', '.ai/commands']
|
|
4
6
|
const commandsRelativeDir = '.ai/commands'
|
|
5
7
|
const commandNamePattern = /^(?!.*--)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
|
|
6
8
|
|
|
@@ -23,10 +25,9 @@ function configuredCommandDirectories(workspaceRoot, commandDir) {
|
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
function commandDirectories(workspaceRoot, commandDir) {
|
|
26
|
-
|
|
27
|
-
if (!defaultDir) return []
|
|
28
|
+
if (!workspaceRoot) return []
|
|
28
29
|
|
|
29
|
-
const dirs =
|
|
30
|
+
const dirs = commandsRelativeDirs.map((dir) => path.join(path.resolve(workspaceRoot), dir))
|
|
30
31
|
for (const configuredDir of configuredCommandDirectories(workspaceRoot, commandDir)) {
|
|
31
32
|
if (!dirs.some((dir) => path.resolve(dir) === path.resolve(configuredDir))) {
|
|
32
33
|
dirs.push(configuredDir)
|
|
@@ -102,7 +103,7 @@ function firstOptionalBoolean(...values) {
|
|
|
102
103
|
return undefined
|
|
103
104
|
}
|
|
104
105
|
|
|
105
|
-
function commandFromFile(file, text) {
|
|
106
|
+
function commandFromFile(file, text, options = {}) {
|
|
106
107
|
const parsed = parseFrontmatter(text)
|
|
107
108
|
if (!parsed.body) return null
|
|
108
109
|
|
|
@@ -127,7 +128,9 @@ function commandFromFile(file, text) {
|
|
|
127
128
|
),
|
|
128
129
|
body: parsed.body,
|
|
129
130
|
filePath: file,
|
|
130
|
-
relativePath: path.relative(path.dirname(path.dirname(file)), file).replace(/\\/g, '/'),
|
|
131
|
+
relativePath: options.relativePath || path.relative(path.dirname(path.dirname(file)), file).replace(/\\/g, '/'),
|
|
132
|
+
source: options.source,
|
|
133
|
+
pluginName: options.pluginName,
|
|
131
134
|
}
|
|
132
135
|
}
|
|
133
136
|
|
|
@@ -159,7 +162,7 @@ export function textFromUserMessage(message) {
|
|
|
159
162
|
.join('\n')
|
|
160
163
|
}
|
|
161
164
|
|
|
162
|
-
async function listCommandsFromDirectory(dir) {
|
|
165
|
+
async function listCommandsFromDirectory(dir, options = {}) {
|
|
163
166
|
let entries
|
|
164
167
|
try {
|
|
165
168
|
entries = await fs.readdir(dir, { withFileTypes: true })
|
|
@@ -173,7 +176,14 @@ async function listCommandsFromDirectory(dir) {
|
|
|
173
176
|
if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.md')) continue
|
|
174
177
|
const file = path.join(dir, entry.name)
|
|
175
178
|
try {
|
|
176
|
-
const
|
|
179
|
+
const relativePath = options.relativeRoot
|
|
180
|
+
? `${options.relativeRoot}/${entry.name}`.replace(/\\/g, '/')
|
|
181
|
+
: undefined
|
|
182
|
+
const command = commandFromFile(file, await fs.readFile(file, 'utf8'), {
|
|
183
|
+
source: options.source,
|
|
184
|
+
pluginName: options.pluginName,
|
|
185
|
+
relativePath,
|
|
186
|
+
})
|
|
177
187
|
if (command) commands.push(command)
|
|
178
188
|
} catch (error) {
|
|
179
189
|
console.warn(`Failed to load custom command ${file}:`, error.message || error)
|
|
@@ -183,9 +193,49 @@ async function listCommandsFromDirectory(dir) {
|
|
|
183
193
|
return commands
|
|
184
194
|
}
|
|
185
195
|
|
|
196
|
+
async function listCommandsFromFile(file, options = {}) {
|
|
197
|
+
if (!file.toLowerCase().endsWith('.md')) return []
|
|
198
|
+
try {
|
|
199
|
+
const command = commandFromFile(file, await fs.readFile(file, 'utf8'), options)
|
|
200
|
+
return command ? [command] : []
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR' || error?.code === 'EACCES' || error?.code === 'EPERM') return []
|
|
203
|
+
console.warn(`Failed to load custom command ${file}:`, error.message || error)
|
|
204
|
+
return []
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function listCommandsFromPluginSource(source) {
|
|
209
|
+
const stat = await fs.stat(source.path).catch(() => null)
|
|
210
|
+
if (!stat) return []
|
|
211
|
+
const options = {
|
|
212
|
+
source: source.source,
|
|
213
|
+
pluginName: source.pluginName,
|
|
214
|
+
relativePath: source.relativePath,
|
|
215
|
+
relativeRoot: source.relativePath,
|
|
216
|
+
}
|
|
217
|
+
if (stat.isFile()) return listCommandsFromFile(source.path, options)
|
|
218
|
+
if (stat.isDirectory()) return listCommandsFromDirectory(source.path, options)
|
|
219
|
+
return []
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function listPluginCommands(workspaceRoot) {
|
|
223
|
+
if (!workspaceRoot) return []
|
|
224
|
+
const sources = await getEnabledPluginCommandSources({ workspaceRoot })
|
|
225
|
+
const commands = []
|
|
226
|
+
for (const source of sources) {
|
|
227
|
+
commands.push(...await listCommandsFromPluginSource(source))
|
|
228
|
+
}
|
|
229
|
+
return commands
|
|
230
|
+
}
|
|
231
|
+
|
|
186
232
|
export async function listProjectCommands(workspaceRoot, commandDir) {
|
|
187
233
|
const byName = new Map()
|
|
188
234
|
|
|
235
|
+
for (const command of (await listPluginCommands(workspaceRoot)).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
236
|
+
byName.set(command.name, command)
|
|
237
|
+
}
|
|
238
|
+
|
|
189
239
|
for (const dir of commandDirectories(workspaceRoot, commandDir)) {
|
|
190
240
|
const commands = await listCommandsFromDirectory(dir)
|
|
191
241
|
for (const command of commands.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
@@ -254,6 +304,9 @@ export function parseInternalCommandInvocation(message) {
|
|
|
254
304
|
const planMatch = text.match(/^\/plan(?:\s+([\s\S]*))?$/i)
|
|
255
305
|
if (planMatch) return { type: 'plan', args: (planMatch[1] || '').trim() }
|
|
256
306
|
|
|
307
|
+
const reviewMatch = text.match(/^\/review(?:\s+([\s\S]*))?$/i)
|
|
308
|
+
if (reviewMatch) return { type: 'review', args: (reviewMatch[1] || '').trim() }
|
|
309
|
+
|
|
257
310
|
const compactMatch = text.match(/^\/compact(?:\s+([\s\S]*))?$/i)
|
|
258
311
|
if (compactMatch) return { type: 'compact', args: (compactMatch[1] || '').trim() }
|
|
259
312
|
|
|
@@ -278,6 +331,11 @@ export async function handleInternalCommand(invocation, workspaceRoot, commandDi
|
|
|
278
331
|
return { plan: true, args: invocation.args }
|
|
279
332
|
}
|
|
280
333
|
|
|
334
|
+
if (invocation.type === 'review') {
|
|
335
|
+
if (!workspaceRoot) return 'Review requires an active project chat.'
|
|
336
|
+
return { review: true, args: invocation.args || '' }
|
|
337
|
+
}
|
|
338
|
+
|
|
281
339
|
if (invocation.type === 'clear') {
|
|
282
340
|
return { clear: true }
|
|
283
341
|
}
|
|
@@ -319,7 +377,7 @@ function formatCommandList(commands) {
|
|
|
319
377
|
'/command new review',
|
|
320
378
|
'```',
|
|
321
379
|
'',
|
|
322
|
-
'Or add Markdown files under `.ai/commands/`, for example `.ai/commands/review.md`.',
|
|
380
|
+
'Or add Markdown files under `.claude/commands/`, `.opencode/commands/`, or `.ai/commands/`, for example `.ai/commands/review.md`.',
|
|
323
381
|
].join('\n')
|
|
324
382
|
}
|
|
325
383
|
|
|
@@ -335,7 +393,7 @@ function formatCommandList(commands) {
|
|
|
335
393
|
'',
|
|
336
394
|
...rows,
|
|
337
395
|
'',
|
|
338
|
-
'Command files live in `.
|
|
396
|
+
'Command files live in `.claude/commands/*.md`, `.opencode/commands/*.md`, `.ai/commands/*.md`, or configured directories. Use `$ARGUMENTS` inside a command file to insert invocation arguments.',
|
|
339
397
|
].join('\n')
|
|
340
398
|
}
|
|
341
399
|
|
package/server/index.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { handleSharesApi } from './routes/shares.mjs'
|
|
|
24
24
|
import { handleSharedConversationApi } from './routes/shared-conversation.mjs'
|
|
25
25
|
import { handleLanAccessApi, renderLanUnlockPage } from './routes/lan-access.mjs'
|
|
26
26
|
import { handleMcpApi } from './routes/mcp.mjs'
|
|
27
|
+
import { handlePluginsApi } from './routes/plugins.mjs'
|
|
27
28
|
import { handleWorkspaceApi, handleGitApi } from './routes/workspace.mjs'
|
|
28
29
|
import { handleTerminalApi, handleTerminalUpgrade } from './routes/terminal.mjs'
|
|
29
30
|
import { serveStatic } from './routes/static.mjs'
|
|
@@ -227,6 +228,12 @@ async function handleApi(req, res, url) {
|
|
|
227
228
|
return
|
|
228
229
|
}
|
|
229
230
|
|
|
231
|
+
// Plugins
|
|
232
|
+
if (pathname === '/api/plugins' || pathname.startsWith('/api/plugins/')) {
|
|
233
|
+
await handlePluginsApi(req, res, url)
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
|
|
230
237
|
// Project routes
|
|
231
238
|
if (pathname === '/api/project' || pathname.startsWith('/api/project/')) {
|
|
232
239
|
await handleProjectApi(req, res, url)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM message format converters.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions that transform AgentMessage[] to LLM-compatible Message[]
|
|
5
|
+
* and extract text content from messages. No module-level state.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Strip the `details` property from a message object.
|
|
10
|
+
* Returns a shallow copy so the original message is not mutated.
|
|
11
|
+
*/
|
|
12
|
+
export function omitDetailsForLlm(message) {
|
|
13
|
+
if (!message || typeof message !== 'object' || message.details === undefined) return message
|
|
14
|
+
const copy = { ...message }
|
|
15
|
+
delete copy.details
|
|
16
|
+
return copy
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Convert AgentMessage[] to LLM-compatible Message[].
|
|
21
|
+
* Handles "user-with-attachments" → "user" with multi-modal content blocks.
|
|
22
|
+
* Without this the default pi-agent-core convertToLlm silently drops
|
|
23
|
+
* user-with-attachments messages, so the LLM never sees attachments.
|
|
24
|
+
*/
|
|
25
|
+
export function serverConvertToLlm(messages) {
|
|
26
|
+
return messages
|
|
27
|
+
.filter(m => m.role !== 'artifact')
|
|
28
|
+
.map(m => {
|
|
29
|
+
if (m.role === 'user-with-attachments') {
|
|
30
|
+
const textContent = typeof m.content === 'string'
|
|
31
|
+
? [{ type: 'text', text: m.content }]
|
|
32
|
+
: [...m.content]
|
|
33
|
+
if (Array.isArray(m.attachments)) {
|
|
34
|
+
for (const att of m.attachments) {
|
|
35
|
+
if (att.type === 'image' && att.content) {
|
|
36
|
+
textContent.push({ type: 'image', data: att.content, mimeType: att.mimeType })
|
|
37
|
+
} else if (att.type === 'document' && att.extractedText) {
|
|
38
|
+
textContent.push({ type: 'text', text: `\n\n[Document: ${att.fileName}]\n${att.extractedText}` })
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return omitDetailsForLlm({ ...m, role: 'user', content: textContent })
|
|
43
|
+
}
|
|
44
|
+
if (m.role === 'user' || m.role === 'assistant' || m.role === 'toolResult') return omitDetailsForLlm(m)
|
|
45
|
+
return null
|
|
46
|
+
})
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Extract plain text content from a message object.
|
|
52
|
+
* Handles string content and ContentBlock[] arrays.
|
|
53
|
+
*/
|
|
54
|
+
export function messageText(message) {
|
|
55
|
+
const content = message?.content
|
|
56
|
+
if (typeof content === 'string') return content
|
|
57
|
+
if (Array.isArray(content)) {
|
|
58
|
+
return content
|
|
59
|
+
.filter((block) => block?.type === 'text')
|
|
60
|
+
.map((block) => block.text ?? '')
|
|
61
|
+
.join('\n')
|
|
62
|
+
.trim()
|
|
63
|
+
}
|
|
64
|
+
return ''
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Find the last assistant message with non-empty text content.
|
|
69
|
+
* Returns the text string, or '' if no assistant text is found.
|
|
70
|
+
*/
|
|
71
|
+
export function lastAssistantText(messages) {
|
|
72
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
73
|
+
const message = messages[index]
|
|
74
|
+
if (message?.role !== 'assistant') continue
|
|
75
|
+
const text = messageText(message)
|
|
76
|
+
if (text) return text
|
|
77
|
+
}
|
|
78
|
+
return ''
|
|
79
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url'
|
|
2
|
+
|
|
3
|
+
function isPlainObject(value) {
|
|
4
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function contentToText(result) {
|
|
8
|
+
if (typeof result === 'string') return result
|
|
9
|
+
if (Array.isArray(result?.content)) {
|
|
10
|
+
return result.content.map((item) => {
|
|
11
|
+
if (typeof item === 'string') return item
|
|
12
|
+
if (item?.type === 'text') return item.text || ''
|
|
13
|
+
return JSON.stringify(item)
|
|
14
|
+
}).join('\n')
|
|
15
|
+
}
|
|
16
|
+
if (Object.prototype.hasOwnProperty.call(result || {}, 'content')) return String(result.content ?? '')
|
|
17
|
+
return JSON.stringify(result ?? null, null, 2)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function loadPlugin(manifest, context = {}) {
|
|
21
|
+
const mainPath = new URL(manifest.main, pathToFileURL(`${manifest.dir}/`))
|
|
22
|
+
const moduleUrl = `${mainPath.href}?quickforgePluginReload=${Date.now()}`
|
|
23
|
+
const module = await import(moduleUrl)
|
|
24
|
+
const factory = module.createPlugin || module.default
|
|
25
|
+
if (typeof factory !== 'function') {
|
|
26
|
+
throw new Error(`Plugin ${manifest.name} must export createPlugin(context) or a default factory function.`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const plugin = await factory({
|
|
30
|
+
...context,
|
|
31
|
+
plugin: {
|
|
32
|
+
name: manifest.name,
|
|
33
|
+
displayName: manifest.displayName,
|
|
34
|
+
version: manifest.version,
|
|
35
|
+
dir: manifest.dir,
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
if (!isPlainObject(plugin)) {
|
|
40
|
+
throw new Error(`Plugin ${manifest.name} factory must return an object.`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const tools = isPlainObject(plugin.tools) ? plugin.tools : {}
|
|
44
|
+
return {
|
|
45
|
+
async callTool(toolName, params = {}, toolContext = {}) {
|
|
46
|
+
const handler = tools[toolName]
|
|
47
|
+
if (typeof handler !== 'function') throw new Error(`Plugin ${manifest.name} did not provide handler for tool ${toolName}.`)
|
|
48
|
+
const result = await handler(params || {}, toolContext)
|
|
49
|
+
return {
|
|
50
|
+
content: contentToText(result),
|
|
51
|
+
details: isPlainObject(result?.details) ? result.details : undefined,
|
|
52
|
+
isError: Boolean(result?.isError),
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
export const PLUGIN_API_VERSION = 1
|
|
4
|
+
|
|
5
|
+
const pluginNamePattern = /^[a-z0-9][a-z0-9_-]{0,63}$/
|
|
6
|
+
const toolNamePattern = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/
|
|
7
|
+
|
|
8
|
+
function isPlainObject(value) {
|
|
9
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function asString(value, fallback = '') {
|
|
13
|
+
return typeof value === 'string' ? value.trim() : fallback
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeStringArray(value) {
|
|
17
|
+
if (!Array.isArray(value)) return []
|
|
18
|
+
const result = []
|
|
19
|
+
const seen = new Set()
|
|
20
|
+
for (const item of value) {
|
|
21
|
+
const text = asString(item)
|
|
22
|
+
if (!text || seen.has(text)) continue
|
|
23
|
+
seen.add(text)
|
|
24
|
+
result.push(text)
|
|
25
|
+
}
|
|
26
|
+
return result
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function addPermission(result, seen, value) {
|
|
30
|
+
const text = asString(value)
|
|
31
|
+
if (!text || seen.has(text)) return
|
|
32
|
+
seen.add(text)
|
|
33
|
+
result.push(text)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizePermissions(value) {
|
|
37
|
+
if (Array.isArray(value)) return normalizeStringArray(value)
|
|
38
|
+
if (!isPlainObject(value)) return []
|
|
39
|
+
|
|
40
|
+
const result = []
|
|
41
|
+
const seen = new Set()
|
|
42
|
+
for (const [key, item] of Object.entries(value)) {
|
|
43
|
+
if (!key) continue
|
|
44
|
+
if (Array.isArray(item)) {
|
|
45
|
+
for (const permission of item) addPermission(result, seen, `${key}:${permission}`)
|
|
46
|
+
} else if (isPlainObject(item)) {
|
|
47
|
+
addPermission(result, seen, `${key}:${JSON.stringify(item)}`)
|
|
48
|
+
} else {
|
|
49
|
+
addPermission(result, seen, `${key}:${item}`)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return result
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeSchema(value) {
|
|
56
|
+
return isPlainObject(value) ? value : { type: 'object', properties: {} }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isPathInside(root, target) {
|
|
60
|
+
const relative = path.relative(root, target)
|
|
61
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeRelativePath(value, fieldName, pluginName, pluginDir) {
|
|
65
|
+
const rawPath = asString(value).replace(/\\/g, '/')
|
|
66
|
+
if (!rawPath) throw new Error(`Plugin ${pluginName} ${fieldName} contribution path is required.`)
|
|
67
|
+
if (rawPath.includes('\0')) throw new Error(`Plugin ${pluginName} ${fieldName} contribution path contains invalid characters.`)
|
|
68
|
+
if (path.isAbsolute(rawPath)) throw new Error(`Plugin ${pluginName} ${fieldName} contribution path must be relative: ${rawPath}`)
|
|
69
|
+
|
|
70
|
+
const resolvedPath = path.resolve(pluginDir, rawPath)
|
|
71
|
+
if (!isPathInside(pluginDir, resolvedPath)) {
|
|
72
|
+
throw new Error(`Plugin ${pluginName} ${fieldName} contribution path is outside the plugin directory: ${rawPath}`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
path: rawPath.replace(/^\.\//, ''),
|
|
77
|
+
resolvedPath,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizePathContributions(value, fieldName, pluginName, pluginDir) {
|
|
82
|
+
const entries = typeof value === 'string' ? [value] : Array.isArray(value) ? value : isPlainObject(value) ? [value] : []
|
|
83
|
+
const result = []
|
|
84
|
+
const seen = new Set()
|
|
85
|
+
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
const rawPath = typeof entry === 'string'
|
|
88
|
+
? entry
|
|
89
|
+
: isPlainObject(entry)
|
|
90
|
+
? entry.path || entry.dir || entry.file
|
|
91
|
+
: ''
|
|
92
|
+
const normalized = normalizeRelativePath(rawPath, fieldName, pluginName, pluginDir)
|
|
93
|
+
const key = normalized.path.toLowerCase()
|
|
94
|
+
if (seen.has(key)) continue
|
|
95
|
+
seen.add(key)
|
|
96
|
+
result.push(normalized)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return result
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function normalizeTool(tool, pluginName) {
|
|
103
|
+
if (!isPlainObject(tool)) throw new Error(`Plugin ${pluginName} tool entry must be an object.`)
|
|
104
|
+
const name = asString(tool.name)
|
|
105
|
+
if (!toolNamePattern.test(name)) {
|
|
106
|
+
throw new Error(`Plugin ${pluginName} has invalid tool name: ${name || '(empty)'}. Use letters, numbers, underscore, or hyphen.`)
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
name,
|
|
110
|
+
label: asString(tool.label || tool.title, name),
|
|
111
|
+
description: asString(tool.description, name),
|
|
112
|
+
parameters: normalizeSchema(tool.parameters || tool.inputSchema),
|
|
113
|
+
executionMode: asString(tool.executionMode) || undefined,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function quickForgePluginToolName(pluginName, toolName) {
|
|
118
|
+
return `plugin__${pluginName}__${toolName.replace(/[^A-Za-z0-9_-]/g, '_')}`
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function parseQuickForgePluginToolName(value) {
|
|
122
|
+
const name = String(value || '')
|
|
123
|
+
if (!name.startsWith('plugin__')) return null
|
|
124
|
+
const rest = name.slice('plugin__'.length)
|
|
125
|
+
const index = rest.indexOf('__')
|
|
126
|
+
if (index <= 0 || index >= rest.length - 2) return null
|
|
127
|
+
return {
|
|
128
|
+
pluginName: rest.slice(0, index),
|
|
129
|
+
toolName: rest.slice(index + 2),
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function isPluginToolName(name) {
|
|
134
|
+
return Boolean(parseQuickForgePluginToolName(name))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function normalizePluginManifest(raw, pluginDir) {
|
|
138
|
+
if (!isPlainObject(raw)) throw new Error('plugin.json must contain an object.')
|
|
139
|
+
|
|
140
|
+
const name = asString(raw.name)
|
|
141
|
+
if (!pluginNamePattern.test(name)) {
|
|
142
|
+
throw new Error(`Invalid plugin name: ${name || '(empty)'}. Use lowercase letters, numbers, underscore, or hyphen.`)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const apiVersion = Number(raw.apiVersion || PLUGIN_API_VERSION)
|
|
146
|
+
if (apiVersion !== PLUGIN_API_VERSION) {
|
|
147
|
+
throw new Error(`Unsupported plugin apiVersion ${apiVersion}. Current QuickForge plugin API is ${PLUGIN_API_VERSION}.`)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const contributes = isPlainObject(raw.contributes) ? raw.contributes : {}
|
|
151
|
+
const tools = Array.isArray(contributes.tools)
|
|
152
|
+
? contributes.tools.map((tool) => normalizeTool(tool, name))
|
|
153
|
+
: []
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
name,
|
|
157
|
+
displayName: asString(raw.displayName || raw.title, name),
|
|
158
|
+
version: asString(raw.version, '0.0.0'),
|
|
159
|
+
description: asString(raw.description),
|
|
160
|
+
apiVersion,
|
|
161
|
+
quickforgeVersion: asString(raw.quickforgeVersion),
|
|
162
|
+
enabledByDefault: raw.enabledByDefault === true,
|
|
163
|
+
main: asString(raw.main, 'index.mjs'),
|
|
164
|
+
permissions: normalizePermissions(raw.permissions),
|
|
165
|
+
contributes: {
|
|
166
|
+
tools,
|
|
167
|
+
skills: normalizePathContributions(contributes.skills, 'skills', name, pluginDir),
|
|
168
|
+
commands: normalizePathContributions(contributes.commands, 'commands', name, pluginDir),
|
|
169
|
+
settings: isPlainObject(contributes.settings) ? contributes.settings : null,
|
|
170
|
+
},
|
|
171
|
+
dir: pluginDir,
|
|
172
|
+
manifestPath: path.join(pluginDir, 'plugin.json'),
|
|
173
|
+
}
|
|
174
|
+
}
|