@geekbeer/minion 1.6.1 → 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 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
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.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
  ],
@@ -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 - List deployed skills
6
- * - POST /api/deploy-skill - Deploy a skill
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
- // Deploy skill to local .claude/skills directory
70
- fastify.post('/api/deploy-skill', async (request, reply) => {
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
- const { name, content, references = [] } = request.body || {}
77
-
78
- if (!name || !content) {
184
+ if (!isHqConfigured()) {
79
185
  reply.code(400)
80
- return { success: false, error: 'name and content are required' }
186
+ return { success: false, error: 'HQ not configured' }
81
187
  }
82
188
 
83
- // Validate skill name (URL-safe slug)
84
- if (!/^[a-z0-9-]+$/.test(name)) {
189
+ const { name } = request.params
190
+
191
+ if (!name || !/^[a-z0-9-]+$/.test(name)) {
85
192
  reply.code(400)
86
- return { success: false, error: 'Skill name must be URL-safe (lowercase letters, numbers, and hyphens only)' }
193
+ return { success: false, error: 'Invalid skill name' }
87
194
  }
88
195
 
89
- console.log(`[Deploy] Deploying skill: ${name}`)
196
+ console.log(`[Skills] Pushing skill to HQ: ${name}`)
90
197
 
91
198
  try {
92
- // Create skill directory in ~/.claude/skills/
93
- const homeDir = os.homedir()
94
- const skillDir = path.join(homeDir, '.claude', 'skills', name)
95
- const referencesDir = path.join(skillDir, 'references')
96
-
97
- // Create directories
98
- await fs.mkdir(skillDir, { recursive: true })
99
- await fs.mkdir(referencesDir, { recursive: true })
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
- console.log(`[Deploy] Skill deployed successfully: ${name}`)
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}" deployed successfully`,
124
- path: skillDir,
125
- references_count: references.length,
245
+ message: `Skill "${name}" fetched from HQ`,
246
+ ...result,
126
247
  }
127
248
  } catch (error) {
128
- console.error(`[Deploy] Failed to deploy skill: ${error.message}`)
129
- reply.code(500)
130
- return {
131
- success: false,
132
- error: error.message,
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 }
@@ -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,6 +123,17 @@ 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', [
@@ -2,22 +2,32 @@
2
2
  * Workflow management endpoints
3
3
  *
4
4
  * Endpoints:
5
- * - GET /api/workflows - List all workflows with status
6
- * - POST /api/workflows - Receive workflows from HQ (upsert/additive)
7
- * - PUT /api/workflows/:id - Update a workflow (schedule, is_active)
8
- * - DELETE /api/workflows/:id - Remove a workflow
9
- * - POST /api/workflows/trigger - Manual trigger for a workflow
10
- * - GET /api/executions - List execution history
11
- * - GET /api/executions/:id - Get single execution details
12
- * - GET /api/executions/:id/log - Get execution log file content
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
- // Update a workflow (schedule, is_active)
97
- fastify.put('/api/workflows/:id', async (request, reply) => {
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()
@@ -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
- module.exports = { load, save, updateLastRun }
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 }