@geekbeer/minion 1.6.1 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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()