@geekbeer/minion 3.16.1 → 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 +158 -6
- 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,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builtin plugin: Claude Code CLI
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const fs = require('fs')
|
|
6
|
+
const path = require('path')
|
|
7
|
+
const { execSync } = require('child_process')
|
|
8
|
+
const { config } = require('../../config')
|
|
9
|
+
const { IS_WINDOWS, buildExtendedPath } = require('../../lib/platform')
|
|
10
|
+
const { resolveBinary, spawnWithStdin } = require('../lib/spawn-helper')
|
|
11
|
+
const { streamClaude } = require('./stream')
|
|
12
|
+
|
|
13
|
+
const BINARY = 'claude'
|
|
14
|
+
|
|
15
|
+
async function isAuthenticated() {
|
|
16
|
+
const candidates = [
|
|
17
|
+
path.join(config.HOME_DIR, '.claude', '.credentials.json'),
|
|
18
|
+
path.join(config.HOME_DIR, '.claude', 'credentials.json'),
|
|
19
|
+
]
|
|
20
|
+
for (const p of candidates) {
|
|
21
|
+
try {
|
|
22
|
+
if (fs.existsSync(p)) {
|
|
23
|
+
const parsed = JSON.parse(fs.readFileSync(p, 'utf-8'))
|
|
24
|
+
if (parsed && Object.keys(parsed).length > 0) return true
|
|
25
|
+
}
|
|
26
|
+
} catch {
|
|
27
|
+
// continue
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const bin = resolveBinary(BINARY, config.HOME_DIR)
|
|
32
|
+
execSync(`${bin} auth whoami`, {
|
|
33
|
+
encoding: 'utf-8',
|
|
34
|
+
timeout: 5000,
|
|
35
|
+
stdio: 'pipe',
|
|
36
|
+
env: {
|
|
37
|
+
...process.env,
|
|
38
|
+
HOME: config.HOME_DIR,
|
|
39
|
+
...(IS_WINDOWS && { USERPROFILE: config.HOME_DIR }),
|
|
40
|
+
PATH: buildExtendedPath(config.HOME_DIR),
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
return true
|
|
44
|
+
} catch {
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildArgs(input, { resume } = {}) {
|
|
50
|
+
const args = ['-p']
|
|
51
|
+
if (input.model) args.push('--model', input.model)
|
|
52
|
+
if (resume) args.push('--resume', resume)
|
|
53
|
+
return args
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseFinalSessionId(stdout) {
|
|
57
|
+
// Claude prints session info on --verbose; in -p mode we don't force verbose.
|
|
58
|
+
// Leave sessionId undefined unless we parse it from known markers in future.
|
|
59
|
+
return undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function invoke(input) {
|
|
63
|
+
const bin = resolveBinary(BINARY, config.HOME_DIR)
|
|
64
|
+
const args = buildArgs(input)
|
|
65
|
+
const { stdout, stderr, code, durationMs } = await spawnWithStdin({
|
|
66
|
+
binary: bin,
|
|
67
|
+
args,
|
|
68
|
+
prompt: input.prompt,
|
|
69
|
+
homeDir: config.HOME_DIR,
|
|
70
|
+
timeoutMs: input.timeoutMs,
|
|
71
|
+
})
|
|
72
|
+
if (code !== 0) {
|
|
73
|
+
return {
|
|
74
|
+
text: stdout,
|
|
75
|
+
files: [],
|
|
76
|
+
metadata: { durationMs },
|
|
77
|
+
error: { code: `exit_${code}`, message: stderr.slice(0, 2000) || 'claude exited non-zero' },
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
text: stdout.trim(),
|
|
82
|
+
files: [],
|
|
83
|
+
metadata: { durationMs, sessionId: parseFinalSessionId(stdout) },
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function resume(sessionId, input) {
|
|
88
|
+
const bin = resolveBinary(BINARY, config.HOME_DIR)
|
|
89
|
+
const args = buildArgs(input, { resume: sessionId })
|
|
90
|
+
const { stdout, stderr, code, durationMs } = await spawnWithStdin({
|
|
91
|
+
binary: bin,
|
|
92
|
+
args,
|
|
93
|
+
prompt: input.prompt,
|
|
94
|
+
homeDir: config.HOME_DIR,
|
|
95
|
+
timeoutMs: input.timeoutMs,
|
|
96
|
+
})
|
|
97
|
+
if (code !== 0) {
|
|
98
|
+
return {
|
|
99
|
+
text: stdout,
|
|
100
|
+
files: [],
|
|
101
|
+
metadata: { durationMs, sessionId },
|
|
102
|
+
error: { code: `exit_${code}`, message: stderr.slice(0, 2000) || 'claude exited non-zero' },
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
text: stdout.trim(),
|
|
107
|
+
files: [],
|
|
108
|
+
metadata: { durationMs, sessionId },
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function stream(input, onEvent, ctx) {
|
|
113
|
+
return streamClaude(input, onEvent, ctx)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function buildShellInvocation({ promptFile, model }) {
|
|
117
|
+
const bin = resolveBinary(BINARY, config.HOME_DIR)
|
|
118
|
+
const modelArg = model ? ` --model ${model}` : ''
|
|
119
|
+
return `${bin} -p${modelArg} < ${promptFile}`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getSkillsDir() {
|
|
123
|
+
return path.join(config.HOME_DIR, '.claude', 'skills')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function formatSkillInvocation(skillName, args = []) {
|
|
127
|
+
const argStr = args.length ? ' ' + args.join(' ') : ''
|
|
128
|
+
return `/${skillName}${argStr}`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** @type {import('../types').LlmPlugin} */
|
|
132
|
+
const plugin = {
|
|
133
|
+
name: 'claude',
|
|
134
|
+
displayName: 'Claude Code',
|
|
135
|
+
capabilities: {
|
|
136
|
+
toolUse: true,
|
|
137
|
+
streaming: true,
|
|
138
|
+
vision: true,
|
|
139
|
+
imageGeneration: false,
|
|
140
|
+
sessionResume: true,
|
|
141
|
+
},
|
|
142
|
+
isAuthenticated,
|
|
143
|
+
invoke,
|
|
144
|
+
stream,
|
|
145
|
+
resume,
|
|
146
|
+
buildShellInvocation,
|
|
147
|
+
getSkillsDir,
|
|
148
|
+
formatSkillInvocation,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = { plugin }
|
|
@@ -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 }
|