@geekbeer/minion 2.53.2 → 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.
@@ -1,22 +1,19 @@
1
1
  /**
2
2
  * Capability checker
3
3
  *
4
- * Detects MCP servers configured in ~/.mcp.json and available CLI tools.
5
- * Results are cached in memory for 5 minutes to avoid excessive filesystem/process checks.
6
- * Follows the same caching pattern as llm-checker.js.
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 + CLI tools + env var keys), cached for 5 minutes.
95
- * @returns {{ mcp_servers: { name: string, configured: boolean }[], cli_tools: { name: string, available: boolean, version?: string }[], env_var_keys: string[] }}
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 cli_tools = TOOL_NAMES.map(name => checkTool(name))
57
+ const packages = scanAllPackages()
105
58
  const env_var_keys = getEnvVarKeys()
106
59
 
107
- cachedResult = { mcp_servers, cli_tools, env_var_keys }
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 arbitrary tool names on-demand (not cached).
120
- * Used by readiness checks to verify skill-declared cli_tools.
121
- * @param {string[]} names - Tool names to check
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 checkTools(names) {
125
- return names.map(name => checkTool(name))
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, clearCapabilityCache, checkTools }
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 }
@@ -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
- * - POST /api/capabilities/check-tools - Check arbitrary CLI tools on-demand
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, checkTools } = require('../lib/capability-checker')
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
- // Check arbitrary CLI tools on-demand
111
- fastify.post('/api/capabilities/check-tools', async (request, reply) => {
112
- const { tools } = request.body || {}
113
- if (!Array.isArray(tools) || tools.length === 0) {
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: 'tools must be a non-empty array of tool names' }
121
+ return { error: 'At least one of mcp_servers, packages, or env_vars must be provided' }
116
122
  }
117
- // Limit to 20 tools per request to avoid abuse
118
- const limited = tools.slice(0, 20).filter(t => typeof t === 'string' && t.length > 0)
119
- return { cli_tools: checkTools(limited) }
123
+
124
+ return checkRequirements({ mcp_servers, packages, env_vars })
120
125
  })
121
126
  }
122
127
 
@@ -0,0 +1,199 @@
1
+ # 環境セットアップガイド
2
+
3
+ スキルが必要とする MCP サーバーや CLI ツールのインストール・設定手順です。
4
+
5
+ ---
6
+
7
+ ## 事前チェック(Readiness Check)の仕組み
8
+
9
+ スキルの `requires` フロントマターで宣言された依存関係は、ワークフロー実行前に事前チェックされる。
10
+
11
+ ```yaml
12
+ requires:
13
+ mcp_servers: [playwright]
14
+ packages:
15
+ apt: [jq, imagemagick]
16
+ env_vars: [API_KEY]
17
+ ```
18
+
19
+ | 種別 | 検出方法 | チェック対象 |
20
+ |------|---------|-------------|
21
+ | `mcp_servers` | `~/.mcp.json` を読み取り、`mcpServers` キーにサーバー名が存在するか確認 | 設定ファイルの有無のみ(実際の起動テストはしない) |
22
+ | `packages` | 各パッケージマネージャーの list コマンドでインストール済みパッケージを取得し、突合 | パッケージの存在 |
23
+ | `env_vars` | ミニオンローカル変数/シークレット + HQ注入変数 | キーの存在 |
24
+
25
+ **重要**: MCP サーバーは `~/.mcp.json` に設定しないと検出されない。npm パッケージをインストールしただけでは不十分。
26
+
27
+ ---
28
+
29
+ ## MCP サーバーの設定
30
+
31
+ MCP サーバーは `~/.mcp.json` に JSON 形式で設定する。このファイルが Claude Code セッション起動時に読み込まれる。
32
+
33
+ ### ファイル形式
34
+
35
+ ```json
36
+ {
37
+ "mcpServers": {
38
+ "<server-name>": {
39
+ "command": "<起動コマンド>",
40
+ "args": ["<引数1>", "<引数2>"]
41
+ }
42
+ }
43
+ }
44
+ ```
45
+
46
+ ### 設定の追加・変更手順
47
+
48
+ 1. `~/.mcp.json` が存在しない場合は新規作成する
49
+ 2. 既存の場合は内容を読み取り、`mcpServers` オブジェクトにエントリを追加する
50
+ 3. 既存エントリを壊さないよう注意する
51
+
52
+ ```bash
53
+ # 既存ファイルの確認
54
+ cat ~/.mcp.json 2>/dev/null || echo '(not found)'
55
+ ```
56
+
57
+ ### よく使う MCP サーバーの設定例
58
+
59
+ #### Playwright(ブラウザ自動化)
60
+
61
+ ```json
62
+ {
63
+ "mcpServers": {
64
+ "playwright": {
65
+ "command": "npx",
66
+ "args": ["-y", "@playwright/mcp@latest"]
67
+ }
68
+ }
69
+ }
70
+ ```
71
+
72
+ `npx -y` により、未インストールでも自動ダウンロード・実行される。事前の `npm install` は不要。
73
+
74
+ #### Supabase(データベース)
75
+
76
+ ```json
77
+ {
78
+ "mcpServers": {
79
+ "supabase": {
80
+ "url": "http://<supabase-host>:54321/mcp?read_only=true&features=database,docs"
81
+ }
82
+ }
83
+ }
84
+ ```
85
+
86
+ URL ベースの MCP サーバーは `url` フィールドで指定する(`command`/`args` は不要)。
87
+
88
+ ### 注意事項
89
+
90
+ - `~/.mcp.json` は手動で編集する。Claude Code の設定 UI からは変更できない
91
+ - `npx -y <package>` 形式を使えば、グローバルインストールなしで MCP サーバーを起動できる
92
+ - サーバー名はスキルの `requires.mcp_servers` と一致させる必要がある(例: `playwright`)
93
+ - `claude-settings.json`(`~/.claude/settings.json`)の `mcpServers` に設定しても同様に動作するが、`~/.mcp.json` が推奨
94
+
95
+ ---
96
+
97
+ ## パッケージのインストール
98
+
99
+ スキルが `requires.packages` で宣言したパッケージは、対応するパッケージマネージャーの list コマンドで検出される。
100
+
101
+ ### パッケージマネージャーの使い分け
102
+
103
+ | マネージャー | 用途 | インストール先 |
104
+ |-------------|------|---------------|
105
+ | `apt` | OS レベルのツール・ライブラリ | システム全体 (`/usr/bin/` 等) |
106
+ | `npm` | Node.js パッケージ・CLI ツール | `node_modules/` またはグローバル |
107
+ | `npx` | npm パッケージの一時実行 | 一時ディレクトリ(実行後破棄) |
108
+ | `pip` / `pip3` | Python パッケージ | ユーザーディレクトリまたは venv |
109
+
110
+ ### apt(システムパッケージ)
111
+
112
+ 画像処理ツール、テキスト処理ツール、ネットワークツールなど OS レベルのツールに使う。
113
+
114
+ ```bash
115
+ sudo apt update && sudo apt install -y <package-name>
116
+ ```
117
+
118
+ 例:
119
+ ```bash
120
+ # 画像処理
121
+ sudo apt install -y imagemagick
122
+
123
+ # JSON 処理
124
+ sudo apt install -y jq
125
+
126
+ # PDF 処理
127
+ sudo apt install -y poppler-utils
128
+ ```
129
+
130
+ ### npm(Node.js CLI ツール)
131
+
132
+ グローバルインストールで `which` に検出されるようにする。
133
+
134
+ ```bash
135
+ npm install -g <package-name>
136
+ ```
137
+
138
+ 例:
139
+ ```bash
140
+ # TypeScript コンパイラ
141
+ npm install -g typescript
142
+
143
+ # Prettier(コードフォーマッター)
144
+ npm install -g prettier
145
+ ```
146
+
147
+ **注意**: MCP サーバーのインストールに `npm install -g` は使わない。MCP サーバーは `~/.mcp.json` に `npx -y` 形式で設定する(前述の MCP サーバー設定を参照)。
148
+
149
+ ### pip / pip3(Python ツール)
150
+
151
+ ```bash
152
+ pip3 install --user <package-name>
153
+ ```
154
+
155
+ `--user` を付けると `~/.local/bin/` にインストールされる。PATH に含まれていることを確認する。
156
+
157
+ 例:
158
+ ```bash
159
+ # AWS CLI
160
+ pip3 install --user awscli
161
+
162
+ # YAML 処理
163
+ pip3 install --user yq
164
+ ```
165
+
166
+ ### npx(一時実行)
167
+
168
+ `npx` はインストールせずに npm パッケージを一時実行する。`packages` の事前チェックでは検出されないため、恒久的に使うツールには `npm install -g` を使うこと。
169
+
170
+ ```bash
171
+ # 一時的な利用(事前チェックでは検出されない)
172
+ npx -y cowsay hello
173
+
174
+ # 恒久的に必要なら npm install -g を使う
175
+ npm install -g cowsay
176
+ ```
177
+
178
+ ---
179
+
180
+ ## インストール後の確認
181
+
182
+ ツールのインストール後、事前チェックが通るか確認する。
183
+
184
+ ```bash
185
+ # CLI ツールが検出されるか確認
186
+ which <tool-name>
187
+
188
+ # MCP サーバーが設定されているか確認
189
+ cat ~/.mcp.json | node -e "
190
+ const cfg = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));
191
+ const servers = cfg.mcpServers || cfg.servers || {};
192
+ console.log('Configured MCP servers:', Object.keys(servers).join(', ') || '(none)');
193
+ "
194
+
195
+ # エージェントのキャパビリティキャッシュをクリアして再検出させる
196
+ # (キャパビリティは 5 分間キャッシュされるため、即座に反映したい場合)
197
+ curl -s -X POST "http://localhost:8080/api/capabilities/clear-cache" \
198
+ -H "Authorization: Bearer $API_TOKEN"
199
+ ```
@@ -23,7 +23,10 @@ name: my-skill
23
23
  description: What this skill does
24
24
  requires:
25
25
  mcp_servers: [playwright, supabase]
26
- cli_tools: [git, node]
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.cli_tools` | No | 必要な CLI ツール名のリスト |
43
+ | `requires.packages` | No | 必要なパッケージ(パッケージマネージャー別に指定) |
44
+ | `requires.env_vars` | No | 必要な環境変数キーのリスト |
41
45
 
42
46
  `requires` を宣言すると、ワークフロー実行前にミニオンの環境と照合する事前チェック(readiness check)が行われる。
43
47
  未宣言の場合は常に実行可能と見なされる。
@@ -193,6 +197,19 @@ await reportIssue({ title: '...', body: '...', labels: ['bug'] })
193
197
 
194
198
  ---
195
199
 
200
+ ## ツール・MCPサーバーのインストール
201
+
202
+ スキルが `requires` で宣言している MCP サーバーや CLI ツールが不足している場合は、`~/.minion/docs/environment-setup.md` の手順に従ってインストールする。
203
+
204
+ 主なポイント:
205
+ - **MCP サーバー**: `~/.mcp.json` にエントリを追加する(`npm install` ではなく設定ファイルの編集)
206
+ - **CLI ツール**: `apt`(OS ツール)、`npm install -g`(Node.js ツール)、`pip3 install --user`(Python ツール)を使い分ける
207
+ - **確認**: インストール後 `which <tool>` や `cat ~/.mcp.json` で事前チェックが通るか確認する
208
+
209
+ 詳細は `~/.minion/docs/environment-setup.md` を参照。
210
+
211
+ ---
212
+
196
213
  ## トラブルシューティング
197
214
 
198
215
  ### HQ APIの仕様がわからない
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
- const { getCapabilities } = require('../core/lib/capability-checker')
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, capabilities: getCapabilities() }),
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, capabilities: getCapabilities() }).catch(err => {
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, capabilities: getCapabilities() }).catch(err => {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "2.53.2",
3
+ "version": "2.54.1",
4
4
  "description": "AI Agent runtime for Minion - manages status and skill deployment on VPS",
5
5
  "main": "linux/server.js",
6
6
  "bin": {
package/rules/core.md CHANGED
@@ -92,3 +92,4 @@ Workflow/Routine 実行中は以下も利用可能:
92
92
  API仕様やタスク手順の詳細は以下を参照:
93
93
  - `~/.minion/docs/api-reference.md` — ローカルAPI・HQ APIの全エンドポイント仕様
94
94
  - `~/.minion/docs/task-guides.md` — スキル修正・ワークフロー管理等の手順書
95
+ - `~/.minion/docs/environment-setup.md` — MCPサーバー設定・CLIツールインストール手順
@@ -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
- ` ${stopCmd}`,
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
- const { getCapabilities } = require('../core/lib/capability-checker')
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, capabilities: getCapabilities() }),
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, capabilities: getCapabilities() }).catch(err => {
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, capabilities: getCapabilities() }).catch(err => {
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)