@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.
@@ -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 }