@geekbeer/minion 2.53.3 → 2.54.1
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/capability-checker.js +44 -74
- package/core/lib/package-scanner.js +370 -0
- package/core/routes/health.js +16 -11
- package/docs/environment-setup.md +8 -5
- package/docs/task-guides.md +6 -2
- package/linux/server.js +4 -4
- package/package.json +1 -1
- package/win/lib/process-manager.js +9 -1
- package/win/server.js +4 -4
|
@@ -1,22 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Capability checker
|
|
3
3
|
*
|
|
4
|
-
* Detects MCP servers
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Detects MCP servers, installed packages, and environment variables.
|
|
5
|
+
* Always returns fresh results — called on-demand, not on every heartbeat.
|
|
6
|
+
*
|
|
7
|
+
* - MCP servers: parsed from ~/.mcp.json
|
|
8
|
+
* - Packages: scanned from package managers (apt, npm, pip, etc.)
|
|
9
|
+
* - Env var keys: collected from minion-local variables/secrets stores
|
|
7
10
|
*/
|
|
8
11
|
|
|
9
12
|
const fs = require('fs')
|
|
10
13
|
const path = require('path')
|
|
11
|
-
const { execSync } = require('child_process')
|
|
12
14
|
const { config } = require('../config')
|
|
13
|
-
const { IS_WINDOWS, buildExtendedPath } = require('./platform')
|
|
14
15
|
const variableStore = require('../stores/variable-store')
|
|
15
|
-
|
|
16
|
-
const CACHE_TTL_MS = 300000 // 5 minutes
|
|
17
|
-
|
|
18
|
-
let cachedResult = null
|
|
19
|
-
let cachedAt = 0
|
|
16
|
+
const { scanAllPackages, checkPackages } = require('./package-scanner')
|
|
20
17
|
|
|
21
18
|
/**
|
|
22
19
|
* Detect MCP servers from ~/.mcp.json.
|
|
@@ -39,46 +36,6 @@ function getMcpServers() {
|
|
|
39
36
|
}
|
|
40
37
|
}
|
|
41
38
|
|
|
42
|
-
/**
|
|
43
|
-
* Check if a CLI tool is available and get its version.
|
|
44
|
-
* @param {string} name - Tool name (e.g., 'git', 'node')
|
|
45
|
-
* @returns {{ name: string, available: boolean, version?: string }}
|
|
46
|
-
*/
|
|
47
|
-
function checkTool(name) {
|
|
48
|
-
const extendedPath = buildExtendedPath(config.HOME_DIR)
|
|
49
|
-
const env = {
|
|
50
|
-
...process.env,
|
|
51
|
-
HOME: config.HOME_DIR,
|
|
52
|
-
...(IS_WINDOWS && { USERPROFILE: config.HOME_DIR }),
|
|
53
|
-
PATH: extendedPath,
|
|
54
|
-
}
|
|
55
|
-
const execOpts = { encoding: 'utf-8', timeout: 5000, stdio: 'pipe', env }
|
|
56
|
-
|
|
57
|
-
try {
|
|
58
|
-
// Check existence
|
|
59
|
-
const whichCmd = IS_WINDOWS ? 'where' : 'which'
|
|
60
|
-
execSync(`${whichCmd} ${name}`, execOpts)
|
|
61
|
-
|
|
62
|
-
// Get version
|
|
63
|
-
let version = null
|
|
64
|
-
try {
|
|
65
|
-
const out = execSync(`${name} --version`, execOpts).trim()
|
|
66
|
-
// Extract version number from output (e.g., "git version 2.43.0" → "2.43.0")
|
|
67
|
-
const match = out.match(/(\d+\.\d+[\.\d]*)/)
|
|
68
|
-
if (match) version = match[1]
|
|
69
|
-
} catch {
|
|
70
|
-
// Tool exists but --version failed, that's ok
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
return { name, available: true, ...(version && { version }) }
|
|
74
|
-
} catch {
|
|
75
|
-
return { name, available: false }
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** CLI tools to detect */
|
|
80
|
-
const TOOL_NAMES = ['git', 'node', 'npx', 'claude', 'docker', 'tmux']
|
|
81
|
-
|
|
82
39
|
/**
|
|
83
40
|
* Get all available environment variable keys (variables + secrets).
|
|
84
41
|
* Returns deduplicated key names without values (secrets stay hidden).
|
|
@@ -91,38 +48,51 @@ function getEnvVarKeys() {
|
|
|
91
48
|
}
|
|
92
49
|
|
|
93
50
|
/**
|
|
94
|
-
* Get all capabilities (MCP servers +
|
|
95
|
-
*
|
|
51
|
+
* Get all capabilities (MCP servers + installed packages + env var keys).
|
|
52
|
+
* Always returns fresh results.
|
|
53
|
+
* @returns {{ mcp_servers: { name: string, configured: boolean }[], packages: Record<string, { available: boolean, items: { name: string, version: string }[] }>, env_var_keys: string[] }}
|
|
96
54
|
*/
|
|
97
55
|
function getCapabilities() {
|
|
98
|
-
const now = Date.now()
|
|
99
|
-
if (cachedResult && (now - cachedAt) < CACHE_TTL_MS) {
|
|
100
|
-
return cachedResult
|
|
101
|
-
}
|
|
102
|
-
|
|
103
56
|
const mcp_servers = getMcpServers()
|
|
104
|
-
const
|
|
57
|
+
const packages = scanAllPackages()
|
|
105
58
|
const env_var_keys = getEnvVarKeys()
|
|
106
59
|
|
|
107
|
-
|
|
108
|
-
cachedAt = now
|
|
109
|
-
return cachedResult
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Clear the cached capability status */
|
|
113
|
-
function clearCapabilityCache() {
|
|
114
|
-
cachedResult = null
|
|
115
|
-
cachedAt = 0
|
|
60
|
+
return { mcp_servers, packages, env_var_keys }
|
|
116
61
|
}
|
|
117
62
|
|
|
118
63
|
/**
|
|
119
|
-
* Check
|
|
120
|
-
*
|
|
121
|
-
* @
|
|
122
|
-
* @returns {{ name: string, available: boolean, version?: string }[]}
|
|
64
|
+
* Check requirements against current capabilities.
|
|
65
|
+
* @param {{ mcp_servers?: string[], packages?: Record<string, string[]>, env_vars?: string[] }} requires
|
|
66
|
+
* @returns {{ satisfied: boolean, missing: { mcp_servers: string[], packages: Record<string, string[]>, env_vars: string[] } }}
|
|
123
67
|
*/
|
|
124
|
-
function
|
|
125
|
-
|
|
68
|
+
function checkRequirements(requires) {
|
|
69
|
+
const missing = { mcp_servers: [], packages: {}, env_vars: [] }
|
|
70
|
+
let satisfied = true
|
|
71
|
+
|
|
72
|
+
// Check MCP servers
|
|
73
|
+
if (requires.mcp_servers && requires.mcp_servers.length > 0) {
|
|
74
|
+
const configuredServers = getMcpServers()
|
|
75
|
+
const configuredNames = new Set(configuredServers.map(s => s.name))
|
|
76
|
+
missing.mcp_servers = requires.mcp_servers.filter(s => !configuredNames.has(s))
|
|
77
|
+
if (missing.mcp_servers.length > 0) satisfied = false
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Check packages
|
|
81
|
+
if (requires.packages && Object.keys(requires.packages).length > 0) {
|
|
82
|
+
const installed = scanAllPackages()
|
|
83
|
+
const result = checkPackages(requires.packages, installed)
|
|
84
|
+
missing.packages = result.missing
|
|
85
|
+
if (!result.satisfied) satisfied = false
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Check env vars
|
|
89
|
+
if (requires.env_vars && requires.env_vars.length > 0) {
|
|
90
|
+
const envVarKeys = new Set(getEnvVarKeys())
|
|
91
|
+
missing.env_vars = requires.env_vars.filter(v => !envVarKeys.has(v))
|
|
92
|
+
if (missing.env_vars.length > 0) satisfied = false
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { satisfied, missing }
|
|
126
96
|
}
|
|
127
97
|
|
|
128
|
-
module.exports = { getCapabilities,
|
|
98
|
+
module.exports = { getCapabilities, checkRequirements }
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package scanner
|
|
3
|
+
*
|
|
4
|
+
* Scans installed packages from various package managers.
|
|
5
|
+
* Each scanner checks if the package manager is available, then lists installed packages.
|
|
6
|
+
* Returns results grouped by package manager with availability status.
|
|
7
|
+
*
|
|
8
|
+
* Supported package managers:
|
|
9
|
+
* Linux: apt, npm, pnpm, bun, pip, uv, pipx, cargo
|
|
10
|
+
* macOS: brew, npm, pnpm, bun, pip, uv, pipx, cargo
|
|
11
|
+
* Windows: winget, scoop, npm, pnpm, bun, pip, uv, pipx, cargo
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { execSync } = require('child_process')
|
|
15
|
+
const { IS_WINDOWS, buildExtendedPath } = require('./platform')
|
|
16
|
+
const { config } = require('../config')
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build exec options with extended PATH.
|
|
20
|
+
* @returns {object}
|
|
21
|
+
*/
|
|
22
|
+
function execOpts() {
|
|
23
|
+
const extendedPath = buildExtendedPath(config.HOME_DIR)
|
|
24
|
+
return {
|
|
25
|
+
encoding: 'utf-8',
|
|
26
|
+
timeout: 30000,
|
|
27
|
+
stdio: 'pipe',
|
|
28
|
+
env: {
|
|
29
|
+
...process.env,
|
|
30
|
+
HOME: config.HOME_DIR,
|
|
31
|
+
...(IS_WINDOWS && { USERPROFILE: config.HOME_DIR }),
|
|
32
|
+
PATH: extendedPath,
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check if a command exists.
|
|
39
|
+
* @param {string} cmd
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
function commandExists(cmd) {
|
|
43
|
+
try {
|
|
44
|
+
const whichCmd = IS_WINDOWS ? 'where' : 'which'
|
|
45
|
+
execSync(`${whichCmd} ${cmd}`, execOpts())
|
|
46
|
+
return true
|
|
47
|
+
} catch {
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Run a command and return stdout, or null on failure.
|
|
54
|
+
* @param {string} cmd
|
|
55
|
+
* @returns {string | null}
|
|
56
|
+
*/
|
|
57
|
+
function run(cmd) {
|
|
58
|
+
try {
|
|
59
|
+
return execSync(cmd, execOpts()).trim()
|
|
60
|
+
} catch {
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @typedef {{ name: string, version: string }} PackageItem
|
|
67
|
+
* @typedef {{ available: boolean, items: PackageItem[] }} ScanResult
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
// --- Individual scanners ---
|
|
71
|
+
|
|
72
|
+
/** @returns {ScanResult} */
|
|
73
|
+
function scanApt() {
|
|
74
|
+
if (IS_WINDOWS || process.platform === 'darwin') return { available: false, items: [] }
|
|
75
|
+
if (!commandExists('dpkg-query')) return { available: false, items: [] }
|
|
76
|
+
|
|
77
|
+
const out = run("dpkg-query -W -f '${Package}\\t${Version}\\n'")
|
|
78
|
+
if (!out) return { available: true, items: [] }
|
|
79
|
+
|
|
80
|
+
const items = out.split('\n').filter(Boolean).map(line => {
|
|
81
|
+
const [name, version] = line.split('\t')
|
|
82
|
+
return { name: name || '', version: version || '' }
|
|
83
|
+
}).filter(i => i.name)
|
|
84
|
+
|
|
85
|
+
return { available: true, items }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @returns {ScanResult} */
|
|
89
|
+
function scanBrew() {
|
|
90
|
+
if (process.platform !== 'darwin') return { available: false, items: [] }
|
|
91
|
+
if (!commandExists('brew')) return { available: false, items: [] }
|
|
92
|
+
|
|
93
|
+
const out = run('brew list --versions')
|
|
94
|
+
if (!out) return { available: true, items: [] }
|
|
95
|
+
|
|
96
|
+
const items = out.split('\n').filter(Boolean).map(line => {
|
|
97
|
+
const parts = line.split(/\s+/)
|
|
98
|
+
return { name: parts[0] || '', version: parts.slice(1).join(' ') || '' }
|
|
99
|
+
}).filter(i => i.name)
|
|
100
|
+
|
|
101
|
+
return { available: true, items }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** @returns {ScanResult} */
|
|
105
|
+
function scanWinget() {
|
|
106
|
+
if (!IS_WINDOWS) return { available: false, items: [] }
|
|
107
|
+
if (!commandExists('winget')) return { available: false, items: [] }
|
|
108
|
+
|
|
109
|
+
const out = run('winget list --disable-interactivity --accept-source-agreements 2>nul')
|
|
110
|
+
if (!out) return { available: true, items: [] }
|
|
111
|
+
|
|
112
|
+
// winget list output is table-formatted; parse lines after the header separator
|
|
113
|
+
const lines = out.split('\n')
|
|
114
|
+
const sepIndex = lines.findIndex(l => /^-{2,}/.test(l.trim()))
|
|
115
|
+
if (sepIndex < 0) return { available: true, items: [] }
|
|
116
|
+
|
|
117
|
+
const items = []
|
|
118
|
+
for (let i = sepIndex + 1; i < lines.length; i++) {
|
|
119
|
+
const line = lines[i].trim()
|
|
120
|
+
if (!line) continue
|
|
121
|
+
// Columns are variable-width; extract name and version heuristically
|
|
122
|
+
const parts = line.split(/\s{2,}/)
|
|
123
|
+
if (parts.length >= 2) {
|
|
124
|
+
items.push({ name: parts[0], version: parts[1] || '' })
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { available: true, items }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** @returns {ScanResult} */
|
|
132
|
+
function scanScoop() {
|
|
133
|
+
if (!IS_WINDOWS) return { available: false, items: [] }
|
|
134
|
+
if (!commandExists('scoop')) return { available: false, items: [] }
|
|
135
|
+
|
|
136
|
+
const out = run('scoop list 2>nul')
|
|
137
|
+
if (!out) return { available: true, items: [] }
|
|
138
|
+
|
|
139
|
+
// scoop list output: " name version source ..."
|
|
140
|
+
const lines = out.split('\n')
|
|
141
|
+
const sepIndex = lines.findIndex(l => /^-{2,}/.test(l.trim()))
|
|
142
|
+
|
|
143
|
+
const items = []
|
|
144
|
+
const startIdx = sepIndex >= 0 ? sepIndex + 1 : 0
|
|
145
|
+
for (let i = startIdx; i < lines.length; i++) {
|
|
146
|
+
const line = lines[i].trim()
|
|
147
|
+
if (!line || line.startsWith('-')) continue
|
|
148
|
+
const parts = line.split(/\s+/)
|
|
149
|
+
if (parts.length >= 2) {
|
|
150
|
+
items.push({ name: parts[0], version: parts[1] || '' })
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { available: true, items }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** @returns {ScanResult} */
|
|
158
|
+
function scanNpm() {
|
|
159
|
+
if (!commandExists('npm')) return { available: false, items: [] }
|
|
160
|
+
|
|
161
|
+
const out = run('npm list -g --depth=0 --json')
|
|
162
|
+
if (!out) return { available: true, items: [] }
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const data = JSON.parse(out)
|
|
166
|
+
const deps = data.dependencies || {}
|
|
167
|
+
const items = Object.entries(deps).map(([name, info]) => ({
|
|
168
|
+
name,
|
|
169
|
+
version: (typeof info === 'object' && info !== null ? info.version : '') || '',
|
|
170
|
+
}))
|
|
171
|
+
return { available: true, items }
|
|
172
|
+
} catch {
|
|
173
|
+
return { available: true, items: [] }
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** @returns {ScanResult} */
|
|
178
|
+
function scanPnpm() {
|
|
179
|
+
if (!commandExists('pnpm')) return { available: false, items: [] }
|
|
180
|
+
|
|
181
|
+
const out = run('pnpm list -g --json')
|
|
182
|
+
if (!out) return { available: true, items: [] }
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const data = JSON.parse(out)
|
|
186
|
+
// pnpm list -g --json returns an array
|
|
187
|
+
const list = Array.isArray(data) ? data : [data]
|
|
188
|
+
const items = []
|
|
189
|
+
for (const entry of list) {
|
|
190
|
+
const deps = { ...(entry.dependencies || {}), ...(entry.devDependencies || {}) }
|
|
191
|
+
for (const [name, info] of Object.entries(deps)) {
|
|
192
|
+
items.push({
|
|
193
|
+
name,
|
|
194
|
+
version: (typeof info === 'object' && info !== null ? info.version : '') || '',
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return { available: true, items }
|
|
199
|
+
} catch {
|
|
200
|
+
return { available: true, items: [] }
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** @returns {ScanResult} */
|
|
205
|
+
function scanBun() {
|
|
206
|
+
if (!commandExists('bun')) return { available: false, items: [] }
|
|
207
|
+
|
|
208
|
+
// bun pm ls -g outputs text format
|
|
209
|
+
const out = run('bun pm ls -g 2>/dev/null || bun pm ls -g 2>nul')
|
|
210
|
+
if (!out) return { available: true, items: [] }
|
|
211
|
+
|
|
212
|
+
const items = []
|
|
213
|
+
for (const line of out.split('\n')) {
|
|
214
|
+
// Lines like: "├── package-name@1.0.0"
|
|
215
|
+
const match = line.match(/[├└─│\s]*(.+?)@(\S+)/)
|
|
216
|
+
if (match) {
|
|
217
|
+
items.push({ name: match[1], version: match[2] })
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { available: true, items }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** @returns {ScanResult} */
|
|
225
|
+
function scanPip() {
|
|
226
|
+
// Try pip3 first, then pip
|
|
227
|
+
const pipCmd = commandExists('pip3') ? 'pip3' : commandExists('pip') ? 'pip' : null
|
|
228
|
+
if (!pipCmd) return { available: false, items: [] }
|
|
229
|
+
|
|
230
|
+
const out = run(`${pipCmd} list --format=json`)
|
|
231
|
+
if (!out) return { available: true, items: [] }
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const data = JSON.parse(out)
|
|
235
|
+
const items = data.map(pkg => ({
|
|
236
|
+
name: pkg.name || '',
|
|
237
|
+
version: pkg.version || '',
|
|
238
|
+
})).filter(i => i.name)
|
|
239
|
+
return { available: true, items }
|
|
240
|
+
} catch {
|
|
241
|
+
return { available: true, items: [] }
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** @returns {ScanResult} */
|
|
246
|
+
function scanUv() {
|
|
247
|
+
if (!commandExists('uv')) return { available: false, items: [] }
|
|
248
|
+
|
|
249
|
+
const out = run('uv pip list --format=json 2>/dev/null || uv pip list --format=json 2>nul')
|
|
250
|
+
if (!out) return { available: true, items: [] }
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
const data = JSON.parse(out)
|
|
254
|
+
const items = data.map(pkg => ({
|
|
255
|
+
name: pkg.name || '',
|
|
256
|
+
version: pkg.version || '',
|
|
257
|
+
})).filter(i => i.name)
|
|
258
|
+
return { available: true, items }
|
|
259
|
+
} catch {
|
|
260
|
+
return { available: true, items: [] }
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** @returns {ScanResult} */
|
|
265
|
+
function scanPipx() {
|
|
266
|
+
if (!commandExists('pipx')) return { available: false, items: [] }
|
|
267
|
+
|
|
268
|
+
const out = run('pipx list --json')
|
|
269
|
+
if (!out) return { available: true, items: [] }
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
const data = JSON.parse(out)
|
|
273
|
+
const venvs = data.venvs || {}
|
|
274
|
+
const items = Object.entries(venvs).map(([name, info]) => ({
|
|
275
|
+
name,
|
|
276
|
+
version: (info && info.metadata && info.metadata.main_package && info.metadata.main_package.package_version) || '',
|
|
277
|
+
}))
|
|
278
|
+
return { available: true, items }
|
|
279
|
+
} catch {
|
|
280
|
+
return { available: true, items: [] }
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** @returns {ScanResult} */
|
|
285
|
+
function scanCargo() {
|
|
286
|
+
if (!commandExists('cargo')) return { available: false, items: [] }
|
|
287
|
+
|
|
288
|
+
const out = run('cargo install --list')
|
|
289
|
+
if (!out) return { available: true, items: [] }
|
|
290
|
+
|
|
291
|
+
// Lines like: "ripgrep v14.1.0:" (top-level, no indent = package name)
|
|
292
|
+
const items = []
|
|
293
|
+
for (const line of out.split('\n')) {
|
|
294
|
+
if (line.startsWith(' ') || line.startsWith('\t') || !line.trim()) continue
|
|
295
|
+
const match = line.match(/^(\S+)\s+v?(\S+):?/)
|
|
296
|
+
if (match) {
|
|
297
|
+
items.push({ name: match[1], version: match[2].replace(/:$/, '') })
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return { available: true, items }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// --- Scanner registry ---
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* All supported package manager scanners.
|
|
308
|
+
* Each scanner handles its own platform check internally.
|
|
309
|
+
* @type {Record<string, () => ScanResult>}
|
|
310
|
+
*/
|
|
311
|
+
const SCANNERS = {
|
|
312
|
+
apt: scanApt,
|
|
313
|
+
brew: scanBrew,
|
|
314
|
+
winget: scanWinget,
|
|
315
|
+
scoop: scanScoop,
|
|
316
|
+
npm: scanNpm,
|
|
317
|
+
pnpm: scanPnpm,
|
|
318
|
+
bun: scanBun,
|
|
319
|
+
pip: scanPip,
|
|
320
|
+
uv: scanUv,
|
|
321
|
+
pipx: scanPipx,
|
|
322
|
+
cargo: scanCargo,
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Scan all package managers and return installed packages.
|
|
327
|
+
* Skips package managers not available on the current platform.
|
|
328
|
+
* @returns {Record<string, ScanResult>}
|
|
329
|
+
*/
|
|
330
|
+
function scanAllPackages() {
|
|
331
|
+
const results = {}
|
|
332
|
+
for (const [name, scanner] of Object.entries(SCANNERS)) {
|
|
333
|
+
results[name] = scanner()
|
|
334
|
+
}
|
|
335
|
+
return results
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Check if specific packages are installed for given package managers.
|
|
340
|
+
* @param {Record<string, string[]>} required - e.g. { apt: ['ffmpeg'], npm: ['@anthropic-ai/claude-code'] }
|
|
341
|
+
* @param {Record<string, ScanResult>} installed - result from scanAllPackages()
|
|
342
|
+
* @returns {{ satisfied: boolean, missing: Record<string, string[]> }}
|
|
343
|
+
*/
|
|
344
|
+
function checkPackages(required, installed) {
|
|
345
|
+
const missing = {}
|
|
346
|
+
let satisfied = true
|
|
347
|
+
|
|
348
|
+
for (const [manager, packages] of Object.entries(required)) {
|
|
349
|
+
const scan = installed[manager]
|
|
350
|
+
if (!scan || !scan.available) {
|
|
351
|
+
// Package manager not available — all packages are missing
|
|
352
|
+
if (packages.length > 0) {
|
|
353
|
+
missing[manager] = [...packages]
|
|
354
|
+
satisfied = false
|
|
355
|
+
}
|
|
356
|
+
continue
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const installedNames = new Set(scan.items.map(i => i.name))
|
|
360
|
+
const missingPkgs = packages.filter(p => !installedNames.has(p))
|
|
361
|
+
if (missingPkgs.length > 0) {
|
|
362
|
+
missing[manager] = missingPkgs
|
|
363
|
+
satisfied = false
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return { satisfied, missing }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
module.exports = { scanAllPackages, checkPackages, SCANNERS }
|
package/core/routes/health.js
CHANGED
|
@@ -5,14 +5,15 @@
|
|
|
5
5
|
* - GET /api/health - Health check
|
|
6
6
|
* - GET /api/status - Get current status
|
|
7
7
|
* - POST /api/status - Update status
|
|
8
|
-
* -
|
|
8
|
+
* - GET /api/capabilities - Get current capabilities (MCP servers, packages, env vars)
|
|
9
|
+
* - POST /api/capabilities/check - Check requirements (MCP servers, packages, env vars)
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
const { version } = require('../../package.json')
|
|
12
13
|
const { config, isHqConfigured } = require('../config')
|
|
13
14
|
const { sendHeartbeat } = require('../api')
|
|
14
15
|
const { getLlmServices, isLlmCommandConfigured } = require('../lib/llm-checker')
|
|
15
|
-
const { getCapabilities,
|
|
16
|
+
const { getCapabilities, checkRequirements } = require('../lib/capability-checker')
|
|
16
17
|
|
|
17
18
|
function maskToken(token) {
|
|
18
19
|
if (!token || token.length < 8) return token ? '***' : ''
|
|
@@ -64,7 +65,6 @@ async function healthRoutes(fastify) {
|
|
|
64
65
|
timestamp: new Date().toISOString(),
|
|
65
66
|
llm_services: getLlmServices(),
|
|
66
67
|
llm_command_configured: isLlmCommandConfigured(),
|
|
67
|
-
capabilities: getCapabilities(),
|
|
68
68
|
env: {
|
|
69
69
|
HQ_URL: config.HQ_URL || '',
|
|
70
70
|
MINION_ID: config.MINION_ID || '',
|
|
@@ -107,16 +107,21 @@ async function healthRoutes(fastify) {
|
|
|
107
107
|
return { success: true }
|
|
108
108
|
})
|
|
109
109
|
|
|
110
|
-
//
|
|
111
|
-
fastify.
|
|
112
|
-
|
|
113
|
-
|
|
110
|
+
// Get current capabilities (MCP servers, installed packages, env var keys)
|
|
111
|
+
fastify.get('/api/capabilities', async () => {
|
|
112
|
+
return getCapabilities()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
// Check requirements against current capabilities
|
|
116
|
+
fastify.post('/api/capabilities/check', async (request, reply) => {
|
|
117
|
+
const { mcp_servers, packages, env_vars } = request.body || {}
|
|
118
|
+
|
|
119
|
+
if (!mcp_servers && !packages && !env_vars) {
|
|
114
120
|
reply.code(400)
|
|
115
|
-
return { error: '
|
|
121
|
+
return { error: 'At least one of mcp_servers, packages, or env_vars must be provided' }
|
|
116
122
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
return { cli_tools: checkTools(limited) }
|
|
123
|
+
|
|
124
|
+
return checkRequirements({ mcp_servers, packages, env_vars })
|
|
120
125
|
})
|
|
121
126
|
}
|
|
122
127
|
|
|
@@ -11,13 +11,16 @@
|
|
|
11
11
|
```yaml
|
|
12
12
|
requires:
|
|
13
13
|
mcp_servers: [playwright]
|
|
14
|
-
|
|
14
|
+
packages:
|
|
15
|
+
apt: [jq, imagemagick]
|
|
16
|
+
env_vars: [API_KEY]
|
|
15
17
|
```
|
|
16
18
|
|
|
17
19
|
| 種別 | 検出方法 | チェック対象 |
|
|
18
20
|
|------|---------|-------------|
|
|
19
21
|
| `mcp_servers` | `~/.mcp.json` を読み取り、`mcpServers` キーにサーバー名が存在するか確認 | 設定ファイルの有無のみ(実際の起動テストはしない) |
|
|
20
|
-
| `
|
|
22
|
+
| `packages` | 各パッケージマネージャーの list コマンドでインストール済みパッケージを取得し、突合 | パッケージの存在 |
|
|
23
|
+
| `env_vars` | ミニオンローカル変数/シークレット + HQ注入変数 | キーの存在 |
|
|
21
24
|
|
|
22
25
|
**重要**: MCP サーバーは `~/.mcp.json` に設定しないと検出されない。npm パッケージをインストールしただけでは不十分。
|
|
23
26
|
|
|
@@ -91,9 +94,9 @@ URL ベースの MCP サーバーは `url` フィールドで指定する(`com
|
|
|
91
94
|
|
|
92
95
|
---
|
|
93
96
|
|
|
94
|
-
##
|
|
97
|
+
## パッケージのインストール
|
|
95
98
|
|
|
96
|
-
スキルが `requires.
|
|
99
|
+
スキルが `requires.packages` で宣言したパッケージは、対応するパッケージマネージャーの list コマンドで検出される。
|
|
97
100
|
|
|
98
101
|
### パッケージマネージャーの使い分け
|
|
99
102
|
|
|
@@ -162,7 +165,7 @@ pip3 install --user yq
|
|
|
162
165
|
|
|
163
166
|
### npx(一時実行)
|
|
164
167
|
|
|
165
|
-
`npx` はインストールせずに npm パッケージを一時実行する。`
|
|
168
|
+
`npx` はインストールせずに npm パッケージを一時実行する。`packages` の事前チェックでは検出されないため、恒久的に使うツールには `npm install -g` を使うこと。
|
|
166
169
|
|
|
167
170
|
```bash
|
|
168
171
|
# 一時的な利用(事前チェックでは検出されない)
|
package/docs/task-guides.md
CHANGED
|
@@ -23,7 +23,10 @@ name: my-skill
|
|
|
23
23
|
description: What this skill does
|
|
24
24
|
requires:
|
|
25
25
|
mcp_servers: [playwright, supabase]
|
|
26
|
-
|
|
26
|
+
packages:
|
|
27
|
+
apt: [ffmpeg]
|
|
28
|
+
npm: ["@anthropic-ai/claude-code"]
|
|
29
|
+
env_vars: [API_KEY]
|
|
27
30
|
---
|
|
28
31
|
|
|
29
32
|
Skill instructions here...
|
|
@@ -37,7 +40,8 @@ Skill instructions here...
|
|
|
37
40
|
| `description` | Yes | スキルの説明 |
|
|
38
41
|
| `requires` | No | 実行に必要な依存関係 |
|
|
39
42
|
| `requires.mcp_servers` | No | 必要な MCP サーバー名のリスト |
|
|
40
|
-
| `requires.
|
|
43
|
+
| `requires.packages` | No | 必要なパッケージ(パッケージマネージャー別に指定) |
|
|
44
|
+
| `requires.env_vars` | No | 必要な環境変数キーのリスト |
|
|
41
45
|
|
|
42
46
|
`requires` を宣言すると、ワークフロー実行前にミニオンの環境と照合する事前チェック(readiness check)が行われる。
|
|
43
47
|
未宣言の場合は常に実行可能と見なされる。
|
package/linux/server.js
CHANGED
|
@@ -35,7 +35,7 @@ const PACKAGE_ROOT = path.join(__dirname, '..')
|
|
|
35
35
|
const { config, validate, isHqConfigured } = require('../core/config')
|
|
36
36
|
const { sendHeartbeat } = require('../core/api')
|
|
37
37
|
const { version } = require('../package.json')
|
|
38
|
-
|
|
38
|
+
|
|
39
39
|
const workflowStore = require('../core/stores/workflow-store')
|
|
40
40
|
const routineStore = require('../core/stores/routine-store')
|
|
41
41
|
|
|
@@ -96,7 +96,7 @@ async function shutdown(signal) {
|
|
|
96
96
|
if (isHqConfigured()) {
|
|
97
97
|
try {
|
|
98
98
|
await Promise.race([
|
|
99
|
-
sendHeartbeat({ status: 'offline', version
|
|
99
|
+
sendHeartbeat({ status: 'offline', version }),
|
|
100
100
|
new Promise(resolve => setTimeout(resolve, 3000)),
|
|
101
101
|
])
|
|
102
102
|
} catch {
|
|
@@ -331,14 +331,14 @@ async function start() {
|
|
|
331
331
|
// Send initial online heartbeat
|
|
332
332
|
const { getStatus } = require('../core/routes/health')
|
|
333
333
|
const { currentTask } = getStatus()
|
|
334
|
-
sendHeartbeat({ status: 'online', current_task: currentTask, version
|
|
334
|
+
sendHeartbeat({ status: 'online', current_task: currentTask, version }).catch(err => {
|
|
335
335
|
console.error('[Heartbeat] Initial heartbeat failed:', err.message)
|
|
336
336
|
})
|
|
337
337
|
|
|
338
338
|
// Start periodic heartbeat
|
|
339
339
|
heartbeatTimer = setInterval(() => {
|
|
340
340
|
const { currentStatus, currentTask } = getStatus()
|
|
341
|
-
sendHeartbeat({ status: currentStatus, current_task: currentTask, version
|
|
341
|
+
sendHeartbeat({ status: currentStatus, current_task: currentTask, version }).catch(err => {
|
|
342
342
|
console.error('[Heartbeat] Periodic heartbeat failed:', err.message)
|
|
343
343
|
})
|
|
344
344
|
}, HEARTBEAT_INTERVAL_MS)
|
package/package.json
CHANGED
|
@@ -60,6 +60,14 @@ function buildUpdateScript(npmInstallCmd, stopCmd, startCmd) {
|
|
|
60
60
|
const logPath = path.join(dataDir, 'update-agent.log')
|
|
61
61
|
|
|
62
62
|
// PowerShell script content: stop → install → start, with logging
|
|
63
|
+
// The stopCmd kills all child processes of the agent. Since this script
|
|
64
|
+
// itself is a child of the agent (spawn from Node), we must exclude our
|
|
65
|
+
// own PID ($PID) to avoid the update script killing itself.
|
|
66
|
+
const safeStopCmd = stopCmd.replace(
|
|
67
|
+
/ForEach-Object\s*\{\s*Stop-Process/,
|
|
68
|
+
'Where-Object { $_.ProcessId -ne $PID } | ForEach-Object { Stop-Process'
|
|
69
|
+
)
|
|
70
|
+
|
|
63
71
|
const ps1 = [
|
|
64
72
|
`$ErrorActionPreference = 'Stop'`,
|
|
65
73
|
`$logFile = '${logPath.replace(/\\/g, '\\\\')}'`,
|
|
@@ -67,7 +75,7 @@ function buildUpdateScript(npmInstallCmd, stopCmd, startCmd) {
|
|
|
67
75
|
`Log 'Update started'`,
|
|
68
76
|
`try {`,
|
|
69
77
|
` Log 'Stopping agent...'`,
|
|
70
|
-
` ${
|
|
78
|
+
` ${safeStopCmd}`,
|
|
71
79
|
` Start-Sleep -Seconds 3`,
|
|
72
80
|
` Log 'Installing package...'`,
|
|
73
81
|
` $out = & cmd /c "${npmInstallCmd} 2>&1"`,
|
package/win/server.js
CHANGED
|
@@ -16,7 +16,7 @@ const fastify = require('fastify')({ logger: true })
|
|
|
16
16
|
const { config, validate, isHqConfigured } = require('../core/config')
|
|
17
17
|
const { sendHeartbeat } = require('../core/api')
|
|
18
18
|
const { version } = require('../package.json')
|
|
19
|
-
|
|
19
|
+
|
|
20
20
|
const workflowStore = require('../core/stores/workflow-store')
|
|
21
21
|
const routineStore = require('../core/stores/routine-store')
|
|
22
22
|
|
|
@@ -74,7 +74,7 @@ async function shutdown(signal) {
|
|
|
74
74
|
if (isHqConfigured()) {
|
|
75
75
|
try {
|
|
76
76
|
await Promise.race([
|
|
77
|
-
sendHeartbeat({ status: 'offline', version
|
|
77
|
+
sendHeartbeat({ status: 'offline', version }),
|
|
78
78
|
new Promise(resolve => setTimeout(resolve, 3000)),
|
|
79
79
|
])
|
|
80
80
|
} catch {
|
|
@@ -267,14 +267,14 @@ async function start() {
|
|
|
267
267
|
// Send initial online heartbeat
|
|
268
268
|
const { getStatus } = require('../core/routes/health')
|
|
269
269
|
const { currentTask } = getStatus()
|
|
270
|
-
sendHeartbeat({ status: 'online', current_task: currentTask, version
|
|
270
|
+
sendHeartbeat({ status: 'online', current_task: currentTask, version }).catch(err => {
|
|
271
271
|
console.error('[Heartbeat] Initial heartbeat failed:', err.message)
|
|
272
272
|
})
|
|
273
273
|
|
|
274
274
|
// Start periodic heartbeat
|
|
275
275
|
heartbeatTimer = setInterval(() => {
|
|
276
276
|
const { currentStatus, currentTask } = getStatus()
|
|
277
|
-
sendHeartbeat({ status: currentStatus, current_task: currentTask, version
|
|
277
|
+
sendHeartbeat({ status: currentStatus, current_task: currentTask, version }).catch(err => {
|
|
278
278
|
console.error('[Heartbeat] Periodic heartbeat failed:', err.message)
|
|
279
279
|
})
|
|
280
280
|
}, HEARTBEAT_INTERVAL_MS)
|