@geekbeer/minion 3.17.0 → 3.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/lib/dag-step-poller.js +22 -13
- package/core/lib/llm-checker.js +4 -7
- package/core/lib/template-expander.js +21 -18
- package/core/llm-dispatch/mcp-server.js +185 -0
- package/core/llm-dispatch/session-pool.js +97 -0
- package/core/llm-plugins/claude/index.js +151 -0
- package/core/llm-plugins/claude/stream.js +166 -0
- package/core/llm-plugins/codex/index.js +161 -0
- package/core/llm-plugins/gemini/index.js +104 -0
- package/core/llm-plugins/lib/active.js +23 -0
- package/core/llm-plugins/lib/mcp-registration.js +132 -0
- package/core/llm-plugins/lib/skill-dirs.js +67 -0
- package/core/llm-plugins/lib/spawn-helper.js +88 -0
- package/core/llm-plugins/registry.js +168 -0
- package/core/llm-plugins/types.js +85 -0
- package/core/routes/llm.js +89 -0
- package/core/routes/skills.js +112 -57
- package/docs/api-reference.md +439 -0
- package/docs/task-guides.md +220 -0
- package/linux/bin/hq +168 -15
- package/linux/minion-cli.sh +14 -0
- package/linux/routes/chat.js +121 -2
- package/linux/routine-runner.js +22 -7
- package/linux/server.js +2 -0
- package/linux/workflow-runner.js +26 -8
- package/package.json +1 -1
- package/rules/core.md +13 -7
- package/win/bin/hq.ps1 +155 -27
- package/win/routes/chat.js +115 -2
- package/win/routine-runner.js +20 -6
- package/win/server.js +2 -0
- package/win/workflow-runner.js +20 -7
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code stream-json parser.
|
|
3
|
+
*
|
|
4
|
+
* Spawns `claude -p --verbose --output-format stream-json` and translates
|
|
5
|
+
* the CLI's event stream into normalized LlmEvent shapes consumed by
|
|
6
|
+
* chat routes. Extracted from linux/routes/chat.js streamLlmResponse().
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { spawn } = require('child_process')
|
|
10
|
+
const { config } = require('../../config')
|
|
11
|
+
const { IS_WINDOWS, buildExtendedPath } = require('../../lib/platform')
|
|
12
|
+
const { resolveBinary } = require('../lib/spawn-helper')
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {import('../types').LlmInput} input
|
|
16
|
+
* @param {(event: any) => void} onEvent
|
|
17
|
+
* @param {{ resumeSessionId?: string | null, activeChildRef?: { current: any } }} [ctx]
|
|
18
|
+
* @returns {Promise<import('../types').LlmOutput>}
|
|
19
|
+
*/
|
|
20
|
+
function streamClaude(input, onEvent, ctx = {}) {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const bin = resolveBinary('claude', config.HOME_DIR)
|
|
23
|
+
const started = Date.now()
|
|
24
|
+
|
|
25
|
+
const args = [
|
|
26
|
+
'-p',
|
|
27
|
+
'--verbose',
|
|
28
|
+
'--model', input.model || 'sonnet',
|
|
29
|
+
'--output-format', 'stream-json',
|
|
30
|
+
]
|
|
31
|
+
if (ctx.resumeSessionId) args.push('--resume', ctx.resumeSessionId)
|
|
32
|
+
|
|
33
|
+
console.log(`[claude-plugin] spawn: ${bin} ${args.join(' ')} (resume=${ctx.resumeSessionId || 'new'})`)
|
|
34
|
+
|
|
35
|
+
const child = spawn(bin, args, {
|
|
36
|
+
cwd: config.HOME_DIR,
|
|
37
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
38
|
+
timeout: input.timeoutMs || 600000,
|
|
39
|
+
env: {
|
|
40
|
+
...process.env,
|
|
41
|
+
HOME: config.HOME_DIR,
|
|
42
|
+
...(IS_WINDOWS && { USERPROFILE: config.HOME_DIR }),
|
|
43
|
+
PATH: buildExtendedPath(config.HOME_DIR),
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
if (ctx.activeChildRef) ctx.activeChildRef.current = child
|
|
48
|
+
|
|
49
|
+
child.stdin.write(input.prompt)
|
|
50
|
+
child.stdin.end()
|
|
51
|
+
|
|
52
|
+
let fullText = ''
|
|
53
|
+
let stderrBuf = ''
|
|
54
|
+
let lineBuf = ''
|
|
55
|
+
let resolvedSessionId = ctx.resumeSessionId || null
|
|
56
|
+
let currentBlockType = null
|
|
57
|
+
let currentToolName = null
|
|
58
|
+
let toolInputBuffer = ''
|
|
59
|
+
let turnCount = 0
|
|
60
|
+
|
|
61
|
+
child.stdout.on('data', chunk => {
|
|
62
|
+
lineBuf += chunk.toString()
|
|
63
|
+
const parts = lineBuf.split('\n')
|
|
64
|
+
lineBuf = parts.pop() || ''
|
|
65
|
+
for (const line of parts) {
|
|
66
|
+
if (!line.trim()) continue
|
|
67
|
+
let parsed
|
|
68
|
+
try { parsed = JSON.parse(line) } catch { continue }
|
|
69
|
+
|
|
70
|
+
if (parsed.type === 'system' && parsed.session_id) {
|
|
71
|
+
resolvedSessionId = parsed.session_id
|
|
72
|
+
onEvent({ type: 'session', sessionId: resolvedSessionId })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (parsed.type === 'content_block_start') {
|
|
76
|
+
const bt = parsed.content_block?.type
|
|
77
|
+
if (bt === 'tool_use') {
|
|
78
|
+
currentBlockType = 'tool_use'
|
|
79
|
+
currentToolName = parsed.content_block.name || 'unknown'
|
|
80
|
+
toolInputBuffer = ''
|
|
81
|
+
onEvent({ type: 'tool_start', tool: currentToolName })
|
|
82
|
+
} else if (bt === 'text') {
|
|
83
|
+
currentBlockType = 'text'
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (parsed.type === 'content_block_delta') {
|
|
88
|
+
const dt = parsed.delta?.type
|
|
89
|
+
if (dt === 'input_json_delta' && currentBlockType === 'tool_use') {
|
|
90
|
+
const partial = parsed.delta.partial_json || ''
|
|
91
|
+
if (partial) {
|
|
92
|
+
toolInputBuffer += partial
|
|
93
|
+
onEvent({ type: 'tool_input_delta', partial_json: partial })
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
const delta = parsed.delta?.text || ''
|
|
97
|
+
if (delta) {
|
|
98
|
+
fullText += delta
|
|
99
|
+
onEvent({ type: 'delta', content: delta })
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (parsed.type === 'content_block_stop') {
|
|
105
|
+
if (currentBlockType === 'tool_use') {
|
|
106
|
+
let parsedInput = null
|
|
107
|
+
try { if (toolInputBuffer) parsedInput = JSON.parse(toolInputBuffer) } catch { /* ignore */ }
|
|
108
|
+
onEvent({ type: 'tool_end', tool: currentToolName, input: parsedInput })
|
|
109
|
+
}
|
|
110
|
+
currentBlockType = null
|
|
111
|
+
currentToolName = null
|
|
112
|
+
toolInputBuffer = ''
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (parsed.type === 'assistant' && parsed.message) {
|
|
116
|
+
turnCount++
|
|
117
|
+
for (const block of (parsed.message.content || [])) {
|
|
118
|
+
if (block.type === 'text') {
|
|
119
|
+
fullText += block.text
|
|
120
|
+
onEvent({ type: 'text', content: block.text })
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} else if (parsed.type === 'result') {
|
|
124
|
+
const resultText = parsed.result || ''
|
|
125
|
+
if (resultText) {
|
|
126
|
+
onEvent({ type: 'result', content: resultText })
|
|
127
|
+
if (!fullText) fullText = resultText
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
child.stderr.on('data', chunk => {
|
|
134
|
+
stderrBuf += chunk.toString()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
child.on('error', err => {
|
|
138
|
+
if (ctx.activeChildRef) ctx.activeChildRef.current = null
|
|
139
|
+
onEvent({ type: 'error', message: err.message })
|
|
140
|
+
reject(err)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
child.on('close', code => {
|
|
144
|
+
if (ctx.activeChildRef) ctx.activeChildRef.current = null
|
|
145
|
+
const durationMs = Date.now() - started
|
|
146
|
+
if (code !== 0 && !fullText) {
|
|
147
|
+
const msg = stderrBuf.trim() || `claude CLI exited with code ${code}`
|
|
148
|
+
onEvent({ type: 'error', message: msg })
|
|
149
|
+
resolve({
|
|
150
|
+
text: '',
|
|
151
|
+
files: [],
|
|
152
|
+
metadata: { sessionId: resolvedSessionId || undefined, durationMs },
|
|
153
|
+
error: { code: `exit_${code}`, message: msg },
|
|
154
|
+
})
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
resolve({
|
|
158
|
+
text: fullText,
|
|
159
|
+
files: [],
|
|
160
|
+
metadata: { sessionId: resolvedSessionId || undefined, durationMs },
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = { streamClaude }
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builtin plugin: Codex CLI (OpenAI)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const fs = require('fs')
|
|
6
|
+
const path = require('path')
|
|
7
|
+
const { config } = require('../../config')
|
|
8
|
+
const { resolveBinary, spawnWithStdin } = require('../lib/spawn-helper')
|
|
9
|
+
|
|
10
|
+
const BINARY = 'codex'
|
|
11
|
+
|
|
12
|
+
async function isAuthenticated() {
|
|
13
|
+
if (process.env.OPENAI_API_KEY) return true
|
|
14
|
+
const codexDir = path.join(config.HOME_DIR, '.codex')
|
|
15
|
+
try {
|
|
16
|
+
if (fs.existsSync(codexDir) && fs.statSync(codexDir).isDirectory()) {
|
|
17
|
+
if (fs.readdirSync(codexDir).length > 0) return true
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
// continue
|
|
21
|
+
}
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function buildArgs(input) {
|
|
26
|
+
const args = []
|
|
27
|
+
if (input.model) args.push('--model', input.model)
|
|
28
|
+
return args
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function invoke(input) {
|
|
32
|
+
const bin = resolveBinary(BINARY, config.HOME_DIR)
|
|
33
|
+
const { stdout, stderr, code, durationMs } = await spawnWithStdin({
|
|
34
|
+
binary: bin,
|
|
35
|
+
args: buildArgs(input),
|
|
36
|
+
prompt: input.prompt,
|
|
37
|
+
homeDir: config.HOME_DIR,
|
|
38
|
+
timeoutMs: input.timeoutMs,
|
|
39
|
+
})
|
|
40
|
+
if (code !== 0) {
|
|
41
|
+
return {
|
|
42
|
+
text: stdout,
|
|
43
|
+
files: [],
|
|
44
|
+
metadata: { durationMs },
|
|
45
|
+
error: { code: `exit_${code}`, message: stderr.slice(0, 2000) || 'codex exited non-zero' },
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
text: stdout.trim(),
|
|
50
|
+
files: [],
|
|
51
|
+
metadata: { durationMs },
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildShellInvocation({ promptFile, model }) {
|
|
56
|
+
const bin = resolveBinary(BINARY, config.HOME_DIR)
|
|
57
|
+
const modelArg = model ? ` --model ${model}` : ''
|
|
58
|
+
return `${bin}${modelArg} < ${promptFile}`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getSkillsDir() {
|
|
62
|
+
return path.join(config.HOME_DIR, '.codex', 'skills')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatSkillInvocation(skillName, args = []) {
|
|
66
|
+
const argStr = args.length ? ' ' + args.join(' ') : ''
|
|
67
|
+
return `/${skillName}${argStr}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Codex stores MCP servers in ~/.codex/config.toml with the format:
|
|
72
|
+
* [mcp_servers.<name>]
|
|
73
|
+
* command = "..."
|
|
74
|
+
* args = ["..."]
|
|
75
|
+
*
|
|
76
|
+
* To avoid pulling in a TOML library we do targeted block replacement:
|
|
77
|
+
* locate `[mcp_servers.<name>]` (and its body up to the next `[` table or EOF)
|
|
78
|
+
* and replace it. If absent, append.
|
|
79
|
+
*/
|
|
80
|
+
function configTomlPath() {
|
|
81
|
+
return path.join(config.HOME_DIR, '.codex', 'config.toml')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function renderTomlBlock(name, spec) {
|
|
85
|
+
const lines = [`[mcp_servers.${name}]`]
|
|
86
|
+
lines.push(`command = ${JSON.stringify(spec.command)}`)
|
|
87
|
+
if (Array.isArray(spec.args)) {
|
|
88
|
+
lines.push(`args = [${spec.args.map(a => JSON.stringify(a)).join(', ')}]`)
|
|
89
|
+
}
|
|
90
|
+
if (spec.env && typeof spec.env === 'object') {
|
|
91
|
+
for (const [k, v] of Object.entries(spec.env)) {
|
|
92
|
+
lines.push(`env.${k} = ${JSON.stringify(v)}`)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return lines.join('\n')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function addMcpServer(name, spec) {
|
|
99
|
+
const tomlPath = configTomlPath()
|
|
100
|
+
fs.mkdirSync(path.dirname(tomlPath), { recursive: true })
|
|
101
|
+
|
|
102
|
+
let content = ''
|
|
103
|
+
try { content = fs.readFileSync(tomlPath, 'utf-8') } catch { /* new file */ }
|
|
104
|
+
|
|
105
|
+
const block = renderTomlBlock(name, spec)
|
|
106
|
+
const headerRe = new RegExp(`^\\[mcp_servers\\.${name.replace(/[-]/g, '\\-')}\\]\\s*$`, 'm')
|
|
107
|
+
const match = content.match(headerRe)
|
|
108
|
+
let next
|
|
109
|
+
if (match) {
|
|
110
|
+
// Replace from the matched header through to the next [ at column 0 (or EOF)
|
|
111
|
+
const startIdx = match.index
|
|
112
|
+
const after = content.slice(startIdx + match[0].length)
|
|
113
|
+
const nextHeader = after.search(/\n\[[^\n]*\]\s*$/m)
|
|
114
|
+
const endIdx = nextHeader === -1 ? content.length : startIdx + match[0].length + nextHeader
|
|
115
|
+
next = content.slice(0, startIdx) + block + content.slice(endIdx)
|
|
116
|
+
} else {
|
|
117
|
+
const sep = content.length === 0 || content.endsWith('\n\n') ? '' : (content.endsWith('\n') ? '\n' : '\n\n')
|
|
118
|
+
next = content + sep + block + '\n'
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const tmp = `${tomlPath}.tmp`
|
|
122
|
+
fs.writeFileSync(tmp, next, 'utf-8')
|
|
123
|
+
fs.renameSync(tmp, tomlPath)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function removeMcpServer(name) {
|
|
127
|
+
const tomlPath = configTomlPath()
|
|
128
|
+
let content
|
|
129
|
+
try { content = fs.readFileSync(tomlPath, 'utf-8') } catch { return }
|
|
130
|
+
const escaped = name.replace(/[-]/g, '\\-')
|
|
131
|
+
// Match the header and everything until the next [ table at column 0 or EOF
|
|
132
|
+
const headerRe = new RegExp(`(?:^|\\n)\\[mcp_servers\\.${escaped}\\][^\\n]*(?:\\n(?!\\[)[^\\n]*)*`, 'm')
|
|
133
|
+
const next = content.replace(headerRe, '').replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '')
|
|
134
|
+
if (next !== content) {
|
|
135
|
+
const tmp = `${tomlPath}.tmp`
|
|
136
|
+
fs.writeFileSync(tmp, next, 'utf-8')
|
|
137
|
+
fs.renameSync(tmp, tomlPath)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** @type {import('../types').LlmPlugin} */
|
|
142
|
+
const plugin = {
|
|
143
|
+
name: 'codex',
|
|
144
|
+
displayName: 'Codex',
|
|
145
|
+
capabilities: {
|
|
146
|
+
toolUse: true,
|
|
147
|
+
streaming: false,
|
|
148
|
+
vision: false,
|
|
149
|
+
imageGeneration: false,
|
|
150
|
+
sessionResume: false,
|
|
151
|
+
},
|
|
152
|
+
isAuthenticated,
|
|
153
|
+
invoke,
|
|
154
|
+
buildShellInvocation,
|
|
155
|
+
getSkillsDir,
|
|
156
|
+
formatSkillInvocation,
|
|
157
|
+
addMcpServer,
|
|
158
|
+
removeMcpServer,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = { plugin }
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builtin plugin: Gemini CLI
|
|
3
|
+
*
|
|
4
|
+
* Note: Gemini CLI flag set is fluid; this plugin targets the common
|
|
5
|
+
* invocation pattern (prompt via stdin, `-p -` to signal stdin input on some
|
|
6
|
+
* builds, or plain stdin on others). The prompt is always passed on stdin to
|
|
7
|
+
* avoid shell quoting issues.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs')
|
|
11
|
+
const path = require('path')
|
|
12
|
+
const { config } = require('../../config')
|
|
13
|
+
const { IS_WINDOWS } = require('../../lib/platform')
|
|
14
|
+
const { resolveBinary, spawnWithStdin } = require('../lib/spawn-helper')
|
|
15
|
+
|
|
16
|
+
const BINARY = 'gemini'
|
|
17
|
+
|
|
18
|
+
async function isAuthenticated() {
|
|
19
|
+
if (process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY) return true
|
|
20
|
+
|
|
21
|
+
// Official @google/gemini-cli stores OAuth credentials at ~/.gemini/oauth_creds.json
|
|
22
|
+
// after `gemini` login. Fall back to ADC locations for gcloud-based setups.
|
|
23
|
+
const candidates = [
|
|
24
|
+
path.join(config.HOME_DIR, '.gemini', 'oauth_creds.json'),
|
|
25
|
+
path.join(config.HOME_DIR, '.config', 'gcloud', 'application_default_credentials.json'),
|
|
26
|
+
...(IS_WINDOWS ? [
|
|
27
|
+
path.join(config.HOME_DIR, 'AppData', 'Roaming', 'gcloud', 'application_default_credentials.json'),
|
|
28
|
+
] : []),
|
|
29
|
+
]
|
|
30
|
+
for (const p of candidates) {
|
|
31
|
+
try {
|
|
32
|
+
if (!fs.existsSync(p)) continue
|
|
33
|
+
if (fs.readFileSync(p, 'utf-8').trim().length > 0) return true
|
|
34
|
+
} catch {
|
|
35
|
+
// continue
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function buildArgs(input) {
|
|
42
|
+
const args = []
|
|
43
|
+
if (input.model) args.push('--model', input.model)
|
|
44
|
+
return args
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function invoke(input) {
|
|
48
|
+
const bin = resolveBinary(BINARY, config.HOME_DIR)
|
|
49
|
+
const { stdout, stderr, code, durationMs } = await spawnWithStdin({
|
|
50
|
+
binary: bin,
|
|
51
|
+
args: buildArgs(input),
|
|
52
|
+
prompt: input.prompt,
|
|
53
|
+
homeDir: config.HOME_DIR,
|
|
54
|
+
timeoutMs: input.timeoutMs,
|
|
55
|
+
})
|
|
56
|
+
if (code !== 0) {
|
|
57
|
+
return {
|
|
58
|
+
text: stdout,
|
|
59
|
+
files: [],
|
|
60
|
+
metadata: { durationMs },
|
|
61
|
+
error: { code: `exit_${code}`, message: stderr.slice(0, 2000) || 'gemini exited non-zero' },
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
text: stdout.trim(),
|
|
66
|
+
files: [],
|
|
67
|
+
metadata: { durationMs },
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function buildShellInvocation({ promptFile, model }) {
|
|
72
|
+
const bin = resolveBinary(BINARY, config.HOME_DIR)
|
|
73
|
+
const modelArg = model ? ` --model ${model}` : ''
|
|
74
|
+
return `${bin}${modelArg} < ${promptFile}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getSkillsDir() {
|
|
78
|
+
return path.join(config.HOME_DIR, '.gemini', 'skills')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function formatSkillInvocation(skillName, args = []) {
|
|
82
|
+
const argStr = args.length ? ` with arguments: ${args.join(' ')}` : ''
|
|
83
|
+
return `Please use the "${skillName}" skill${argStr}.`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** @type {import('../types').LlmPlugin} */
|
|
87
|
+
const plugin = {
|
|
88
|
+
name: 'gemini',
|
|
89
|
+
displayName: 'Gemini CLI',
|
|
90
|
+
capabilities: {
|
|
91
|
+
toolUse: true,
|
|
92
|
+
streaming: false,
|
|
93
|
+
vision: true,
|
|
94
|
+
imageGeneration: true,
|
|
95
|
+
sessionResume: false,
|
|
96
|
+
},
|
|
97
|
+
isAuthenticated,
|
|
98
|
+
invoke,
|
|
99
|
+
buildShellInvocation,
|
|
100
|
+
getSkillsDir,
|
|
101
|
+
formatSkillInvocation,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { plugin }
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime helpers for resolving the currently active plugin.
|
|
3
|
+
*
|
|
4
|
+
* These helpers centralize the "plugin enabled vs legacy LLM_COMMAND" branch
|
|
5
|
+
* so callers (chat route, workflow-runner, routine-runner) share the same
|
|
6
|
+
* resolution rule.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { readConfig, getPlugin } = require('../registry')
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolve the Primary plugin if the plugin system is enabled, otherwise null.
|
|
13
|
+
* Caller can branch to LLM_COMMAND-legacy path when this returns null.
|
|
14
|
+
*
|
|
15
|
+
* @returns {import('../types').LlmPlugin | null}
|
|
16
|
+
*/
|
|
17
|
+
function getActivePrimary() {
|
|
18
|
+
const cfg = readConfig()
|
|
19
|
+
if (!cfg.primary) return null
|
|
20
|
+
return getPlugin(cfg.primary)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = { getActivePrimary }
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server registration helpers.
|
|
3
|
+
*
|
|
4
|
+
* Each LLM CLI stores MCP server configuration in a different file with a
|
|
5
|
+
* different format. This module hides that variation: callers ask a plugin to
|
|
6
|
+
* register a server, and the plugin (or a fallback here) writes to the
|
|
7
|
+
* appropriate location.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs')
|
|
11
|
+
const path = require('path')
|
|
12
|
+
const { config } = require('../../config')
|
|
13
|
+
|
|
14
|
+
const DISPATCH_SERVER_NAME = 'minion-llm-dispatch'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build the spec for the dispatch MCP server used by Primary to reach children.
|
|
18
|
+
* Plugins register this via {@link registerDispatchServer}.
|
|
19
|
+
*/
|
|
20
|
+
function buildDispatchServerSpec() {
|
|
21
|
+
const serverScript = path.join(__dirname, '..', '..', 'llm-dispatch', 'mcp-server.js')
|
|
22
|
+
return {
|
|
23
|
+
command: 'node',
|
|
24
|
+
args: [serverScript],
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* JSON-config MCP registration (used by Claude Code, Gemini CLI).
|
|
30
|
+
* Reads the target file, merges in the server entry under `mcpServers`,
|
|
31
|
+
* and writes back atomically.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} configPath
|
|
34
|
+
* @param {string} serverName
|
|
35
|
+
* @param {{ command: string, args: string[] }} spec
|
|
36
|
+
*/
|
|
37
|
+
function writeJsonMcpEntry(configPath, serverName, spec) {
|
|
38
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true })
|
|
39
|
+
let current = {}
|
|
40
|
+
try {
|
|
41
|
+
const raw = fs.readFileSync(configPath, 'utf-8')
|
|
42
|
+
current = JSON.parse(raw)
|
|
43
|
+
} catch {
|
|
44
|
+
// New file or unparseable — start fresh
|
|
45
|
+
}
|
|
46
|
+
if (!current.mcpServers || typeof current.mcpServers !== 'object') {
|
|
47
|
+
current.mcpServers = {}
|
|
48
|
+
}
|
|
49
|
+
current.mcpServers[serverName] = spec
|
|
50
|
+
const tmp = `${configPath}.tmp`
|
|
51
|
+
fs.writeFileSync(tmp, JSON.stringify(current, null, 2), 'utf-8')
|
|
52
|
+
fs.renameSync(tmp, configPath)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Remove a server entry from a JSON MCP config.
|
|
57
|
+
*/
|
|
58
|
+
function removeJsonMcpEntry(configPath, serverName) {
|
|
59
|
+
try {
|
|
60
|
+
const raw = fs.readFileSync(configPath, 'utf-8')
|
|
61
|
+
const current = JSON.parse(raw)
|
|
62
|
+
if (current?.mcpServers && current.mcpServers[serverName]) {
|
|
63
|
+
delete current.mcpServers[serverName]
|
|
64
|
+
const tmp = `${configPath}.tmp`
|
|
65
|
+
fs.writeFileSync(tmp, JSON.stringify(current, null, 2), 'utf-8')
|
|
66
|
+
fs.renameSync(tmp, configPath)
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// File missing or unparseable — nothing to remove
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Register the dispatch MCP server with the given Primary plugin.
|
|
75
|
+
* No-op if the plugin does not implement addMcpServer() or when we don't
|
|
76
|
+
* know its MCP config location.
|
|
77
|
+
*
|
|
78
|
+
* @param {import('../types').LlmPlugin} plugin
|
|
79
|
+
*/
|
|
80
|
+
function registerDispatchServer(plugin) {
|
|
81
|
+
const spec = buildDispatchServerSpec()
|
|
82
|
+
if (typeof plugin.addMcpServer === 'function') {
|
|
83
|
+
plugin.addMcpServer(DISPATCH_SERVER_NAME, spec)
|
|
84
|
+
return { registered: true, method: 'plugin' }
|
|
85
|
+
}
|
|
86
|
+
// Fallback: well-known locations by plugin name
|
|
87
|
+
const fallbackPath = defaultMcpConfigPath(plugin.name)
|
|
88
|
+
if (fallbackPath) {
|
|
89
|
+
writeJsonMcpEntry(fallbackPath, DISPATCH_SERVER_NAME, spec)
|
|
90
|
+
return { registered: true, method: 'fallback', path: fallbackPath }
|
|
91
|
+
}
|
|
92
|
+
return { registered: false }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Unregister the dispatch MCP server from all known plugin MCP configs.
|
|
97
|
+
* Used when Primary is cleared.
|
|
98
|
+
*/
|
|
99
|
+
function unregisterDispatchServerFromAll(plugins) {
|
|
100
|
+
for (const p of plugins) {
|
|
101
|
+
if (typeof p.removeMcpServer === 'function') {
|
|
102
|
+
try { p.removeMcpServer(DISPATCH_SERVER_NAME) } catch { /* ignore */ }
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
const fallbackPath = defaultMcpConfigPath(p.name)
|
|
106
|
+
if (fallbackPath) removeJsonMcpEntry(fallbackPath, DISPATCH_SERVER_NAME)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Best-known MCP config path for a given plugin name. Returns null if unknown. */
|
|
111
|
+
function defaultMcpConfigPath(pluginName) {
|
|
112
|
+
switch (pluginName) {
|
|
113
|
+
case 'claude':
|
|
114
|
+
// Claude Code reads ~/.mcp.json (global) and project-local .mcp.json
|
|
115
|
+
return path.join(config.HOME_DIR, '.mcp.json')
|
|
116
|
+
case 'gemini':
|
|
117
|
+
// Gemini CLI settings include mcpServers field
|
|
118
|
+
return path.join(config.HOME_DIR, '.gemini', 'settings.json')
|
|
119
|
+
default:
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = {
|
|
125
|
+
DISPATCH_SERVER_NAME,
|
|
126
|
+
buildDispatchServerSpec,
|
|
127
|
+
writeJsonMcpEntry,
|
|
128
|
+
removeJsonMcpEntry,
|
|
129
|
+
registerDispatchServer,
|
|
130
|
+
unregisterDispatchServerFromAll,
|
|
131
|
+
defaultMcpConfigPath,
|
|
132
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve skill directory paths across enabled LLM plugins.
|
|
3
|
+
*
|
|
4
|
+
* Each LLM CLI loads skills from its own directory:
|
|
5
|
+
* Claude → ~/.claude/skills/
|
|
6
|
+
* Gemini → ~/.gemini/skills/
|
|
7
|
+
* Codex → ~/.codex/skills/
|
|
8
|
+
*
|
|
9
|
+
* To make skill deployment LLM-agnostic, we write each skill to all enabled
|
|
10
|
+
* plugins' skill directories. Reads/deletes operate on the union.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const path = require('path')
|
|
14
|
+
const { config } = require('../../config')
|
|
15
|
+
const { readConfig, loadAllPlugins } = require('../registry')
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Return the canonical skill directory paths for all enabled plugins.
|
|
19
|
+
* Falls back to ~/.claude/skills/ when the plugin system is not configured
|
|
20
|
+
* (preserves backward compatibility with the LLM_COMMAND era).
|
|
21
|
+
*
|
|
22
|
+
* @returns {string[]} Absolute directory paths
|
|
23
|
+
*/
|
|
24
|
+
function getActiveSkillDirs() {
|
|
25
|
+
const cfg = readConfig()
|
|
26
|
+
const enabled = new Set(cfg.enabled)
|
|
27
|
+
|
|
28
|
+
if (enabled.size === 0) {
|
|
29
|
+
return [path.join(config.HOME_DIR, '.claude', 'skills')]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const plugins = loadAllPlugins()
|
|
33
|
+
const dirs = []
|
|
34
|
+
for (const name of enabled) {
|
|
35
|
+
const plugin = plugins.get(name)
|
|
36
|
+
if (plugin && typeof plugin.getSkillsDir === 'function') {
|
|
37
|
+
dirs.push(plugin.getSkillsDir())
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Safety: if no enabled plugin reported a dir, default to claude
|
|
41
|
+
if (dirs.length === 0) {
|
|
42
|
+
dirs.push(path.join(config.HOME_DIR, '.claude', 'skills'))
|
|
43
|
+
}
|
|
44
|
+
return dirs
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get the primary plugin's skill directory (used as the canonical
|
|
49
|
+
* read source when listing or pushing skills back to HQ).
|
|
50
|
+
*
|
|
51
|
+
* @returns {string} Absolute directory path
|
|
52
|
+
*/
|
|
53
|
+
function getPrimarySkillDir() {
|
|
54
|
+
const cfg = readConfig()
|
|
55
|
+
if (cfg.primary) {
|
|
56
|
+
const plugins = loadAllPlugins()
|
|
57
|
+
const plugin = plugins.get(cfg.primary)
|
|
58
|
+
if (plugin && typeof plugin.getSkillsDir === 'function') {
|
|
59
|
+
return plugin.getSkillsDir()
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Fallback: first enabled plugin's dir, else claude
|
|
63
|
+
const dirs = getActiveSkillDirs()
|
|
64
|
+
return dirs[0]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { getActiveSkillDirs, getPrimarySkillDir }
|