@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.
Files changed (57) hide show
  1. package/README.md +24 -18
  2. package/dist/assets/anthropic-BcnDL7hi.js +39 -0
  3. package/dist/assets/azure-openai-responses-BEfdv0qd.js +1 -0
  4. package/dist/assets/google-C2y985rW.js +1 -0
  5. package/dist/assets/google-shared-Cqjw1plk.js +11 -0
  6. package/dist/assets/google-vertex-Jf9zNsCF.js +1 -0
  7. package/dist/assets/{icons-DmRYmmql.js → icons-BVM5--R9.js} +1 -1
  8. package/dist/assets/{index-s72bxhrh.js → index-8Q1Ovled.js} +604 -550
  9. package/dist/assets/index-ZYbEKGUp.css +3 -0
  10. package/dist/assets/{mistral-DCZ8VphX.js → mistral-qYbgRY3z.js} +1 -1
  11. package/dist/assets/openai-codex-responses--aAgyYJM.js +7 -0
  12. package/dist/assets/openai-completions-CHDluyXM.js +5 -0
  13. package/dist/assets/openai-prompt-cache-CErE62Yt.js +1 -0
  14. package/dist/assets/openai-responses-UtRriBXu.js +1 -0
  15. package/dist/assets/{openai-responses-shared-RzgnIlMf.js → openai-responses-shared-G6WDDqJ8.js} +1 -1
  16. package/dist/assets/openrouter-Dz9zwzUG.js +1 -0
  17. package/dist/assets/{react-vendor-BsV2HYbc.js → react-vendor-DAoL5p8_.js} +1 -1
  18. package/dist/assets/sanitize-unicode-BhyPmlyt.js +1 -0
  19. package/dist/assets/transform-messages-Dhj_4OTw.js +1 -0
  20. package/dist/index.html +4 -4
  21. package/package.json +4 -3
  22. package/server/agent-manager.mjs +162 -176
  23. package/server/ai-http-logger.mjs +20 -5
  24. package/server/approval-store.mjs +63 -0
  25. package/server/custom-commands.mjs +67 -9
  26. package/server/index.mjs +7 -0
  27. package/server/message-converters.mjs +79 -0
  28. package/server/plugins/loader.mjs +56 -0
  29. package/server/plugins/manifest.mjs +174 -0
  30. package/server/plugins/registry.mjs +304 -0
  31. package/server/project-config.mjs +53 -4
  32. package/server/routes/agent-profiles.mjs +1 -1
  33. package/server/routes/agent.mjs +1 -16
  34. package/server/routes/filesystem.mjs +18 -2
  35. package/server/routes/plugins.mjs +63 -0
  36. package/server/routes/project.mjs +2 -0
  37. package/server/routes/scheduled-tasks.mjs +1 -1
  38. package/server/routes/storage.mjs +66 -31
  39. package/server/routes/tools.mjs +12 -1
  40. package/server/session-utils.mjs +1 -1
  41. package/server/skills.mjs +64 -5
  42. package/server/storage.mjs +91 -8
  43. package/server/system-prompt.mjs +27 -5
  44. package/server/tool-wiring.mjs +113 -0
  45. package/server/utils/workspace.mjs +20 -1
  46. package/dist/assets/anthropic-BrbLtQkg.js +0 -39
  47. package/dist/assets/azure-openai-responses-q9QFpQk3.js +0 -1
  48. package/dist/assets/google-Bv6IeSRf.js +0 -1
  49. package/dist/assets/google-shared-CLc4ziON.js +0 -11
  50. package/dist/assets/google-vertex-Cwpe8vbn.js +0 -1
  51. package/dist/assets/index-C4m48ndP.css +0 -3
  52. package/dist/assets/openai-codex-responses-Bx7iyHzd.js +0 -7
  53. package/dist/assets/openai-completions-CihVV11E.js +0 -5
  54. package/dist/assets/openai-responses-BigEdUNS.js +0 -1
  55. package/dist/assets/transform-messages-CmnxG9RB.js +0 -1
  56. /package/dist/assets/{hash-Bt1aVMQ3.js → hash-kZ2KD_no.js} +0 -0
  57. /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
- const defaultDir = commandDirectory(workspaceRoot)
27
- if (!defaultDir) return []
28
+ if (!workspaceRoot) return []
28
29
 
29
- const dirs = [defaultDir]
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 command = commandFromFile(file, await fs.readFile(file, 'utf8'))
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 `.ai/commands/*.md`. Use `$ARGUMENTS` inside a command file to insert invocation arguments.',
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
+ }