@geekbeer/minion 1.6.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api.js +3 -1
- package/minion-cli.sh +56 -1
- 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 +13 -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/minion-cli.sh
CHANGED
|
@@ -375,7 +375,7 @@ SUPEOF
|
|
|
375
375
|
$SUDO systemctl enable tmux-init
|
|
376
376
|
$SUDO systemctl start tmux-init
|
|
377
377
|
$SUDO systemctl enable minion-agent
|
|
378
|
-
$SUDO systemctl
|
|
378
|
+
$SUDO systemctl restart minion-agent
|
|
379
379
|
;;
|
|
380
380
|
supervisord)
|
|
381
381
|
if $SUDO supervisorctl status &>/dev/null; then
|
|
@@ -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.0",
|
|
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 = {
|
package/routes/skills.js
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
* Skill management endpoints
|
|
3
3
|
*
|
|
4
4
|
* Endpoints:
|
|
5
|
-
* - GET /api/list-skills
|
|
6
|
-
* -
|
|
5
|
+
* - GET /api/list-skills - List locally deployed skills
|
|
6
|
+
* - DELETE /api/skills/:name - Delete a local skill
|
|
7
|
+
* - POST /api/skills/push/:name - Push local skill to HQ
|
|
8
|
+
* - POST /api/skills/fetch/:name - Fetch skill from HQ and deploy locally
|
|
9
|
+
* - GET /api/skills/remote - List skills on HQ
|
|
7
10
|
*/
|
|
8
11
|
|
|
9
12
|
const fs = require('fs').promises
|
|
@@ -11,6 +14,111 @@ const path = require('path')
|
|
|
11
14
|
const os = require('os')
|
|
12
15
|
|
|
13
16
|
const { verifyToken } = require('../lib/auth')
|
|
17
|
+
const api = require('../api')
|
|
18
|
+
const { isHqConfigured } = require('../config')
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse YAML frontmatter from SKILL.md content
|
|
22
|
+
* @param {string} content - Raw file content
|
|
23
|
+
* @returns {{ metadata: object, body: string }}
|
|
24
|
+
*/
|
|
25
|
+
function parseFrontmatter(content) {
|
|
26
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
|
|
27
|
+
if (!match) return { metadata: {}, body: content }
|
|
28
|
+
|
|
29
|
+
const metadata = {}
|
|
30
|
+
for (const line of match[1].split('\n')) {
|
|
31
|
+
const [key, ...rest] = line.split(':')
|
|
32
|
+
if (key && rest.length) {
|
|
33
|
+
metadata[key.trim()] = rest.join(':').trim()
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { metadata, body: match[2].trimStart() }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Write a skill to the local ~/.claude/skills/ directory.
|
|
41
|
+
* Shared by deploy-skill and skills/fetch endpoints.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} name - Skill slug (e.g. "execution-report")
|
|
44
|
+
* @param {object} opts
|
|
45
|
+
* @param {string} opts.content - Skill body (markdown without frontmatter)
|
|
46
|
+
* @param {string} [opts.description] - Skill description for frontmatter
|
|
47
|
+
* @param {string} [opts.display_name] - Display name for frontmatter
|
|
48
|
+
* @param {Array<{filename: string, content: string}>} [opts.references] - Reference files
|
|
49
|
+
* @returns {Promise<{path: string, references_count: number}>}
|
|
50
|
+
*/
|
|
51
|
+
async function writeSkillToLocal(name, { content, description, display_name, references = [] }) {
|
|
52
|
+
const skillDir = path.join(os.homedir(), '.claude', 'skills', name)
|
|
53
|
+
const referencesDir = path.join(skillDir, 'references')
|
|
54
|
+
|
|
55
|
+
await fs.mkdir(skillDir, { recursive: true })
|
|
56
|
+
await fs.mkdir(referencesDir, { recursive: true })
|
|
57
|
+
|
|
58
|
+
// Build frontmatter with all available metadata
|
|
59
|
+
const frontmatterLines = [
|
|
60
|
+
`name: ${name}`,
|
|
61
|
+
display_name ? `display_name: ${display_name}` : null,
|
|
62
|
+
`description: ${description || ''}`,
|
|
63
|
+
].filter(Boolean).join('\n')
|
|
64
|
+
|
|
65
|
+
await fs.writeFile(
|
|
66
|
+
path.join(skillDir, 'SKILL.md'),
|
|
67
|
+
`---\n${frontmatterLines}\n---\n\n${content}`,
|
|
68
|
+
'utf-8'
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
// Write reference files
|
|
72
|
+
for (const ref of references) {
|
|
73
|
+
if (ref.filename && ref.content) {
|
|
74
|
+
const safeFilename = path.basename(ref.filename)
|
|
75
|
+
await fs.writeFile(path.join(referencesDir, safeFilename), ref.content, 'utf-8')
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { path: skillDir, references_count: references.length }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Read a local skill and push it to HQ.
|
|
84
|
+
* Reusable by workflow push to auto-sync pipeline skills.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} name - Skill slug (e.g. "execution-report")
|
|
87
|
+
* @returns {Promise<object>} HQ response
|
|
88
|
+
* @throws {Error} If skill not found locally or HQ rejects
|
|
89
|
+
*/
|
|
90
|
+
async function pushSkillToHQ(name) {
|
|
91
|
+
const homeDir = os.homedir()
|
|
92
|
+
const skillDir = path.join(homeDir, '.claude', 'skills', name)
|
|
93
|
+
const skillMdPath = path.join(skillDir, 'SKILL.md')
|
|
94
|
+
|
|
95
|
+
const rawContent = await fs.readFile(skillMdPath, 'utf-8')
|
|
96
|
+
const { metadata, body } = parseFrontmatter(rawContent)
|
|
97
|
+
|
|
98
|
+
// Read references
|
|
99
|
+
const referencesDir = path.join(skillDir, 'references')
|
|
100
|
+
const references = []
|
|
101
|
+
try {
|
|
102
|
+
const refEntries = await fs.readdir(referencesDir)
|
|
103
|
+
for (const filename of refEntries) {
|
|
104
|
+
const refContent = await fs.readFile(path.join(referencesDir, filename), 'utf-8')
|
|
105
|
+
references.push({ filename, content: refContent })
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
// No references directory
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return api.request('/skills', {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
body: JSON.stringify({
|
|
114
|
+
name: metadata.name || name,
|
|
115
|
+
display_name: metadata.display_name || metadata.name || name,
|
|
116
|
+
description: metadata.description || '',
|
|
117
|
+
content: body,
|
|
118
|
+
references,
|
|
119
|
+
}),
|
|
120
|
+
})
|
|
121
|
+
}
|
|
14
122
|
|
|
15
123
|
/**
|
|
16
124
|
* Register skill routes as Fastify plugin
|
|
@@ -66,71 +174,105 @@ async function skillRoutes(fastify) {
|
|
|
66
174
|
}
|
|
67
175
|
})
|
|
68
176
|
|
|
69
|
-
//
|
|
70
|
-
fastify.post('/api/
|
|
177
|
+
// Push local skill to HQ
|
|
178
|
+
fastify.post('/api/skills/push/:name', async (request, reply) => {
|
|
71
179
|
if (!verifyToken(request)) {
|
|
72
180
|
reply.code(401)
|
|
73
181
|
return { success: false, error: 'Unauthorized' }
|
|
74
182
|
}
|
|
75
183
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (!name || !content) {
|
|
184
|
+
if (!isHqConfigured()) {
|
|
79
185
|
reply.code(400)
|
|
80
|
-
return { success: false, error: '
|
|
186
|
+
return { success: false, error: 'HQ not configured' }
|
|
81
187
|
}
|
|
82
188
|
|
|
83
|
-
|
|
84
|
-
|
|
189
|
+
const { name } = request.params
|
|
190
|
+
|
|
191
|
+
if (!name || !/^[a-z0-9-]+$/.test(name)) {
|
|
85
192
|
reply.code(400)
|
|
86
|
-
return { success: false, error: '
|
|
193
|
+
return { success: false, error: 'Invalid skill name' }
|
|
87
194
|
}
|
|
88
195
|
|
|
89
|
-
console.log(`[
|
|
196
|
+
console.log(`[Skills] Pushing skill to HQ: ${name}`)
|
|
90
197
|
|
|
91
198
|
try {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
// Write SKILL.md with frontmatter
|
|
102
|
-
const skillContent = `---
|
|
103
|
-
name: ${name}
|
|
104
|
-
description: Deployed from HQ
|
|
105
|
-
---
|
|
106
|
-
|
|
107
|
-
${content}`
|
|
108
|
-
await fs.writeFile(path.join(skillDir, 'SKILL.md'), skillContent, 'utf-8')
|
|
109
|
-
|
|
110
|
-
// Write reference files
|
|
111
|
-
for (const ref of references) {
|
|
112
|
-
if (ref.filename && ref.content) {
|
|
113
|
-
// Sanitize filename to prevent directory traversal
|
|
114
|
-
const safeFilename = path.basename(ref.filename)
|
|
115
|
-
await fs.writeFile(path.join(referencesDir, safeFilename), ref.content, 'utf-8')
|
|
116
|
-
}
|
|
117
|
-
}
|
|
199
|
+
const result = await pushSkillToHQ(name)
|
|
200
|
+
console.log(`[Skills] Skill pushed to HQ: ${name}`)
|
|
201
|
+
return { success: true, ...result }
|
|
202
|
+
} catch (error) {
|
|
203
|
+
console.error(`[Skills] Failed to push skill: ${error.message}`)
|
|
204
|
+
reply.code(error.statusCode || 500)
|
|
205
|
+
return { success: false, error: error.message }
|
|
206
|
+
}
|
|
207
|
+
})
|
|
118
208
|
|
|
119
|
-
|
|
209
|
+
// Fetch skill from HQ and deploy locally
|
|
210
|
+
fastify.post('/api/skills/fetch/:name', async (request, reply) => {
|
|
211
|
+
if (!verifyToken(request)) {
|
|
212
|
+
reply.code(401)
|
|
213
|
+
return { success: false, error: 'Unauthorized' }
|
|
214
|
+
}
|
|
120
215
|
|
|
216
|
+
if (!isHqConfigured()) {
|
|
217
|
+
reply.code(400)
|
|
218
|
+
return { success: false, error: 'HQ not configured' }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const { name } = request.params
|
|
222
|
+
|
|
223
|
+
if (!name || !/^[a-z0-9-]+$/.test(name)) {
|
|
224
|
+
reply.code(400)
|
|
225
|
+
return { success: false, error: 'Invalid skill name' }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
console.log(`[Skills] Fetching skill from HQ: ${name}`)
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
// Fetch from HQ
|
|
232
|
+
const skill = await api.request(`/skills/${encodeURIComponent(name)}`)
|
|
233
|
+
|
|
234
|
+
// Write to local filesystem using shared helper
|
|
235
|
+
const result = await writeSkillToLocal(name, {
|
|
236
|
+
content: skill.content,
|
|
237
|
+
description: skill.description,
|
|
238
|
+
display_name: skill.display_name,
|
|
239
|
+
references: skill.references || [],
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
console.log(`[Skills] Skill fetched and deployed: ${name}`)
|
|
121
243
|
return {
|
|
122
244
|
success: true,
|
|
123
|
-
message: `Skill "${name}"
|
|
124
|
-
|
|
125
|
-
references_count: references.length,
|
|
245
|
+
message: `Skill "${name}" fetched from HQ`,
|
|
246
|
+
...result,
|
|
126
247
|
}
|
|
127
248
|
} catch (error) {
|
|
128
|
-
console.error(`[
|
|
129
|
-
reply.code(500)
|
|
130
|
-
return {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
249
|
+
console.error(`[Skills] Failed to fetch skill: ${error.message}`)
|
|
250
|
+
reply.code(error.statusCode || 500)
|
|
251
|
+
return { success: false, error: error.message }
|
|
252
|
+
}
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
// List skills on HQ (remote)
|
|
256
|
+
fastify.get('/api/skills/remote', async (request, reply) => {
|
|
257
|
+
if (!verifyToken(request)) {
|
|
258
|
+
reply.code(401)
|
|
259
|
+
return { success: false, error: 'Unauthorized' }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (!isHqConfigured()) {
|
|
263
|
+
reply.code(400)
|
|
264
|
+
return { success: false, error: 'HQ not configured' }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
console.log('[Skills] Listing remote skills from HQ')
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
const result = await api.request('/skills')
|
|
271
|
+
return result
|
|
272
|
+
} catch (error) {
|
|
273
|
+
console.error(`[Skills] Failed to list remote skills: ${error.message}`)
|
|
274
|
+
reply.code(error.statusCode || 500)
|
|
275
|
+
return { success: false, error: error.message }
|
|
134
276
|
}
|
|
135
277
|
})
|
|
136
278
|
|
|
@@ -185,4 +327,4 @@ ${content}`
|
|
|
185
327
|
})
|
|
186
328
|
}
|
|
187
329
|
|
|
188
|
-
module.exports = { skillRoutes }
|
|
330
|
+
module.exports = { skillRoutes, writeSkillToLocal, pushSkillToHQ }
|
package/routes/terminal.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
const { exec, spawn } = require('child_process')
|
|
18
18
|
const { promisify } = require('util')
|
|
19
|
+
const path = require('path')
|
|
19
20
|
const net = require('net')
|
|
20
21
|
const os = require('os')
|
|
21
22
|
const execAsync = promisify(exec)
|
|
@@ -122,9 +123,21 @@ async function startTtydForSession(session) {
|
|
|
122
123
|
}
|
|
123
124
|
}
|
|
124
125
|
|
|
126
|
+
// Reload tmux.conf to ensure mouse/scroll settings are active
|
|
127
|
+
const tmuxConf = path.join(homeDir, '.tmux.conf')
|
|
128
|
+
try {
|
|
129
|
+
await execAsync(`tmux source-file "${tmuxConf}"`, {
|
|
130
|
+
timeout: 5000,
|
|
131
|
+
env: { ...process.env, HOME: homeDir },
|
|
132
|
+
})
|
|
133
|
+
} catch {
|
|
134
|
+
// Ignore: .tmux.conf may not exist yet on first run
|
|
135
|
+
}
|
|
136
|
+
|
|
125
137
|
const port = await findAvailablePort()
|
|
126
138
|
|
|
127
139
|
const proc = spawn('ttyd', [
|
|
140
|
+
'-W',
|
|
128
141
|
'-p', port.toString(),
|
|
129
142
|
'-t', 'fontSize=14',
|
|
130
143
|
'-t', 'theme={"background": "#1a1a2e", "foreground": "#eee"}',
|
package/routes/workflows.js
CHANGED
|
@@ -2,22 +2,32 @@
|
|
|
2
2
|
* Workflow management endpoints
|
|
3
3
|
*
|
|
4
4
|
* Endpoints:
|
|
5
|
-
* - GET /api/workflows
|
|
6
|
-
* - POST /api/workflows
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
12
|
-
* -
|
|
5
|
+
* - GET /api/workflows - List all workflows with status
|
|
6
|
+
* - POST /api/workflows - Receive workflows from HQ (upsert/additive)
|
|
7
|
+
* - POST /api/workflows/push/:name - Push local workflow to HQ
|
|
8
|
+
* - POST /api/workflows/fetch/:name - Fetch workflow from HQ and deploy locally
|
|
9
|
+
* - GET /api/workflows/remote - List workflows on HQ
|
|
10
|
+
* - PUT /api/workflows/:id/schedule - Update a workflow schedule (cron_expression, is_active)
|
|
11
|
+
* - DELETE /api/workflows/:id - Remove a workflow
|
|
12
|
+
* - POST /api/workflows/trigger - Manual trigger for a workflow
|
|
13
|
+
* - GET /api/executions - List execution history
|
|
14
|
+
* - GET /api/executions/:id - Get single execution details
|
|
15
|
+
* - GET /api/executions/:id/log - Get execution log file content
|
|
13
16
|
* - POST /api/executions/:id/outcome - Update execution outcome (no auth, local)
|
|
14
17
|
*/
|
|
15
18
|
|
|
19
|
+
const fs = require('fs').promises
|
|
20
|
+
const path = require('path')
|
|
21
|
+
const os = require('os')
|
|
22
|
+
|
|
16
23
|
const { verifyToken } = require('../lib/auth')
|
|
17
24
|
const workflowRunner = require('../workflow-runner')
|
|
18
25
|
const workflowStore = require('../workflow-store')
|
|
19
26
|
const executionStore = require('../execution-store')
|
|
20
27
|
const logManager = require('../lib/log-manager')
|
|
28
|
+
const api = require('../api')
|
|
29
|
+
const { isHqConfigured } = require('../config')
|
|
30
|
+
const { writeSkillToLocal, pushSkillToHQ } = require('./skills')
|
|
21
31
|
|
|
22
32
|
/**
|
|
23
33
|
* Register workflow routes as Fastify plugin
|
|
@@ -93,8 +103,179 @@ async function workflowRoutes(fastify) {
|
|
|
93
103
|
return { workflows: result }
|
|
94
104
|
})
|
|
95
105
|
|
|
96
|
-
//
|
|
97
|
-
fastify.
|
|
106
|
+
// Push local workflow to HQ
|
|
107
|
+
fastify.post('/api/workflows/push/:name', async (request, reply) => {
|
|
108
|
+
if (!verifyToken(request)) {
|
|
109
|
+
reply.code(401)
|
|
110
|
+
return { success: false, error: 'Unauthorized' }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!isHqConfigured()) {
|
|
114
|
+
reply.code(400)
|
|
115
|
+
return { success: false, error: 'HQ not configured' }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const { name } = request.params
|
|
119
|
+
|
|
120
|
+
if (!name || !/^[a-z0-9-]+$/.test(name)) {
|
|
121
|
+
reply.code(400)
|
|
122
|
+
return { success: false, error: 'Invalid workflow name' }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
console.log(`[Workflows] Pushing workflow to HQ: ${name}`)
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const workflow = await workflowStore.findByName(name)
|
|
129
|
+
if (!workflow) {
|
|
130
|
+
reply.code(404)
|
|
131
|
+
return { success: false, error: `Local workflow not found: ${name}` }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const pipelineSkillNames = workflow.pipeline_skill_names || []
|
|
135
|
+
|
|
136
|
+
// Auto-push pipeline skills to HQ (ensures they exist before workflow push)
|
|
137
|
+
const skillsPushed = []
|
|
138
|
+
const skillsFailed = []
|
|
139
|
+
for (const skillName of pipelineSkillNames) {
|
|
140
|
+
try {
|
|
141
|
+
await pushSkillToHQ(skillName)
|
|
142
|
+
skillsPushed.push(skillName)
|
|
143
|
+
console.log(`[Workflows] Auto-pushed pipeline skill: ${skillName}`)
|
|
144
|
+
} catch (err) {
|
|
145
|
+
console.log(`[Workflows] Skill "${skillName}" not auto-pushed: ${err.message}`)
|
|
146
|
+
skillsFailed.push(skillName)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (skillsFailed.length > 0) {
|
|
151
|
+
reply.code(400)
|
|
152
|
+
return {
|
|
153
|
+
success: false,
|
|
154
|
+
error: `Pipeline skills not found locally: ${skillsFailed.join(', ')}. Deploy them first with: minion-cli skill fetch <name>`,
|
|
155
|
+
skills_pushed: skillsPushed,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const result = await api.request('/workflows', {
|
|
160
|
+
method: 'POST',
|
|
161
|
+
body: JSON.stringify({
|
|
162
|
+
name: workflow.name,
|
|
163
|
+
pipeline_skill_names: pipelineSkillNames,
|
|
164
|
+
content: workflow.content || '',
|
|
165
|
+
}),
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
console.log(`[Workflows] Workflow pushed to HQ: ${name}`)
|
|
169
|
+
return { success: true, skills_pushed: skillsPushed, ...result }
|
|
170
|
+
} catch (error) {
|
|
171
|
+
console.error(`[Workflows] Failed to push workflow: ${error.message}`)
|
|
172
|
+
reply.code(error.statusCode || 500)
|
|
173
|
+
return { success: false, error: error.message }
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
// Fetch workflow from HQ and deploy locally
|
|
178
|
+
fastify.post('/api/workflows/fetch/:name', async (request, reply) => {
|
|
179
|
+
if (!verifyToken(request)) {
|
|
180
|
+
reply.code(401)
|
|
181
|
+
return { success: false, error: 'Unauthorized' }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!isHqConfigured()) {
|
|
185
|
+
reply.code(400)
|
|
186
|
+
return { success: false, error: 'HQ not configured' }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const { name } = request.params
|
|
190
|
+
|
|
191
|
+
if (!name || !/^[a-z0-9-]+$/.test(name)) {
|
|
192
|
+
reply.code(400)
|
|
193
|
+
return { success: false, error: 'Invalid workflow name' }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
console.log(`[Workflows] Fetching workflow from HQ: ${name}`)
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
// 1. Fetch workflow definition from HQ
|
|
200
|
+
const workflow = await api.request(`/workflows/${encodeURIComponent(name)}`)
|
|
201
|
+
|
|
202
|
+
// 2. Fetch pipeline skills that are not deployed locally
|
|
203
|
+
const fetchedSkills = []
|
|
204
|
+
const homeDir = os.homedir()
|
|
205
|
+
|
|
206
|
+
for (const skillName of workflow.pipeline_skill_names || []) {
|
|
207
|
+
const skillMdPath = path.join(homeDir, '.claude', 'skills', skillName, 'SKILL.md')
|
|
208
|
+
let exists = false
|
|
209
|
+
try {
|
|
210
|
+
await fs.access(skillMdPath)
|
|
211
|
+
exists = true
|
|
212
|
+
} catch {
|
|
213
|
+
// Skill not deployed locally
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (!exists) {
|
|
217
|
+
console.log(`[Workflows] Fetching pipeline skill: ${skillName}`)
|
|
218
|
+
const skill = await api.request(`/skills/${encodeURIComponent(skillName)}`)
|
|
219
|
+
await writeSkillToLocal(skillName, {
|
|
220
|
+
content: skill.content,
|
|
221
|
+
description: skill.description,
|
|
222
|
+
display_name: skill.display_name,
|
|
223
|
+
references: skill.references || [],
|
|
224
|
+
})
|
|
225
|
+
fetchedSkills.push(skillName)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// 3. Save workflow to local store (upsert by name)
|
|
230
|
+
const updatedWorkflows = await workflowStore.upsertByName({
|
|
231
|
+
name: workflow.name,
|
|
232
|
+
pipeline_skill_names: workflow.pipeline_skill_names,
|
|
233
|
+
content: workflow.content || '',
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
// 4. Reload cron jobs
|
|
237
|
+
workflowRunner.loadWorkflows(updatedWorkflows)
|
|
238
|
+
|
|
239
|
+
console.log(`[Workflows] Workflow fetched: ${name} (${fetchedSkills.length} skills fetched)`)
|
|
240
|
+
return {
|
|
241
|
+
success: true,
|
|
242
|
+
message: `Workflow "${name}" fetched from HQ`,
|
|
243
|
+
skills_fetched: fetchedSkills,
|
|
244
|
+
pipeline: workflow.pipeline_skill_names,
|
|
245
|
+
}
|
|
246
|
+
} catch (error) {
|
|
247
|
+
console.error(`[Workflows] Failed to fetch workflow: ${error.message}`)
|
|
248
|
+
reply.code(error.statusCode || 500)
|
|
249
|
+
return { success: false, error: error.message }
|
|
250
|
+
}
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
// List workflows on HQ (remote)
|
|
254
|
+
fastify.get('/api/workflows/remote', async (request, reply) => {
|
|
255
|
+
if (!verifyToken(request)) {
|
|
256
|
+
reply.code(401)
|
|
257
|
+
return { success: false, error: 'Unauthorized' }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!isHqConfigured()) {
|
|
261
|
+
reply.code(400)
|
|
262
|
+
return { success: false, error: 'HQ not configured' }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
console.log('[Workflows] Listing remote workflows from HQ')
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
const result = await api.request('/workflows')
|
|
269
|
+
return result
|
|
270
|
+
} catch (error) {
|
|
271
|
+
console.error(`[Workflows] Failed to list remote workflows: ${error.message}`)
|
|
272
|
+
reply.code(error.statusCode || 500)
|
|
273
|
+
return { success: false, error: error.message }
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
// Update a workflow schedule (cron_expression, is_active)
|
|
278
|
+
fastify.put('/api/workflows/:id/schedule', async (request, reply) => {
|
|
98
279
|
if (!verifyToken(request)) {
|
|
99
280
|
reply.code(401)
|
|
100
281
|
return { success: false, error: 'Unauthorized' }
|
|
@@ -103,7 +284,7 @@ async function workflowRoutes(fastify) {
|
|
|
103
284
|
const { id } = request.params
|
|
104
285
|
const updates = request.body || {}
|
|
105
286
|
|
|
106
|
-
console.log(`[Workflows] Updating workflow: ${id}`)
|
|
287
|
+
console.log(`[Workflows] Updating workflow schedule: ${id}`)
|
|
107
288
|
|
|
108
289
|
try {
|
|
109
290
|
const workflows = await workflowStore.load()
|
package/rules/minion.md
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# Minion Agent Environment
|
|
2
|
+
|
|
3
|
+
You are running on a Minion VPS managed by the @geekbeer/minion package.
|
|
4
|
+
A local Agent API server runs at `http://localhost:3001` and a CLI tool `minion-cli` is available.
|
|
5
|
+
|
|
6
|
+
## CLI Commands
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
minion-cli status # Get current status (online/offline/busy)
|
|
10
|
+
minion-cli health # Health check
|
|
11
|
+
minion-cli set-status <status> [task] # Set status and optional task description
|
|
12
|
+
minion-cli skill push <name> # Push local skill to HQ
|
|
13
|
+
minion-cli skill fetch <name> # Fetch skill from HQ to local
|
|
14
|
+
minion-cli skill list # List skills on HQ
|
|
15
|
+
minion-cli skill list --local # List local deployed skills
|
|
16
|
+
minion-cli --version # Show package version
|
|
17
|
+
# Service management (requires sudo):
|
|
18
|
+
# sudo minion-cli start | stop | restart
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Agent API Endpoints (http://localhost:3001)
|
|
22
|
+
|
|
23
|
+
Authentication: `Authorization: Bearer $API_TOKEN` header (except where noted).
|
|
24
|
+
|
|
25
|
+
### Health & Status (no auth required)
|
|
26
|
+
|
|
27
|
+
| Method | Endpoint | Description |
|
|
28
|
+
|--------|----------|-------------|
|
|
29
|
+
| GET | `/api/health` | Health check (`{status:'ok', timestamp}`) |
|
|
30
|
+
| GET | `/api/status` | Agent status, uptime, version, HQ connection |
|
|
31
|
+
| POST | `/api/status` | Update status. Body: `{status, current_task}` |
|
|
32
|
+
|
|
33
|
+
### Skills
|
|
34
|
+
|
|
35
|
+
| Method | Endpoint | Description |
|
|
36
|
+
|--------|----------|-------------|
|
|
37
|
+
| GET | `/api/list-skills` | List local deployed skills |
|
|
38
|
+
| DELETE | `/api/skills/:name` | Delete local skill |
|
|
39
|
+
| POST | `/api/skills/push/:name` | Push local skill to HQ |
|
|
40
|
+
| POST | `/api/skills/fetch/:name` | Fetch skill from HQ and deploy locally |
|
|
41
|
+
| GET | `/api/skills/remote` | List skills on HQ |
|
|
42
|
+
|
|
43
|
+
### Workflows
|
|
44
|
+
|
|
45
|
+
| Method | Endpoint | Description |
|
|
46
|
+
|--------|----------|-------------|
|
|
47
|
+
| GET | `/api/workflows` | List all workflows with next_run |
|
|
48
|
+
| POST | `/api/workflows` | Receive/upsert workflows from HQ |
|
|
49
|
+
| POST | `/api/workflows/push/:name` | Push local workflow to HQ |
|
|
50
|
+
| POST | `/api/workflows/fetch/:name` | Fetch workflow from HQ and deploy locally (+ pipeline skills) |
|
|
51
|
+
| GET | `/api/workflows/remote` | List workflows on HQ |
|
|
52
|
+
| PUT | `/api/workflows/:id/schedule` | Update workflow schedule (cron_expression, is_active) |
|
|
53
|
+
| DELETE | `/api/workflows/:id` | Remove workflow |
|
|
54
|
+
| POST | `/api/workflows/trigger` | Manual trigger. Body: `{workflow_id}` |
|
|
55
|
+
|
|
56
|
+
### Executions
|
|
57
|
+
|
|
58
|
+
| Method | Endpoint | Description |
|
|
59
|
+
|--------|----------|-------------|
|
|
60
|
+
| GET | `/api/executions` | List execution history (?limit=50) |
|
|
61
|
+
| GET | `/api/executions/:id` | Get single execution |
|
|
62
|
+
| GET | `/api/executions/:id/log` | Get execution log content |
|
|
63
|
+
| POST | `/api/executions/:id/outcome` | Report outcome (no auth). Body: `{outcome}` |
|
|
64
|
+
|
|
65
|
+
Outcome values: `success`, `failure`, `partial`
|
|
66
|
+
|
|
67
|
+
### Terminal / tmux
|
|
68
|
+
|
|
69
|
+
| Method | Endpoint | Description |
|
|
70
|
+
|--------|----------|-------------|
|
|
71
|
+
| GET | `/api/terminal/sessions` | List tmux sessions |
|
|
72
|
+
| POST | `/api/terminal/send` | Send keys. Body: `{session, keys}` |
|
|
73
|
+
| POST | `/api/terminal/create` | Create session. Body: `{name, command?}` |
|
|
74
|
+
| POST | `/api/terminal/kill` | Kill session. Body: `{session}` |
|
|
75
|
+
| GET | `/api/terminal/capture` | Capture pane content. Query: `?session=` |
|
|
76
|
+
| GET | `/api/terminal/ttyd/status` | ttyd process status |
|
|
77
|
+
| POST | `/api/terminal/ttyd/start` | Start ttyd. Body: `{session}` |
|
|
78
|
+
| POST | `/api/terminal/ttyd/stop` | Stop ttyd. Body: `{session}` |
|
|
79
|
+
|
|
80
|
+
### Commands
|
|
81
|
+
|
|
82
|
+
| Method | Endpoint | Description |
|
|
83
|
+
|--------|----------|-------------|
|
|
84
|
+
| GET | `/api/commands` | List available whitelisted commands |
|
|
85
|
+
| POST | `/api/command` | Execute command. Body: `{command}` |
|
|
86
|
+
|
|
87
|
+
Available commands: `restart-agent`, `update-agent`, `restart-display`, `status-services`
|
|
88
|
+
|
|
89
|
+
## Environment Variables
|
|
90
|
+
|
|
91
|
+
| Variable | Description |
|
|
92
|
+
|----------|-------------|
|
|
93
|
+
| `HQ_URL` | HQ server URL (empty = standalone mode) |
|
|
94
|
+
| `API_TOKEN` | Bearer token for HQ and local API auth |
|
|
95
|
+
| `MINION_ID` | UUID assigned by HQ |
|
|
96
|
+
| `AGENT_PORT` | Agent HTTP port (default: 3001) |
|
|
97
|
+
| `MINION_USER` | System user running the agent |
|
|
98
|
+
|
|
99
|
+
## Skills Directory Structure
|
|
100
|
+
|
|
101
|
+
Skills are stored in `~/.claude/skills/<name>/`:
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
~/.claude/skills/<name>/
|
|
105
|
+
SKILL.md # Skill definition (YAML frontmatter + markdown body)
|
|
106
|
+
references/ # Optional supporting files
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
SKILL.md format:
|
|
110
|
+
```markdown
|
|
111
|
+
---
|
|
112
|
+
name: my-skill
|
|
113
|
+
description: What this skill does
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
Skill instructions here...
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## HQ Connection
|
|
120
|
+
|
|
121
|
+
When `HQ_URL` and `API_TOKEN` are set, the agent sends heartbeats and syncs with HQ.
|
|
122
|
+
Use `minion-cli skill push/fetch` to sync skills between local and HQ.
|
|
123
|
+
Use the workflow API endpoints (`/api/workflows/push/:name`, `/api/workflows/fetch/:name`) to sync workflows.
|
|
124
|
+
When not configured, the agent runs in standalone mode and HQ-dependent features return errors.
|
|
125
|
+
|
|
126
|
+
## Workflow Structure
|
|
127
|
+
|
|
128
|
+
Workflows are stored locally in `workflows.json`. Each workflow object:
|
|
129
|
+
|
|
130
|
+
```json
|
|
131
|
+
{
|
|
132
|
+
"id": "uuid",
|
|
133
|
+
"name": "my-workflow",
|
|
134
|
+
"pipeline_skill_names": ["skill-1", "skill-2", "execution-report"],
|
|
135
|
+
"content": "Markdown description of the workflow",
|
|
136
|
+
"cron_expression": "0 9 * * 1-5",
|
|
137
|
+
"is_active": true,
|
|
138
|
+
"last_run": "2026-02-10T00:00:00.000Z"
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
| Field | Type | Description |
|
|
143
|
+
|-------|------|-------------|
|
|
144
|
+
| `id` | string | UUID (auto-generated) |
|
|
145
|
+
| `name` | string | Slug identifier (`/^[a-z0-9-]+$/`) |
|
|
146
|
+
| `pipeline_skill_names` | string[] | Ordered skill names to execute |
|
|
147
|
+
| `content` | string | Markdown body describing the workflow |
|
|
148
|
+
| `cron_expression` | string | Cron schedule (empty = manual only) |
|
|
149
|
+
| `is_active` | boolean | Whether cron scheduling is enabled |
|
|
150
|
+
| `last_run` | string\|null | ISO timestamp of last execution |
|
|
151
|
+
|
|
152
|
+
### Creating a workflow locally
|
|
153
|
+
|
|
154
|
+
Use `POST /api/workflows` to create/update workflows:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
curl -X POST http://localhost:3001/api/workflows \
|
|
158
|
+
-H "Authorization: Bearer $API_TOKEN" \
|
|
159
|
+
-H "Content-Type: application/json" \
|
|
160
|
+
-d '{
|
|
161
|
+
"workflows": [{
|
|
162
|
+
"id": "'$(uuidgen)'",
|
|
163
|
+
"name": "my-workflow",
|
|
164
|
+
"pipeline_skill_names": ["my-skill", "execution-report"],
|
|
165
|
+
"content": "Description of what this workflow does",
|
|
166
|
+
"cron_expression": "",
|
|
167
|
+
"is_active": false
|
|
168
|
+
}]
|
|
169
|
+
}'
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Then activate with a schedule:
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
curl -X PUT http://localhost:3001/api/workflows/<id>/schedule \
|
|
176
|
+
-H "Authorization: Bearer $API_TOKEN" \
|
|
177
|
+
-H "Content-Type: application/json" \
|
|
178
|
+
-d '{"cron_expression": "0 9 * * 1-5", "is_active": true}'
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Syncing workflows with HQ
|
|
182
|
+
|
|
183
|
+
- `POST /api/workflows/push/:name` — Push local workflow to HQ. Pipeline skills are auto-pushed first.
|
|
184
|
+
- `POST /api/workflows/fetch/:name` — Fetch workflow from HQ. Missing pipeline skills are auto-fetched.
|
|
185
|
+
- `GET /api/workflows/remote` — List workflows available on HQ.
|
|
186
|
+
|
|
187
|
+
Pipeline skills must be deployed locally (in `~/.claude/skills/`) before pushing a workflow.
|
|
188
|
+
|
|
189
|
+
## Workflow Execution
|
|
190
|
+
|
|
191
|
+
Workflows run on cron schedules. Each execution:
|
|
192
|
+
1. Creates a tmux session
|
|
193
|
+
2. Runs skills in pipeline order via `claude --dangerously-skip-permissions`
|
|
194
|
+
3. Appends `execution-report` skill to report outcome
|
|
195
|
+
4. Environment variables `MINION_EXECUTION_ID`, `MINION_WORKFLOW_ID`, `MINION_WORKFLOW_NAME` are available during execution
|
package/server.js
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
* See routes/index.js for API documentation.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
const fs = require('fs')
|
|
9
|
+
const path = require('path')
|
|
10
|
+
const os = require('os')
|
|
11
|
+
|
|
8
12
|
const fastify = require('fastify')({ logger: true })
|
|
9
13
|
const { config, validate, isHqConfigured } = require('./config')
|
|
10
14
|
const workflowRunner = require('./workflow-runner')
|
|
@@ -42,9 +46,96 @@ async function shutdown(signal) {
|
|
|
42
46
|
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
|
43
47
|
process.on('SIGINT', () => shutdown('SIGINT'))
|
|
44
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Sync bundled permissions into ~/.claude/settings.json.
|
|
51
|
+
* Merges package-defined allow/deny into the existing settings without
|
|
52
|
+
* removing user-added entries or non-permission keys (e.g. mcpServers).
|
|
53
|
+
*/
|
|
54
|
+
function syncPermissions() {
|
|
55
|
+
const bundledPath = path.join(__dirname, 'settings', 'permissions.json')
|
|
56
|
+
const settingsDir = path.join(os.homedir(), '.claude')
|
|
57
|
+
const settingsPath = path.join(settingsDir, 'settings.json')
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
if (!fs.existsSync(bundledPath)) return
|
|
61
|
+
|
|
62
|
+
const bundled = JSON.parse(fs.readFileSync(bundledPath, 'utf-8'))
|
|
63
|
+
|
|
64
|
+
// Read existing settings or start fresh
|
|
65
|
+
let settings = {}
|
|
66
|
+
if (fs.existsSync(settingsPath)) {
|
|
67
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Replace permissions section with bundled values
|
|
71
|
+
settings.permissions = {
|
|
72
|
+
allow: bundled.allow || [],
|
|
73
|
+
deny: bundled.deny || [],
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
fs.mkdirSync(settingsDir, { recursive: true })
|
|
77
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8')
|
|
78
|
+
console.log(`[Permissions] Synced: allow=${bundled.allow.length}, deny=${bundled.deny.length}`)
|
|
79
|
+
} catch (err) {
|
|
80
|
+
console.error(`[Permissions] Failed to sync permissions: ${err.message}`)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Sync bundled tmux.conf to ~/.tmux.conf.
|
|
86
|
+
* Enables mouse-driven scrollback (copy-mode) for the WebSocket terminal.
|
|
87
|
+
*/
|
|
88
|
+
function syncTmuxConfig() {
|
|
89
|
+
const bundledPath = path.join(__dirname, 'settings', 'tmux.conf')
|
|
90
|
+
const destPath = path.join(os.homedir(), '.tmux.conf')
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
if (!fs.existsSync(bundledPath)) return
|
|
94
|
+
|
|
95
|
+
fs.copyFileSync(bundledPath, destPath)
|
|
96
|
+
console.log('[Tmux] Synced tmux.conf')
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error(`[Tmux] Failed to sync tmux.conf: ${err.message}`)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Sync bundled rules from the package to ~/.claude/rules/.
|
|
104
|
+
* Runs on every server start to ensure rules stay up-to-date after package updates.
|
|
105
|
+
*/
|
|
106
|
+
function syncBundledRules() {
|
|
107
|
+
const bundledRulesDir = path.join(__dirname, 'rules')
|
|
108
|
+
const targetRulesDir = path.join(os.homedir(), '.claude', 'rules')
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
if (!fs.existsSync(bundledRulesDir)) return
|
|
112
|
+
|
|
113
|
+
fs.mkdirSync(targetRulesDir, { recursive: true })
|
|
114
|
+
|
|
115
|
+
for (const file of fs.readdirSync(bundledRulesDir)) {
|
|
116
|
+
if (!file.endsWith('.md')) continue
|
|
117
|
+
const src = path.join(bundledRulesDir, file)
|
|
118
|
+
const dest = path.join(targetRulesDir, file)
|
|
119
|
+
fs.copyFileSync(src, dest)
|
|
120
|
+
console.log(`[Rules] Synced: ${file}`)
|
|
121
|
+
}
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.error(`[Rules] Failed to sync bundled rules: ${err.message}`)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
45
127
|
// Start server
|
|
46
128
|
async function start() {
|
|
47
129
|
try {
|
|
130
|
+
// Sync bundled rules to ~/.claude/rules/ (keeps rules fresh after package updates)
|
|
131
|
+
syncBundledRules()
|
|
132
|
+
|
|
133
|
+
// Sync bundled permissions to ~/.claude/settings.json (broad allow + deny-list)
|
|
134
|
+
syncPermissions()
|
|
135
|
+
|
|
136
|
+
// Sync tmux.conf for mouse scroll support in WebSocket terminal
|
|
137
|
+
syncTmuxConfig()
|
|
138
|
+
|
|
48
139
|
// Register all routes
|
|
49
140
|
await registerRoutes(fastify)
|
|
50
141
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"allow": [
|
|
3
|
+
"Bash",
|
|
4
|
+
"Read",
|
|
5
|
+
"Write",
|
|
6
|
+
"Edit",
|
|
7
|
+
"mcp__playwright__*"
|
|
8
|
+
],
|
|
9
|
+
"deny": [
|
|
10
|
+
"Bash(sudo *)",
|
|
11
|
+
"Bash(git push --force*)",
|
|
12
|
+
"Bash(git push -f *)",
|
|
13
|
+
"Bash(git reset --hard*)",
|
|
14
|
+
"Bash(git clean -f*)",
|
|
15
|
+
"Bash(dd *)"
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Minion tmux configuration
|
|
2
|
+
# Enables mouse scroll → copy-mode for WebSocket terminal scrollback
|
|
3
|
+
|
|
4
|
+
# Enable mouse support (click, drag, scroll)
|
|
5
|
+
set -g mouse on
|
|
6
|
+
|
|
7
|
+
# Scrollback buffer size (lines)
|
|
8
|
+
set -g history-limit 10000
|
|
9
|
+
|
|
10
|
+
# Mouse wheel up: enter copy-mode if not already in it,
|
|
11
|
+
# otherwise forward the event (for nested mouse-aware apps)
|
|
12
|
+
bind -n WheelUpPane if-shell -F -t = "#{mouse_any_flag}" "send-keys -M" "if -Ft= '#{pane_in_mode}' 'send-keys -M' 'copy-mode -e'"
|
|
13
|
+
|
|
14
|
+
# Scroll 1 line per wheel tick (default is 5)
|
|
15
|
+
bind -T copy-mode WheelUpPane send-keys -X -N 1 scroll-up
|
|
16
|
+
bind -T copy-mode WheelDownPane send-keys -X -N 1 scroll-down
|
package/workflow-store.js
CHANGED
|
@@ -68,4 +68,48 @@ async function updateLastRun(workflowId) {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Find a workflow by name
|
|
73
|
+
* @param {string} name - Workflow name (slug)
|
|
74
|
+
* @returns {Promise<object|null>} Workflow object or null
|
|
75
|
+
*/
|
|
76
|
+
async function findByName(name) {
|
|
77
|
+
const workflows = await load()
|
|
78
|
+
return workflows.find(w => w.name === name) || null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Upsert a workflow by name.
|
|
83
|
+
* If exists: updates definition only (preserves schedule/local state).
|
|
84
|
+
* If new: creates with inactive schedule.
|
|
85
|
+
* @param {object} workflowData - { name, pipeline_skill_names, content }
|
|
86
|
+
* @returns {Promise<Array>} Updated workflows array
|
|
87
|
+
*/
|
|
88
|
+
async function upsertByName(workflowData) {
|
|
89
|
+
const crypto = require('crypto')
|
|
90
|
+
const workflows = await load()
|
|
91
|
+
const index = workflows.findIndex(w => w.name === workflowData.name)
|
|
92
|
+
|
|
93
|
+
if (index >= 0) {
|
|
94
|
+
// Update definition only (preserve schedule/local state)
|
|
95
|
+
workflows[index].pipeline_skill_names = workflowData.pipeline_skill_names
|
|
96
|
+
if (workflowData.content !== undefined) {
|
|
97
|
+
workflows[index].content = workflowData.content
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
workflows.push({
|
|
101
|
+
id: crypto.randomUUID(),
|
|
102
|
+
name: workflowData.name,
|
|
103
|
+
pipeline_skill_names: workflowData.pipeline_skill_names,
|
|
104
|
+
content: workflowData.content || '',
|
|
105
|
+
cron_expression: '',
|
|
106
|
+
is_active: false,
|
|
107
|
+
last_run: null,
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
await save(workflows)
|
|
112
|
+
return workflows
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = { load, save, updateLastRun, findByName, upsertByName }
|