@shawnstack/quickforge 1.3.25 → 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 +20 -14
- package/dist/assets/{anthropic-B1_Yrokl.js → anthropic-BcnDL7hi.js} +1 -1
- package/dist/assets/{azure-openai-responses-UMiOBCBd.js → azure-openai-responses-BEfdv0qd.js} +1 -1
- package/dist/assets/{google-BLE_Gcd1.js → google-C2y985rW.js} +1 -1
- package/dist/assets/{google-vertex-6_sIZLVc.js → google-vertex-Jf9zNsCF.js} +1 -1
- package/dist/assets/{icons-Bs7OG8yi.js → icons-BVM5--R9.js} +1 -1
- package/dist/assets/{index-C3bc5C3k.js → index-8Q1Ovled.js} +595 -547
- package/dist/assets/index-ZYbEKGUp.css +3 -0
- package/dist/assets/{mistral-DmZEmRkv.js → mistral-qYbgRY3z.js} +1 -1
- package/dist/assets/{openai-codex-responses-i_SmQGzQ.js → openai-codex-responses--aAgyYJM.js} +1 -1
- package/dist/assets/{openai-completions-BmmZFDDY.js → openai-completions-CHDluyXM.js} +1 -1
- package/dist/assets/{openai-responses-C8tPdeE9.js → openai-responses-UtRriBXu.js} +1 -1
- package/dist/assets/{openai-responses-shared-DchtjQNp.js → openai-responses-shared-G6WDDqJ8.js} +1 -1
- package/dist/assets/{openrouter-CcTv1G_v.js → openrouter-Dz9zwzUG.js} +1 -1
- package/dist/assets/{react-vendor-Cu-7p9CI.js → react-vendor-DAoL5p8_.js} +1 -1
- package/dist/index.html +4 -4
- package/package.json +2 -1
- package/server/agent-manager.mjs +102 -22
- package/server/approval-store.mjs +1 -1
- package/server/custom-commands.mjs +67 -9
- package/server/index.mjs +7 -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.mjs +1 -16
- package/server/routes/plugins.mjs +63 -0
- package/server/routes/project.mjs +2 -0
- package/server/routes/tools.mjs +12 -1
- package/server/skills.mjs +64 -5
- package/server/storage.mjs +13 -6
- package/server/system-prompt.mjs +27 -5
- package/server/tool-wiring.mjs +26 -0
- package/dist/assets/index-C7oT9Rdw.css +0 -3
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { dataDir, readStore, atomicUpdate } from '../storage.mjs'
|
|
6
|
+
import { logger } from '../utils/logger.mjs'
|
|
7
|
+
import { loadPlugin } from './loader.mjs'
|
|
8
|
+
import {
|
|
9
|
+
isPluginToolName,
|
|
10
|
+
normalizePluginManifest,
|
|
11
|
+
parseQuickForgePluginToolName,
|
|
12
|
+
quickForgePluginToolName,
|
|
13
|
+
} from './manifest.mjs'
|
|
14
|
+
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
16
|
+
const bundledPluginDir = path.resolve(__dirname, '..', '..', 'plugins')
|
|
17
|
+
const globalPluginDir = path.join(dataDir, 'plugins')
|
|
18
|
+
const legacyGlobalPluginDir = path.join(os.homedir(), '.agents', 'plugins')
|
|
19
|
+
let cachedCatalog = null
|
|
20
|
+
let cachedCatalogKey = null
|
|
21
|
+
let refreshPromise = null
|
|
22
|
+
|
|
23
|
+
function catalogKey(projectContext = null) {
|
|
24
|
+
return projectContext?.workspaceRoot ? path.resolve(projectContext.workspaceRoot) : ''
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isPlainObject(value) {
|
|
28
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function exists(filePath) {
|
|
32
|
+
try {
|
|
33
|
+
await fs.access(filePath)
|
|
34
|
+
return true
|
|
35
|
+
} catch {
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function readJsonFile(filePath) {
|
|
41
|
+
const text = await fs.readFile(filePath, 'utf8')
|
|
42
|
+
return JSON.parse(text.trimStart())
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function listPluginDirs(root) {
|
|
46
|
+
let entries = []
|
|
47
|
+
try {
|
|
48
|
+
entries = await fs.readdir(root, { withFileTypes: true })
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error?.code === 'ENOENT') return []
|
|
51
|
+
throw error
|
|
52
|
+
}
|
|
53
|
+
return entries.filter((entry) => entry.isDirectory()).map((entry) => path.join(root, entry.name))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizePluginStore(store) {
|
|
57
|
+
const source = isPlainObject(store) ? store : {}
|
|
58
|
+
return {
|
|
59
|
+
enabled: isPlainObject(source.enabled) ? source.enabled : {},
|
|
60
|
+
config: isPlainObject(source.config) ? source.config : {},
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function readPluginStore() {
|
|
65
|
+
return normalizePluginStore(await readStore('plugins'))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function contributionSummary(contribution) {
|
|
69
|
+
return {
|
|
70
|
+
path: contribution.path,
|
|
71
|
+
resolvedPath: contribution.resolvedPath,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function discoverManifests(projectContext) {
|
|
76
|
+
const roots = [bundledPluginDir, globalPluginDir, legacyGlobalPluginDir]
|
|
77
|
+
if (projectContext?.workspaceRoot) roots.push(path.join(projectContext.workspaceRoot, '.quickforge', 'plugins'))
|
|
78
|
+
|
|
79
|
+
const manifests = new Map()
|
|
80
|
+
const errors = []
|
|
81
|
+
for (const root of roots) {
|
|
82
|
+
const dirs = await listPluginDirs(root)
|
|
83
|
+
for (const dir of dirs) {
|
|
84
|
+
const manifestPath = path.join(dir, 'plugin.json')
|
|
85
|
+
if (!(await exists(manifestPath))) continue
|
|
86
|
+
try {
|
|
87
|
+
const manifest = normalizePluginManifest(await readJsonFile(manifestPath), dir)
|
|
88
|
+
if (!manifests.has(manifest.name) || root !== legacyGlobalPluginDir) {
|
|
89
|
+
manifests.set(manifest.name, { manifest, sourceRoot: root })
|
|
90
|
+
}
|
|
91
|
+
} catch (error) {
|
|
92
|
+
errors.push({ dir, sourceRoot: root, error: error?.message || 'Failed to read plugin manifest' })
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { manifests, errors, roots }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function loadEnabledPlugins(projectContext) {
|
|
100
|
+
const store = await readPluginStore()
|
|
101
|
+
const { manifests, errors, roots } = await discoverManifests(projectContext)
|
|
102
|
+
const plugins = []
|
|
103
|
+
const handlers = new Map()
|
|
104
|
+
|
|
105
|
+
for (const { manifest, sourceRoot } of manifests.values()) {
|
|
106
|
+
const enabled = store.enabled[manifest.name] === true || (manifest.enabledByDefault && store.enabled[manifest.name] !== false)
|
|
107
|
+
const config = isPlainObject(store.config[manifest.name]) ? store.config[manifest.name] : {}
|
|
108
|
+
const entry = {
|
|
109
|
+
...manifest,
|
|
110
|
+
enabled,
|
|
111
|
+
config,
|
|
112
|
+
sourceRoot,
|
|
113
|
+
status: enabled ? 'loaded' : 'disabled',
|
|
114
|
+
error: null,
|
|
115
|
+
tools: manifest.contributes.tools.map((tool) => ({
|
|
116
|
+
...tool,
|
|
117
|
+
quickForgeName: quickForgePluginToolName(manifest.name, tool.name),
|
|
118
|
+
})),
|
|
119
|
+
skills: manifest.contributes.skills.map(contributionSummary),
|
|
120
|
+
commands: manifest.contributes.commands.map(contributionSummary),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (enabled && entry.tools.length > 0) {
|
|
124
|
+
try {
|
|
125
|
+
const loaded = await loadPlugin(manifest, { config, projectContext })
|
|
126
|
+
handlers.set(manifest.name, loaded)
|
|
127
|
+
} catch (error) {
|
|
128
|
+
logger.error(`Failed to load plugin ${manifest.name}:`, error)
|
|
129
|
+
entry.status = 'error'
|
|
130
|
+
entry.error = error?.message || 'Failed to load plugin'
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
plugins.push(entry)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
plugins.sort((a, b) => a.name.localeCompare(b.name))
|
|
138
|
+
return { plugins, handlers, errors, roots }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function refreshPlugins(projectContext = null) {
|
|
142
|
+
const key = catalogKey(projectContext)
|
|
143
|
+
if (!refreshPromise) {
|
|
144
|
+
refreshPromise = loadEnabledPlugins(projectContext).then((catalog) => {
|
|
145
|
+
cachedCatalog = catalog
|
|
146
|
+
cachedCatalogKey = key
|
|
147
|
+
return catalog
|
|
148
|
+
}).finally(() => {
|
|
149
|
+
refreshPromise = null
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
return refreshPromise
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function getCatalog(projectContext = null) {
|
|
156
|
+
const key = catalogKey(projectContext)
|
|
157
|
+
if (!cachedCatalog || cachedCatalogKey !== key) return refreshPlugins(projectContext)
|
|
158
|
+
return cachedCatalog
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function enabledLoadedPlugins(catalog) {
|
|
162
|
+
return catalog.plugins.filter((plugin) => plugin.enabled && plugin.status === 'loaded')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function getEnabledPluginSkillSources(projectContext = null) {
|
|
166
|
+
const catalog = await getCatalog(projectContext)
|
|
167
|
+
return enabledLoadedPlugins(catalog).flatMap((plugin) => plugin.skills.map((skill) => ({
|
|
168
|
+
pluginName: plugin.name,
|
|
169
|
+
source: `plugin:${plugin.name}`,
|
|
170
|
+
dir: skill.resolvedPath,
|
|
171
|
+
path: skill.path,
|
|
172
|
+
})))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function getEnabledPluginCommandSources(projectContext = null) {
|
|
176
|
+
const catalog = await getCatalog(projectContext)
|
|
177
|
+
return enabledLoadedPlugins(catalog).flatMap((plugin) => plugin.commands.map((command) => ({
|
|
178
|
+
pluginName: plugin.name,
|
|
179
|
+
source: `plugin:${plugin.name}`,
|
|
180
|
+
path: command.resolvedPath,
|
|
181
|
+
relativePath: command.path,
|
|
182
|
+
})))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function getPluginStatus(projectContext = null) {
|
|
186
|
+
const catalog = await refreshPlugins(projectContext)
|
|
187
|
+
return {
|
|
188
|
+
searchPaths: catalog.roots,
|
|
189
|
+
errors: catalog.errors,
|
|
190
|
+
plugins: catalog.plugins.map((plugin) => ({
|
|
191
|
+
name: plugin.name,
|
|
192
|
+
displayName: plugin.displayName,
|
|
193
|
+
version: plugin.version,
|
|
194
|
+
description: plugin.description,
|
|
195
|
+
apiVersion: plugin.apiVersion,
|
|
196
|
+
quickforgeVersion: plugin.quickforgeVersion,
|
|
197
|
+
enabledByDefault: plugin.enabledByDefault,
|
|
198
|
+
dir: plugin.dir,
|
|
199
|
+
sourceRoot: plugin.sourceRoot,
|
|
200
|
+
enabled: plugin.enabled,
|
|
201
|
+
status: plugin.status,
|
|
202
|
+
error: plugin.error,
|
|
203
|
+
permissions: plugin.permissions,
|
|
204
|
+
tools: plugin.tools.map((tool) => ({
|
|
205
|
+
name: tool.name,
|
|
206
|
+
quickForgeName: tool.quickForgeName,
|
|
207
|
+
label: tool.label,
|
|
208
|
+
description: tool.description,
|
|
209
|
+
})),
|
|
210
|
+
skills: plugin.skills.map((skill) => ({ path: skill.path })),
|
|
211
|
+
commands: plugin.commands.map((command) => ({ path: command.path })),
|
|
212
|
+
settings: plugin.contributes.settings,
|
|
213
|
+
config: plugin.config,
|
|
214
|
+
})),
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function setPluginEnabled(name, enabled) {
|
|
219
|
+
await atomicUpdate('plugins', (store) => {
|
|
220
|
+
const next = normalizePluginStore(store)
|
|
221
|
+
next.enabled[name] = enabled === true
|
|
222
|
+
return next
|
|
223
|
+
})
|
|
224
|
+
cachedCatalog = null
|
|
225
|
+
cachedCatalogKey = null
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function setPluginConfig(name, config) {
|
|
229
|
+
await atomicUpdate('plugins', (store) => {
|
|
230
|
+
const next = normalizePluginStore(store)
|
|
231
|
+
next.config[name] = isPlainObject(config) ? config : {}
|
|
232
|
+
return next
|
|
233
|
+
})
|
|
234
|
+
cachedCatalog = null
|
|
235
|
+
cachedCatalogKey = null
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function createPluginToolDefinitions(projectContext = null) {
|
|
239
|
+
const catalog = await getCatalog(projectContext)
|
|
240
|
+
const definitions = []
|
|
241
|
+
for (const plugin of catalog.plugins) {
|
|
242
|
+
if (!plugin.enabled || plugin.status !== 'loaded') continue
|
|
243
|
+
for (const tool of plugin.tools) {
|
|
244
|
+
definitions.push({
|
|
245
|
+
name: tool.quickForgeName,
|
|
246
|
+
label: tool.label,
|
|
247
|
+
description: `[Plugin:${plugin.name}] ${tool.description || tool.name}`,
|
|
248
|
+
parameters: tool.parameters,
|
|
249
|
+
executionMode: tool.executionMode,
|
|
250
|
+
plugin: { name: plugin.name, toolName: tool.name },
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return definitions
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export { isPluginToolName }
|
|
258
|
+
|
|
259
|
+
export async function callPluginTool(toolName, params = {}, toolContext = {}) {
|
|
260
|
+
const parsed = parseQuickForgePluginToolName(toolName)
|
|
261
|
+
if (!parsed) {
|
|
262
|
+
const error = new Error(`Invalid plugin tool name: ${toolName}`)
|
|
263
|
+
error.statusCode = 400
|
|
264
|
+
throw error
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const catalog = await getCatalog(toolContext)
|
|
268
|
+
const plugin = catalog.plugins.find((item) => item.name === parsed.pluginName)
|
|
269
|
+
if (!plugin || !plugin.enabled || plugin.status !== 'loaded') {
|
|
270
|
+
const error = new Error(`Plugin is not loaded: ${parsed.pluginName}`)
|
|
271
|
+
error.statusCode = 503
|
|
272
|
+
throw error
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const tool = plugin.tools.find((item) => item.name === parsed.toolName || item.quickForgeName === toolName)
|
|
276
|
+
if (!tool) {
|
|
277
|
+
const error = new Error(`Unknown plugin tool: ${toolName}`)
|
|
278
|
+
error.statusCode = 404
|
|
279
|
+
throw error
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const handler = catalog.handlers.get(parsed.pluginName)
|
|
283
|
+
if (!handler) {
|
|
284
|
+
const error = new Error(`Missing plugin handler: ${parsed.pluginName}`)
|
|
285
|
+
error.statusCode = 503
|
|
286
|
+
throw error
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const result = await handler.callTool(tool.name, params, {
|
|
290
|
+
...toolContext,
|
|
291
|
+
plugin: { name: plugin.name, dir: plugin.dir, config: plugin.config },
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
isError: Boolean(result.isError),
|
|
296
|
+
content: result.content,
|
|
297
|
+
details: {
|
|
298
|
+
...(isPlainObject(result.details) ? result.details : {}),
|
|
299
|
+
plugin: true,
|
|
300
|
+
pluginName: plugin.name,
|
|
301
|
+
tool: tool.name,
|
|
302
|
+
},
|
|
303
|
+
}
|
|
304
|
+
}
|
|
@@ -298,24 +298,71 @@ export async function readInstructionsFile(filePath) {
|
|
|
298
298
|
return null
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
async function readInstructionSources(candidates) {
|
|
302
|
+
const sources = []
|
|
303
|
+
const seen = new Set()
|
|
304
|
+
|
|
305
|
+
for (const candidate of candidates) {
|
|
306
|
+
const file = path.resolve(candidate.file)
|
|
307
|
+
if (seen.has(file)) continue
|
|
308
|
+
seen.add(file)
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
const content = await fs.readFile(file, 'utf8')
|
|
312
|
+
const trimmed = content.trim()
|
|
313
|
+
if (trimmed) sources.push({ source: candidate.source, content: trimmed })
|
|
314
|
+
} catch {
|
|
315
|
+
// optional compatibility source
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return sources
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function combineInstructionSources(sources) {
|
|
323
|
+
return sources.map((source) => source.content).join('\n\n') || null
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function globalInstructionCandidates() {
|
|
327
|
+
const home = os.homedir()
|
|
328
|
+
return [
|
|
329
|
+
{ file: path.join(home, '.claude', 'CLAUDE.md'), source: '~/.claude/CLAUDE.md' },
|
|
330
|
+
{ file: path.join(home, '.opencode', 'AGENTS.md'), source: '~/.opencode/AGENTS.md' },
|
|
331
|
+
{ file: path.join(dataDir, 'AGENTS.md'), source: '~/.quickforge/AGENTS.md' },
|
|
332
|
+
{ file: path.join(dataDir, 'agents.md'), source: '~/.quickforge/agents.md' },
|
|
333
|
+
]
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function projectInstructionCandidates(workspaceRoot) {
|
|
337
|
+
return [
|
|
338
|
+
{ file: path.join(workspaceRoot, 'CLAUDE.md'), source: 'CLAUDE.md' },
|
|
339
|
+
{ file: path.join(workspaceRoot, 'AGENTS.md'), source: 'AGENTS.md' },
|
|
340
|
+
{ file: path.join(workspaceRoot, 'agents.md'), source: 'agents.md' },
|
|
341
|
+
{ file: path.join(workspaceRoot, '.opencode', 'AGENTS.md'), source: '.opencode/AGENTS.md' },
|
|
342
|
+
{ file: path.join(workspaceRoot, '.quickforge', 'AGENTS.md'), source: '.quickforge/AGENTS.md' },
|
|
343
|
+
]
|
|
344
|
+
}
|
|
345
|
+
|
|
301
346
|
export async function buildInstructionsPayload(projectId) {
|
|
302
347
|
const config = await readProjectConfig()
|
|
303
|
-
let
|
|
348
|
+
let projectInstructionSources = []
|
|
304
349
|
let project = projectId ? config.projects.find((item) => item.id === projectId) ?? null : null
|
|
305
350
|
|
|
306
351
|
if (projectId) {
|
|
307
352
|
try {
|
|
308
353
|
const context = await projectContextFromId(projectId)
|
|
309
354
|
project = context.project
|
|
310
|
-
|
|
355
|
+
projectInstructionSources = await readInstructionSources(projectInstructionCandidates(context.workspaceRoot))
|
|
311
356
|
} catch {
|
|
312
357
|
// project not found or inaccessible — leave projectInstructions null
|
|
313
358
|
}
|
|
314
359
|
}
|
|
315
360
|
|
|
316
|
-
const
|
|
361
|
+
const globalInstructionSources = await readInstructionSources(globalInstructionCandidates())
|
|
362
|
+
const globalInstructions = combineInstructionSources(globalInstructionSources)
|
|
363
|
+
const projectInstructions = combineInstructionSources(projectInstructionSources)
|
|
317
364
|
const globalSkills = await loadSelectedGlobalSkills(config.globalSkills)
|
|
318
|
-
const projectSkills = project?.
|
|
365
|
+
const projectSkills = project?.path
|
|
319
366
|
? await loadSelectedProjectSkills(project.skills, project.path)
|
|
320
367
|
: []
|
|
321
368
|
const activeSkills = mergeSkills(globalSkills, projectSkills)
|
|
@@ -331,6 +378,8 @@ export async function buildInstructionsPayload(projectId) {
|
|
|
331
378
|
: null,
|
|
332
379
|
global: globalInstructions,
|
|
333
380
|
project: projectInstructions,
|
|
381
|
+
globalSources: globalInstructionSources,
|
|
382
|
+
projectSources: projectInstructionSources,
|
|
334
383
|
globalSkills: globalSkills.map(stripRuntimeFields),
|
|
335
384
|
projectSkills: projectSkills.map(stripRuntimeFields),
|
|
336
385
|
skills: activeSkills.map(stripRuntimeFields),
|
package/server/routes/agent.mjs
CHANGED
|
@@ -23,7 +23,6 @@ import {
|
|
|
23
23
|
approveAutoCompact,
|
|
24
24
|
rejectAutoCompact,
|
|
25
25
|
abortToolCall,
|
|
26
|
-
replaceSessionMessages,
|
|
27
26
|
rollbackSessionMessages,
|
|
28
27
|
continueSession,
|
|
29
28
|
agentEvents,
|
|
@@ -83,7 +82,7 @@ export async function handleAgentApi(req, res, url) {
|
|
|
83
82
|
error.statusCode = 400
|
|
84
83
|
throw error
|
|
85
84
|
}
|
|
86
|
-
const result = await runPrompt(sessionId, message)
|
|
85
|
+
const result = await runPrompt(sessionId, message, body?.selectedCapabilities)
|
|
87
86
|
sendJson(res, 200, result)
|
|
88
87
|
return
|
|
89
88
|
}
|
|
@@ -169,20 +168,6 @@ export async function handleAgentApi(req, res, url) {
|
|
|
169
168
|
return
|
|
170
169
|
}
|
|
171
170
|
|
|
172
|
-
// POST /api/agents/:sessionId/messages — replace session messages (legacy rollback/sync)
|
|
173
|
-
if (req.method === 'POST' && subPath === 'messages') {
|
|
174
|
-
const body = await readJsonBody(req)
|
|
175
|
-
const messages = body?.messages
|
|
176
|
-
if (!Array.isArray(messages)) {
|
|
177
|
-
const error = new Error('Missing messages array in request body')
|
|
178
|
-
error.statusCode = 400
|
|
179
|
-
throw error
|
|
180
|
-
}
|
|
181
|
-
const state = await replaceSessionMessages(sessionId, messages)
|
|
182
|
-
sendJson(res, 200, { ok: true, messages: state?.messages })
|
|
183
|
-
return
|
|
184
|
-
}
|
|
185
|
-
|
|
186
171
|
// POST /api/agents/:sessionId/rollback — roll back from a message index on the authoritative server state
|
|
187
172
|
if (req.method === 'POST' && subPath === 'rollback') {
|
|
188
173
|
const body = await readJsonBody(req)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
|
|
2
|
+
import { refreshAllSessionTools } from '../agent-manager.mjs'
|
|
3
|
+
import { projectContextFromId, readProjectConfig } from '../project-config.mjs'
|
|
4
|
+
import { getPluginStatus, refreshPlugins, setPluginConfig, setPluginEnabled } from '../plugins/registry.mjs'
|
|
5
|
+
|
|
6
|
+
async function activeProjectContext() {
|
|
7
|
+
const config = await readProjectConfig()
|
|
8
|
+
const activeProject = config.projects.find((project) => project.id === config.activeProjectId) || config.projects[0]
|
|
9
|
+
if (!activeProject?.id) return null
|
|
10
|
+
return projectContextFromId(activeProject.id)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function refreshPluginsAndAgentTools() {
|
|
14
|
+
const projectContext = await activeProjectContext()
|
|
15
|
+
await refreshPlugins(projectContext)
|
|
16
|
+
const refreshedSessions = await refreshAllSessionTools()
|
|
17
|
+
return { ...(await getPluginStatus(projectContext)), refreshedSessions }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function handlePluginsApi(req, res, url) {
|
|
21
|
+
const parts = url.pathname.split('/').filter(Boolean)
|
|
22
|
+
|
|
23
|
+
if (req.method === 'GET' && url.pathname === '/api/plugins') {
|
|
24
|
+
sendJson(res, 200, await getPluginStatus(await activeProjectContext()))
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (req.method === 'POST' && url.pathname === '/api/plugins/reload') {
|
|
29
|
+
sendJson(res, 200, await refreshPluginsAndAgentTools())
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (req.method === 'GET' && parts[0] === 'api' && parts[1] === 'plugins' && parts[2]) {
|
|
34
|
+
const name = decodeSegment(parts[2])
|
|
35
|
+
const status = await getPluginStatus(await activeProjectContext())
|
|
36
|
+
const plugin = status.plugins.find((item) => item.name === name)
|
|
37
|
+
if (!plugin) {
|
|
38
|
+
const error = new Error(`Unknown plugin: ${name}`)
|
|
39
|
+
error.statusCode = 404
|
|
40
|
+
throw error
|
|
41
|
+
}
|
|
42
|
+
sendJson(res, 200, { ...status, plugin })
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (req.method === 'PUT' && parts[0] === 'api' && parts[1] === 'plugins' && parts[2] && parts[3] === 'enabled') {
|
|
47
|
+
const body = await readJsonBody(req)
|
|
48
|
+
await setPluginEnabled(decodeSegment(parts[2]), body?.enabled === true)
|
|
49
|
+
sendJson(res, 200, await refreshPluginsAndAgentTools())
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (req.method === 'PUT' && parts[0] === 'api' && parts[1] === 'plugins' && parts[2] && parts[3] === 'config') {
|
|
54
|
+
const body = await readJsonBody(req)
|
|
55
|
+
await setPluginConfig(decodeSegment(parts[2]), body?.config || body || {})
|
|
56
|
+
sendJson(res, 200, await refreshPluginsAndAgentTools())
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const error = new Error('Not found')
|
|
61
|
+
error.statusCode = 404
|
|
62
|
+
throw error
|
|
63
|
+
}
|