@geekbeer/minion 1.6.1 → 2.4.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/api.js +3 -1
- package/lib/process-manager.js +3 -3
- package/minion-cli.sh +55 -0
- package/package.json +3 -1
- package/routes/files.js +288 -0
- package/routes/index.js +8 -0
- package/routes/skills.js +191 -49
- package/routes/terminal.js +12 -0
- package/routes/workflows.js +192 -11
- package/rules/minion.md +195 -0
- package/server.js +91 -0
- package/settings/permissions.json +17 -0
- package/settings/tmux.conf +16 -0
- package/workflow-store.js +45 -1
package/api.js
CHANGED
|
@@ -31,7 +31,9 @@ async function request(endpoint, options = {}) {
|
|
|
31
31
|
const data = await response.json()
|
|
32
32
|
|
|
33
33
|
if (!response.ok) {
|
|
34
|
-
|
|
34
|
+
const err = new Error(data.error || `API request failed: ${response.status}`)
|
|
35
|
+
err.statusCode = response.status
|
|
36
|
+
throw err
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
return data
|
package/lib/process-manager.js
CHANGED
|
@@ -51,7 +51,7 @@ function buildAllowedCommands(procMgr) {
|
|
|
51
51
|
}
|
|
52
52
|
commands['update-agent'] = {
|
|
53
53
|
description: 'Update @geekbeer/minion to latest version and restart',
|
|
54
|
-
command: `npm
|
|
54
|
+
command: `npm install -g @geekbeer/minion@latest && ${SUDO}systemctl restart minion-agent`,
|
|
55
55
|
deferred: true,
|
|
56
56
|
}
|
|
57
57
|
commands['restart-display'] = {
|
|
@@ -70,7 +70,7 @@ function buildAllowedCommands(procMgr) {
|
|
|
70
70
|
}
|
|
71
71
|
commands['update-agent'] = {
|
|
72
72
|
description: 'Update @geekbeer/minion to latest version and restart',
|
|
73
|
-
command: `npm
|
|
73
|
+
command: `npm install -g @geekbeer/minion@latest && ${SUDO}supervisorctl restart minion-agent`,
|
|
74
74
|
deferred: true,
|
|
75
75
|
}
|
|
76
76
|
commands['restart-display'] = {
|
|
@@ -85,7 +85,7 @@ function buildAllowedCommands(procMgr) {
|
|
|
85
85
|
// Standalone mode: limited commands
|
|
86
86
|
commands['update-agent'] = {
|
|
87
87
|
description: 'Update @geekbeer/minion to latest version',
|
|
88
|
-
command: 'npm
|
|
88
|
+
command: 'npm install -g @geekbeer/minion@latest',
|
|
89
89
|
}
|
|
90
90
|
commands['status-services'] = {
|
|
91
91
|
description: 'Show agent process info',
|
package/minion-cli.sh
CHANGED
|
@@ -438,6 +438,16 @@ SUPEOF
|
|
|
438
438
|
echo " -> No bundled skills found (this is OK for development)"
|
|
439
439
|
fi
|
|
440
440
|
|
|
441
|
+
# Deploy Claude rules (minion self-knowledge for AI)
|
|
442
|
+
local BUNDLED_RULES_DIR="${NPM_ROOT}/@geekbeer/minion/rules"
|
|
443
|
+
local CLAUDE_RULES_DIR="${TARGET_HOME}/.claude/rules"
|
|
444
|
+
|
|
445
|
+
if [ -d "$BUNDLED_RULES_DIR" ]; then
|
|
446
|
+
$RUN_AS mkdir -p "$CLAUDE_RULES_DIR"
|
|
447
|
+
$RUN_AS cp "$BUNDLED_RULES_DIR"/minion.md "$CLAUDE_RULES_DIR"/minion.md
|
|
448
|
+
echo " -> Deployed Claude rules: minion.md"
|
|
449
|
+
fi
|
|
450
|
+
|
|
441
451
|
# Step 9 (optional): Cloudflare Tunnel setup
|
|
442
452
|
if [ "$SETUP_TUNNEL" = true ]; then
|
|
443
453
|
echo ""
|
|
@@ -585,6 +595,50 @@ case "${1:-}" in
|
|
|
585
595
|
echo "minion-agent restarted ($PROC_MGR)"
|
|
586
596
|
;;
|
|
587
597
|
|
|
598
|
+
skill)
|
|
599
|
+
shift
|
|
600
|
+
SKILL_CMD="${1:-}"
|
|
601
|
+
shift || true
|
|
602
|
+
case "$SKILL_CMD" in
|
|
603
|
+
push)
|
|
604
|
+
SKILL_NAME="${1:-}"
|
|
605
|
+
if [ -z "$SKILL_NAME" ]; then
|
|
606
|
+
echo "Usage: minion-cli skill push <skill-name>"
|
|
607
|
+
exit 1
|
|
608
|
+
fi
|
|
609
|
+
curl -s -X POST "$AGENT_URL/api/skills/push/$SKILL_NAME" \
|
|
610
|
+
-H "Authorization: Bearer $API_TOKEN" | jq .
|
|
611
|
+
;;
|
|
612
|
+
fetch)
|
|
613
|
+
SKILL_NAME="${1:-}"
|
|
614
|
+
if [ -z "$SKILL_NAME" ]; then
|
|
615
|
+
echo "Usage: minion-cli skill fetch <skill-name>"
|
|
616
|
+
exit 1
|
|
617
|
+
fi
|
|
618
|
+
curl -s -X POST "$AGENT_URL/api/skills/fetch/$SKILL_NAME" \
|
|
619
|
+
-H "Authorization: Bearer $API_TOKEN" | jq .
|
|
620
|
+
;;
|
|
621
|
+
list)
|
|
622
|
+
FLAG="${1:-}"
|
|
623
|
+
if [ "$FLAG" = "--local" ]; then
|
|
624
|
+
curl -s "$AGENT_URL/api/list-skills" \
|
|
625
|
+
-H "Authorization: Bearer $API_TOKEN" | jq .
|
|
626
|
+
else
|
|
627
|
+
curl -s "$AGENT_URL/api/skills/remote" \
|
|
628
|
+
-H "Authorization: Bearer $API_TOKEN" | jq .
|
|
629
|
+
fi
|
|
630
|
+
;;
|
|
631
|
+
*)
|
|
632
|
+
echo "Usage: minion-cli skill <push|fetch|list> [options]"
|
|
633
|
+
echo ""
|
|
634
|
+
echo "Commands:"
|
|
635
|
+
echo " push <name> Push local skill to HQ"
|
|
636
|
+
echo " fetch <name> Fetch skill from HQ to local"
|
|
637
|
+
echo " list List skills on HQ (--local for local skills)"
|
|
638
|
+
;;
|
|
639
|
+
esac
|
|
640
|
+
;;
|
|
641
|
+
|
|
588
642
|
*)
|
|
589
643
|
echo "Minion Agent CLI (@geekbeer/minion) v${CLI_VERSION}"
|
|
590
644
|
echo ""
|
|
@@ -596,6 +650,7 @@ case "${1:-}" in
|
|
|
596
650
|
echo " minion-cli status # Get current status"
|
|
597
651
|
echo " minion-cli health # Health check"
|
|
598
652
|
echo " minion-cli set-status <status> [task] # Set status and optional task"
|
|
653
|
+
echo " minion-cli skill <push|fetch|list> # Manage skills with HQ"
|
|
599
654
|
echo " minion-cli --version # Show version"
|
|
600
655
|
echo ""
|
|
601
656
|
echo "Setup options:"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geekbeer/minion",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.4.1",
|
|
4
4
|
"description": "AI Agent runtime for Minion - manages status and skill deployment on VPS",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
"lib/",
|
|
18
18
|
"routes/",
|
|
19
19
|
"skills/",
|
|
20
|
+
"rules/",
|
|
21
|
+
"settings/",
|
|
20
22
|
"minion-cli.sh",
|
|
21
23
|
".env.example"
|
|
22
24
|
],
|
package/routes/files.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File management endpoints
|
|
3
|
+
*
|
|
4
|
+
* Provides bidirectional file transfer between HQ and minion.
|
|
5
|
+
* Files are stored in ~/files/ directory.
|
|
6
|
+
*
|
|
7
|
+
* Endpoints:
|
|
8
|
+
* - GET /api/files - List files in directory
|
|
9
|
+
* - GET /api/files/* - Download a file
|
|
10
|
+
* - POST /api/files/* - Upload a file
|
|
11
|
+
* - DELETE /api/files/* - Delete a file
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs').promises
|
|
15
|
+
const fsSync = require('fs')
|
|
16
|
+
const path = require('path')
|
|
17
|
+
const os = require('os')
|
|
18
|
+
|
|
19
|
+
const { verifyToken } = require('../lib/auth')
|
|
20
|
+
|
|
21
|
+
/** Base directory for file storage */
|
|
22
|
+
const FILES_DIR = path.join(os.homedir(), 'files')
|
|
23
|
+
|
|
24
|
+
/** Max upload size: 50MB */
|
|
25
|
+
const MAX_UPLOAD_SIZE = 50 * 1024 * 1024
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve a user-provided path safely within the base directory.
|
|
29
|
+
* Returns null if the path escapes the sandbox.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} basedir - Absolute base directory
|
|
32
|
+
* @param {string} userPath - User-provided relative path
|
|
33
|
+
* @returns {string|null} Resolved absolute path, or null if traversal detected
|
|
34
|
+
*/
|
|
35
|
+
function safePath(basedir, userPath) {
|
|
36
|
+
const decoded = decodeURIComponent(userPath)
|
|
37
|
+
// Reject null bytes
|
|
38
|
+
if (decoded.includes('\0')) return null
|
|
39
|
+
const resolved = path.resolve(basedir, decoded)
|
|
40
|
+
if (resolved === basedir) return resolved
|
|
41
|
+
if (!resolved.startsWith(basedir + path.sep)) return null
|
|
42
|
+
return resolved
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Register file routes as Fastify plugin
|
|
47
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
48
|
+
*/
|
|
49
|
+
async function fileRoutes(fastify) {
|
|
50
|
+
// Register binary body parser for octet-stream
|
|
51
|
+
fastify.addContentTypeParser('application/octet-stream', function (request, payload, done) {
|
|
52
|
+
const chunks = []
|
|
53
|
+
let totalSize = 0
|
|
54
|
+
payload.on('data', chunk => {
|
|
55
|
+
totalSize += chunk.length
|
|
56
|
+
if (totalSize > MAX_UPLOAD_SIZE) {
|
|
57
|
+
done(new Error('File too large (max 50MB)'))
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
chunks.push(chunk)
|
|
61
|
+
})
|
|
62
|
+
payload.on('end', () => done(null, Buffer.concat(chunks)))
|
|
63
|
+
payload.on('error', done)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// GET /api/files - List files in directory
|
|
67
|
+
fastify.get('/api/files', async (request, reply) => {
|
|
68
|
+
if (!verifyToken(request)) {
|
|
69
|
+
reply.code(401)
|
|
70
|
+
return { success: false, error: 'Unauthorized' }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const subPath = request.query.path || ''
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
let targetDir = FILES_DIR
|
|
77
|
+
if (subPath) {
|
|
78
|
+
targetDir = safePath(FILES_DIR, subPath)
|
|
79
|
+
if (!targetDir) {
|
|
80
|
+
reply.code(403)
|
|
81
|
+
return { success: false, error: 'Invalid path' }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Ensure directory exists
|
|
86
|
+
await fs.mkdir(targetDir, { recursive: true })
|
|
87
|
+
|
|
88
|
+
const entries = await fs.readdir(targetDir, { withFileTypes: true })
|
|
89
|
+
const files = []
|
|
90
|
+
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
const entryPath = path.join(targetDir, entry.name)
|
|
93
|
+
try {
|
|
94
|
+
const stat = await fs.lstat(entryPath)
|
|
95
|
+
// Skip symlinks that point outside FILES_DIR
|
|
96
|
+
if (stat.isSymbolicLink()) {
|
|
97
|
+
const realPath = await fs.realpath(entryPath)
|
|
98
|
+
if (!realPath.startsWith(FILES_DIR)) continue
|
|
99
|
+
}
|
|
100
|
+
const relativePath = path.relative(FILES_DIR, entryPath)
|
|
101
|
+
files.push({
|
|
102
|
+
name: entry.name,
|
|
103
|
+
path: relativePath,
|
|
104
|
+
size: stat.size,
|
|
105
|
+
modified: stat.mtime.toISOString(),
|
|
106
|
+
is_directory: entry.isDirectory(),
|
|
107
|
+
})
|
|
108
|
+
} catch {
|
|
109
|
+
// Skip entries we can't stat
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Sort: directories first, then by name
|
|
114
|
+
files.sort((a, b) => {
|
|
115
|
+
if (a.is_directory !== b.is_directory) return a.is_directory ? -1 : 1
|
|
116
|
+
return a.name.localeCompare(b.name)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
return { success: true, files, current_path: subPath || '' }
|
|
120
|
+
} catch (error) {
|
|
121
|
+
console.error(`[Files] List error: ${error.message}`)
|
|
122
|
+
reply.code(500)
|
|
123
|
+
return { success: false, error: error.message }
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
// GET /api/files/* - Download a file
|
|
128
|
+
fastify.get('/api/files/*', async (request, reply) => {
|
|
129
|
+
if (!verifyToken(request)) {
|
|
130
|
+
reply.code(401)
|
|
131
|
+
return { success: false, error: 'Unauthorized' }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const filePath = request.params['*']
|
|
135
|
+
if (!filePath) {
|
|
136
|
+
reply.code(400)
|
|
137
|
+
return { success: false, error: 'File path is required' }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const resolved = safePath(FILES_DIR, filePath)
|
|
141
|
+
if (!resolved) {
|
|
142
|
+
reply.code(403)
|
|
143
|
+
return { success: false, error: 'Invalid path' }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const stat = await fs.lstat(resolved)
|
|
148
|
+
|
|
149
|
+
// Don't allow downloading directories
|
|
150
|
+
if (stat.isDirectory()) {
|
|
151
|
+
reply.code(400)
|
|
152
|
+
return { success: false, error: 'Cannot download a directory' }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Check symlink safety
|
|
156
|
+
if (stat.isSymbolicLink()) {
|
|
157
|
+
const realPath = await fs.realpath(resolved)
|
|
158
|
+
if (!realPath.startsWith(FILES_DIR)) {
|
|
159
|
+
reply.code(403)
|
|
160
|
+
return { success: false, error: 'Invalid path' }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const filename = path.basename(resolved)
|
|
165
|
+
reply
|
|
166
|
+
.type('application/octet-stream')
|
|
167
|
+
.header('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`)
|
|
168
|
+
.header('Content-Length', stat.size)
|
|
169
|
+
|
|
170
|
+
return reply.send(fsSync.createReadStream(resolved))
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (error.code === 'ENOENT') {
|
|
173
|
+
reply.code(404)
|
|
174
|
+
return { success: false, error: 'File not found' }
|
|
175
|
+
}
|
|
176
|
+
console.error(`[Files] Download error: ${error.message}`)
|
|
177
|
+
reply.code(500)
|
|
178
|
+
return { success: false, error: error.message }
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// POST /api/files/* - Upload a file
|
|
183
|
+
fastify.post('/api/files/*', { bodyLimit: MAX_UPLOAD_SIZE }, async (request, reply) => {
|
|
184
|
+
if (!verifyToken(request)) {
|
|
185
|
+
reply.code(401)
|
|
186
|
+
return { success: false, error: 'Unauthorized' }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const filePath = request.params['*']
|
|
190
|
+
if (!filePath) {
|
|
191
|
+
reply.code(400)
|
|
192
|
+
return { success: false, error: 'File path is required' }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const resolved = safePath(FILES_DIR, filePath)
|
|
196
|
+
if (!resolved) {
|
|
197
|
+
reply.code(403)
|
|
198
|
+
return { success: false, error: 'Invalid path' }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Don't allow overwriting the base directory itself
|
|
202
|
+
if (resolved === FILES_DIR) {
|
|
203
|
+
reply.code(400)
|
|
204
|
+
return { success: false, error: 'Invalid file path' }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const body = request.body
|
|
209
|
+
if (!Buffer.isBuffer(body)) {
|
|
210
|
+
reply.code(400)
|
|
211
|
+
return { success: false, error: 'Expected binary body (application/octet-stream)' }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Create parent directories
|
|
215
|
+
await fs.mkdir(path.dirname(resolved), { recursive: true })
|
|
216
|
+
|
|
217
|
+
// Write file
|
|
218
|
+
await fs.writeFile(resolved, body)
|
|
219
|
+
|
|
220
|
+
const stat = await fs.stat(resolved)
|
|
221
|
+
const relativePath = path.relative(FILES_DIR, resolved)
|
|
222
|
+
|
|
223
|
+
console.log(`[Files] Uploaded: ${relativePath} (${stat.size} bytes)`)
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
success: true,
|
|
227
|
+
file: {
|
|
228
|
+
name: path.basename(resolved),
|
|
229
|
+
path: relativePath,
|
|
230
|
+
size: stat.size,
|
|
231
|
+
},
|
|
232
|
+
}
|
|
233
|
+
} catch (error) {
|
|
234
|
+
console.error(`[Files] Upload error: ${error.message}`)
|
|
235
|
+
reply.code(500)
|
|
236
|
+
return { success: false, error: error.message }
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
// DELETE /api/files/* - Delete a file or directory
|
|
241
|
+
fastify.delete('/api/files/*', async (request, reply) => {
|
|
242
|
+
if (!verifyToken(request)) {
|
|
243
|
+
reply.code(401)
|
|
244
|
+
return { success: false, error: 'Unauthorized' }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const filePath = request.params['*']
|
|
248
|
+
if (!filePath) {
|
|
249
|
+
reply.code(400)
|
|
250
|
+
return { success: false, error: 'File path is required' }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const resolved = safePath(FILES_DIR, filePath)
|
|
254
|
+
if (!resolved) {
|
|
255
|
+
reply.code(403)
|
|
256
|
+
return { success: false, error: 'Invalid path' }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Don't allow deleting the base directory itself
|
|
260
|
+
if (resolved === FILES_DIR) {
|
|
261
|
+
reply.code(400)
|
|
262
|
+
return { success: false, error: 'Cannot delete root files directory' }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
await fs.access(resolved)
|
|
267
|
+
} catch {
|
|
268
|
+
reply.code(404)
|
|
269
|
+
return { success: false, error: 'File not found' }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
const stat = await fs.lstat(resolved)
|
|
274
|
+
await fs.rm(resolved, { recursive: stat.isDirectory(), force: true })
|
|
275
|
+
|
|
276
|
+
const relativePath = path.relative(FILES_DIR, resolved)
|
|
277
|
+
console.log(`[Files] Deleted: ${relativePath}`)
|
|
278
|
+
|
|
279
|
+
return { success: true, message: `Deleted ${relativePath}` }
|
|
280
|
+
} catch (error) {
|
|
281
|
+
console.error(`[Files] Delete error: ${error.message}`)
|
|
282
|
+
reply.code(500)
|
|
283
|
+
return { success: false, error: error.message }
|
|
284
|
+
}
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
module.exports = { fileRoutes }
|
package/routes/index.js
CHANGED
|
@@ -34,6 +34,12 @@
|
|
|
34
34
|
* GET /api/terminal/capture - Capture pane content (auth required)
|
|
35
35
|
* POST /api/terminal/send - Send keys to session (auth required)
|
|
36
36
|
* POST /api/terminal/kill - Kill a session (auth required)
|
|
37
|
+
*
|
|
38
|
+
* Files (routes/files.js)
|
|
39
|
+
* GET /api/files - List files in directory (auth required)
|
|
40
|
+
* GET /api/files/* - Download a file (auth required)
|
|
41
|
+
* POST /api/files/* - Upload a file (auth required)
|
|
42
|
+
* DELETE /api/files/* - Delete a file (auth required)
|
|
37
43
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
38
44
|
*/
|
|
39
45
|
|
|
@@ -42,6 +48,7 @@ const { commandRoutes, getProcessManager, getAllowedCommands } = require('./comm
|
|
|
42
48
|
const { skillRoutes } = require('./skills')
|
|
43
49
|
const { workflowRoutes } = require('./workflows')
|
|
44
50
|
const { terminalRoutes } = require('./terminal')
|
|
51
|
+
const { fileRoutes } = require('./files')
|
|
45
52
|
|
|
46
53
|
/**
|
|
47
54
|
* Register all routes with Fastify instance
|
|
@@ -53,6 +60,7 @@ async function registerRoutes(fastify) {
|
|
|
53
60
|
await fastify.register(skillRoutes)
|
|
54
61
|
await fastify.register(workflowRoutes)
|
|
55
62
|
await fastify.register(terminalRoutes)
|
|
63
|
+
await fastify.register(fileRoutes)
|
|
56
64
|
}
|
|
57
65
|
|
|
58
66
|
module.exports = {
|