@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 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
- throw new Error(data.error || `API request failed: ${response.status}`)
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
@@ -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 update -g @geekbeer/minion && ${SUDO}systemctl restart minion-agent`,
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 update -g @geekbeer/minion && ${SUDO}supervisorctl restart minion-agent`,
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 update -g @geekbeer/minion',
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": "1.6.1",
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
  ],
@@ -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 = {