suparank 1.2.5 → 1.2.7

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/mcp-client.js DELETED
@@ -1,3693 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * Suparank MCP - Stdio Client
5
- *
6
- * Local MCP client that connects to the Suparank backend API.
7
- * Works with Claude Desktop and Cursor via stdio transport.
8
- *
9
- * Usage:
10
- * npx suparank
11
- * node mcp-client.js <project-slug> <api-key>
12
- *
13
- * Credentials:
14
- * Local credentials are loaded from ~/.suparank/credentials.json
15
- * These enable additional tools: image generation, CMS publishing, webhooks
16
- *
17
- * Note: API keys are more secure than JWT tokens for MCP connections
18
- * because they don't expire and can be revoked individually.
19
- */
20
-
21
- import { Server } from '@modelcontextprotocol/sdk/server/index.js'
22
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
23
- import {
24
- CallToolRequestSchema,
25
- ListToolsRequestSchema,
26
- InitializeRequestSchema
27
- } from '@modelcontextprotocol/sdk/types.js'
28
- import * as fs from 'fs'
29
- import { marked } from 'marked'
30
- import * as path from 'path'
31
- import * as os from 'os'
32
-
33
- // Parse command line arguments
34
- const projectSlug = process.argv[2]
35
- const apiKey = process.argv[3]
36
- const apiUrl = process.env.SUPARANK_API_URL || 'https://api.suparank.io'
37
-
38
- // ============================================================================
39
- // EXTERNAL API ENDPOINTS - Configurable via environment variables
40
- // ============================================================================
41
- const API_ENDPOINTS = {
42
- // Image generation providers
43
- fal: process.env.FAL_API_URL || 'https://fal.run/fal-ai/nano-banana-pro',
44
- gemini: process.env.GEMINI_API_URL || 'https://generativelanguage.googleapis.com/v1beta/models',
45
- wiro: process.env.WIRO_API_URL || 'https://api.wiro.ai/v1',
46
- wiroTaskDetail: process.env.WIRO_TASK_URL || 'https://api.wiro.ai/v1/Task/Detail'
47
- }
48
-
49
- if (!projectSlug) {
50
- console.error('Error: Project slug is required')
51
- console.error('Usage: node mcp-client.js <project-slug> <api-key>')
52
- console.error('Example: node mcp-client.js my-project sk_live_abc123...')
53
- process.exit(1)
54
- }
55
-
56
- if (!apiKey) {
57
- console.error('Error: API key is required')
58
- console.error('Usage: node mcp-client.js <project-slug> <api-key>')
59
- console.error('')
60
- console.error('To create an API key:')
61
- console.error('1. Sign in to the dashboard at http://localhost:3001')
62
- console.error('2. Go to Settings > API Keys')
63
- console.error('3. Click "Create API Key"')
64
- console.error('4. Copy the key (shown only once!)')
65
- process.exit(1)
66
- }
67
-
68
- // Validate API key format
69
- if (!apiKey.startsWith('sk_live_') && !apiKey.startsWith('sk_test_')) {
70
- console.error('Error: Invalid API key format')
71
- console.error('API keys must start with "sk_live_" or "sk_test_"')
72
- console.error('Example: sk_live_abc123...')
73
- process.exit(1)
74
- }
75
-
76
- // Log to stderr (stdout is used for MCP protocol)
77
- const log = (...args) => console.error('[suparank]', ...args)
78
-
79
- // Structured progress logging for user visibility
80
- const progress = (step, message) => console.error(`[suparank] ${step}: ${message}`)
81
-
82
- // Local credentials storage
83
- let localCredentials = null
84
-
85
- // Session state for orchestration - stores content between steps
86
- // Supports multiple articles for batch content creation workflows
87
- const sessionState = {
88
- currentWorkflow: null,
89
- stepResults: {},
90
-
91
- // Multi-article support: Array of saved articles
92
- articles: [],
93
-
94
- // Current working article (being edited/created)
95
- // These fields are for the article currently being worked on
96
- article: null,
97
- title: null,
98
- imageUrl: null, // Cover image
99
- inlineImages: [], // Array of inline image URLs
100
- keywords: null,
101
- metadata: null,
102
- metaTitle: null,
103
- metaDescription: null,
104
-
105
- contentFolder: null // Path to saved content folder
106
- }
107
-
108
- /**
109
- * Generate a unique article ID
110
- */
111
- function generateArticleId() {
112
- return `art_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`
113
- }
114
-
115
- /**
116
- * Get the path to the Suparank config directory (~/.suparank/)
117
- */
118
- function getSuparankDir() {
119
- return path.join(os.homedir(), '.suparank')
120
- }
121
-
122
- /**
123
- * Get the path to the session file (~/.suparank/session.json)
124
- */
125
- function getSessionFilePath() {
126
- return path.join(getSuparankDir(), 'session.json')
127
- }
128
-
129
- /**
130
- * Get the path to the content directory (~/.suparank/content/)
131
- */
132
- function getContentDir() {
133
- return path.join(getSuparankDir(), 'content')
134
- }
135
-
136
- /**
137
- * Ensure the Suparank config directory exists
138
- */
139
- function ensureSuparankDir() {
140
- const dir = getSuparankDir()
141
- if (!fs.existsSync(dir)) {
142
- fs.mkdirSync(dir, { recursive: true })
143
- log(`Created config directory: ${dir}`)
144
- }
145
- }
146
-
147
- /**
148
- * Ensure content directory exists
149
- */
150
- function ensureContentDir() {
151
- const dir = getContentDir()
152
- if (!fs.existsSync(dir)) {
153
- fs.mkdirSync(dir, { recursive: true })
154
- }
155
- return dir
156
- }
157
-
158
- /**
159
- * Get the path to the stats file (~/.suparank/stats.json)
160
- */
161
- function getStatsFile() {
162
- return path.join(getSuparankDir(), 'stats.json')
163
- }
164
-
165
- /**
166
- * Load usage stats
167
- */
168
- function loadStats() {
169
- try {
170
- const file = getStatsFile()
171
- if (fs.existsSync(file)) {
172
- return JSON.parse(fs.readFileSync(file, 'utf-8'))
173
- }
174
- } catch (e) {
175
- log(`Warning: Could not load stats: ${e.message}`)
176
- }
177
- return { tool_calls: 0, images_generated: 0, articles_created: 0, words_written: 0 }
178
- }
179
-
180
- /**
181
- * Save usage stats
182
- */
183
- function saveStats(stats) {
184
- try {
185
- ensureSuparankDir()
186
- fs.writeFileSync(getStatsFile(), JSON.stringify(stats, null, 2))
187
- } catch (e) {
188
- log(`Error saving stats: ${e.message}`)
189
- }
190
- }
191
-
192
- /**
193
- * Increment a stat counter
194
- */
195
- function incrementStat(key, amount = 1) {
196
- const stats = loadStats()
197
- stats[key] = (stats[key] || 0) + amount
198
- saveStats(stats)
199
- }
200
-
201
- /**
202
- * Generate a slug from title for folder naming
203
- */
204
- function slugify(text) {
205
- return text
206
- .toLowerCase()
207
- .replace(/[^a-z0-9]+/g, '-')
208
- .replace(/^-|-$/g, '')
209
- .substring(0, 50)
210
- }
211
-
212
- // ============================================================================
213
- // PATH SANITIZATION - Prevents path traversal attacks
214
- // ============================================================================
215
-
216
- /**
217
- * Sanitize and validate a path to prevent traversal attacks
218
- * @param {string} userPath - User-provided path segment
219
- * @param {string} allowedBase - Base directory that paths must stay within
220
- * @returns {string} - Resolved safe path
221
- * @throws {Error} - If path would escape the allowed base
222
- */
223
- function sanitizePath(userPath, allowedBase) {
224
- // Remove any null bytes (common attack vector)
225
- const cleanPath = userPath.replace(/\0/g, '')
226
-
227
- // Resolve to absolute path
228
- const resolved = path.resolve(allowedBase, cleanPath)
229
-
230
- // Ensure the resolved path starts with the allowed base
231
- // Adding path.sep ensures we don't match partial directory names
232
- const normalizedBase = path.normalize(allowedBase + path.sep)
233
- const normalizedResolved = path.normalize(resolved + path.sep)
234
-
235
- if (!normalizedResolved.startsWith(normalizedBase)) {
236
- throw new Error(`Path traversal detected: "${userPath}" would escape allowed directory`)
237
- }
238
-
239
- return resolved
240
- }
241
-
242
- /**
243
- * Get content folder path safely
244
- * @param {string} folderName - Folder name (article ID or title slug)
245
- * @returns {string} - Safe path to content folder
246
- */
247
- function getContentFolderSafe(folderName) {
248
- const contentDir = path.join(getSuparankDir(), 'content')
249
- return sanitizePath(folderName, contentDir)
250
- }
251
-
252
- /**
253
- * Atomic file write - prevents corruption on concurrent writes
254
- */
255
- function atomicWriteSync(filePath, data) {
256
- const tmpFile = filePath + '.tmp.' + process.pid
257
- try {
258
- fs.writeFileSync(tmpFile, data)
259
- fs.renameSync(tmpFile, filePath) // Atomic on POSIX
260
- } catch (error) {
261
- // Clean up temp file if rename failed
262
- try { fs.unlinkSync(tmpFile) } catch (e) { /* ignore */ }
263
- throw error
264
- }
265
- }
266
-
267
- /**
268
- * Fetch with timeout - prevents hanging requests
269
- */
270
- async function fetchWithTimeout(url, options = {}, timeoutMs = 30000) {
271
- const controller = new AbortController()
272
- const timeout = setTimeout(() => controller.abort(), timeoutMs)
273
-
274
- try {
275
- const response = await fetch(url, {
276
- ...options,
277
- signal: controller.signal
278
- })
279
- return response
280
- } finally {
281
- clearTimeout(timeout)
282
- }
283
- }
284
-
285
- /**
286
- * Fetch with retry - handles transient failures
287
- */
288
- async function fetchWithRetry(url, options = {}, maxRetries = 3, timeoutMs = 30000) {
289
- let lastError
290
-
291
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
292
- try {
293
- const response = await fetchWithTimeout(url, options, timeoutMs)
294
-
295
- // Retry on 5xx errors or rate limiting
296
- if (response.status >= 500 || response.status === 429) {
297
- const retryAfter = response.headers.get('retry-after')
298
- const delay = retryAfter ? parseInt(retryAfter) * 1000 : Math.pow(2, attempt) * 1000
299
-
300
- if (attempt < maxRetries) {
301
- log(`Request failed (${response.status}), retrying in ${delay}ms... (attempt ${attempt}/${maxRetries})`)
302
- await new Promise(resolve => setTimeout(resolve, delay))
303
- continue
304
- }
305
- }
306
-
307
- return response
308
- } catch (error) {
309
- lastError = error
310
-
311
- // Retry on network errors
312
- if (error.name === 'AbortError') {
313
- lastError = new Error(`Request timeout after ${timeoutMs}ms`)
314
- }
315
-
316
- if (attempt < maxRetries) {
317
- const delay = Math.pow(2, attempt) * 1000
318
- log(`Request error: ${lastError.message}, retrying in ${delay}ms... (attempt ${attempt}/${maxRetries})`)
319
- await new Promise(resolve => setTimeout(resolve, delay))
320
- }
321
- }
322
- }
323
-
324
- throw lastError
325
- }
326
-
327
- /**
328
- * Load session state from file (survives MCP restarts)
329
- * Supports both old single-article format and new multi-article format
330
- */
331
- function loadSession() {
332
- try {
333
- const sessionFile = getSessionFilePath()
334
- if (fs.existsSync(sessionFile)) {
335
- const content = fs.readFileSync(sessionFile, 'utf-8')
336
- const saved = JSON.parse(content)
337
-
338
- // Check if session is stale (older than 24 hours)
339
- const savedAt = new Date(saved.savedAt)
340
- const hoursSinceSave = (Date.now() - savedAt.getTime()) / (1000 * 60 * 60)
341
- if (hoursSinceSave > 24) {
342
- log(`Session expired (${Math.round(hoursSinceSave)} hours old), starting fresh`)
343
- clearSessionFile()
344
- return false
345
- }
346
-
347
- // Restore session state
348
- sessionState.currentWorkflow = saved.currentWorkflow || null
349
- sessionState.stepResults = saved.stepResults || {}
350
-
351
- // Load articles array (new format)
352
- sessionState.articles = saved.articles || []
353
-
354
- // Backwards compatibility: migrate old single-article format to articles array
355
- if (!saved.articles && saved.article && saved.title) {
356
- const migratedArticle = {
357
- id: generateArticleId(),
358
- title: saved.title,
359
- content: saved.article,
360
- keywords: saved.keywords || [],
361
- metaDescription: saved.metaDescription || '',
362
- metaTitle: saved.metaTitle || saved.title,
363
- imageUrl: saved.imageUrl || null,
364
- inlineImages: saved.inlineImages || [],
365
- savedAt: saved.savedAt,
366
- published: false,
367
- publishedTo: []
368
- }
369
- sessionState.articles = [migratedArticle]
370
- log(`Migrated old session format to multi-article format`)
371
- }
372
-
373
- // Current working article fields (cleared after each save)
374
- sessionState.article = saved.article || null
375
- sessionState.title = saved.title || null
376
- sessionState.imageUrl = saved.imageUrl || null
377
- sessionState.inlineImages = saved.inlineImages || []
378
- sessionState.keywords = saved.keywords || null
379
- sessionState.metadata = saved.metadata || null
380
- sessionState.metaTitle = saved.metaTitle || null
381
- sessionState.metaDescription = saved.metaDescription || null
382
- sessionState.contentFolder = saved.contentFolder || null
383
-
384
- log(`Restored session from ${sessionFile}`)
385
-
386
- // Show all saved articles
387
- if (sessionState.articles.length > 0) {
388
- log(` - ${sessionState.articles.length} article(s) in session:`)
389
- sessionState.articles.forEach((art, i) => {
390
- const wordCount = art.content?.split(/\s+/).length || 0
391
- const status = art.published ? `published to ${art.publishedTo.join(', ')}` : 'unpublished'
392
- log(` ${i + 1}. "${art.title}" (${wordCount} words) - ${status}`)
393
- })
394
- }
395
-
396
- // Show current working article if different
397
- if (sessionState.title && !sessionState.articles.find(a => a.title === sessionState.title)) {
398
- log(` - Current working: "${sessionState.title}" (${sessionState.article?.split(/\s+/).length || 0} words)`)
399
- }
400
-
401
- if (sessionState.contentFolder) {
402
- log(` - Content folder: ${sessionState.contentFolder}`)
403
- }
404
- return true
405
- }
406
- } catch (error) {
407
- log(`Warning: Failed to load session: ${error.message}`)
408
- }
409
- return false
410
- }
411
-
412
- // ============================================================================
413
- // SESSION MUTEX - Prevents race conditions on concurrent session writes
414
- // ============================================================================
415
-
416
- let sessionLock = false
417
- const sessionLockQueue = []
418
-
419
- /**
420
- * Acquire session lock for safe concurrent access
421
- * @returns {Promise<void>}
422
- */
423
- async function acquireSessionLock() {
424
- if (!sessionLock) {
425
- sessionLock = true
426
- return
427
- }
428
- return new Promise(resolve => {
429
- sessionLockQueue.push(resolve)
430
- })
431
- }
432
-
433
- /**
434
- * Release session lock, allowing next queued operation
435
- */
436
- function releaseSessionLock() {
437
- if (sessionLockQueue.length > 0) {
438
- const next = sessionLockQueue.shift()
439
- next()
440
- } else {
441
- sessionLock = false
442
- }
443
- }
444
-
445
- /**
446
- * Safe session save with mutex - use this for concurrent operations
447
- * @returns {Promise<void>}
448
- */
449
- async function saveSessionSafe() {
450
- await acquireSessionLock()
451
- try {
452
- saveSession()
453
- } finally {
454
- releaseSessionLock()
455
- }
456
- }
457
-
458
- /**
459
- * Save session state to file (persists across MCP restarts)
460
- * Uses atomic write to prevent corruption
461
- * NOTE: For concurrent operations, use saveSessionSafe() instead
462
- */
463
- function saveSession() {
464
- try {
465
- ensureSuparankDir()
466
- const sessionFile = getSessionFilePath()
467
-
468
- const toSave = {
469
- currentWorkflow: sessionState.currentWorkflow,
470
- stepResults: sessionState.stepResults,
471
- // Multi-article support
472
- articles: sessionState.articles,
473
- // Current working article (for backwards compat and active editing)
474
- article: sessionState.article,
475
- title: sessionState.title,
476
- imageUrl: sessionState.imageUrl,
477
- inlineImages: sessionState.inlineImages,
478
- keywords: sessionState.keywords,
479
- metadata: sessionState.metadata,
480
- metaTitle: sessionState.metaTitle,
481
- metaDescription: sessionState.metaDescription,
482
- contentFolder: sessionState.contentFolder,
483
- savedAt: new Date().toISOString()
484
- }
485
-
486
- // Atomic write to prevent corruption
487
- atomicWriteSync(sessionFile, JSON.stringify(toSave, null, 2))
488
- progress('Session', `Saved to ${sessionFile} (${sessionState.articles.length} articles)`)
489
- } catch (error) {
490
- log(`Warning: Failed to save session: ${error.message}`)
491
- progress('Session', `FAILED to save: ${error.message}`)
492
- }
493
- }
494
-
495
- /**
496
- * Extract image prompts from article content
497
- * Uses H2 headings to create contextual image prompts
498
- * @param {string} content - Article content in markdown
499
- * @param {object} projectConfig - Project configuration from database
500
- * @returns {Array<{heading: string, prompt: string}>} - Array of image prompts
501
- */
502
- function extractImagePromptsFromArticle(content, projectConfig) {
503
- // Extract H2 headings from markdown
504
- const headings = content.match(/^## .+$/gm) || []
505
-
506
- // Get visual style from project config
507
- const visualStyle = projectConfig?.visual_style?.image_aesthetic || 'professional minimalist'
508
- const brandColors = projectConfig?.visual_style?.colors || []
509
- const brandVoice = projectConfig?.brand?.voice || 'professional'
510
- const niche = projectConfig?.site?.niche || ''
511
-
512
- // Limit to 4 images (1 hero + 3 section images)
513
- const selectedHeadings = headings.slice(0, 4)
514
-
515
- return selectedHeadings.map((heading, index) => {
516
- const topic = heading.replace(/^## /, '').trim()
517
-
518
- // Create contextual prompt based on heading
519
- let prompt = `${topic}`
520
-
521
- // Add visual style
522
- if (visualStyle) {
523
- prompt += `, ${visualStyle} style`
524
- }
525
-
526
- // Add brand context for hero image
527
- if (index === 0) {
528
- prompt += `, hero image for article about ${niche}`
529
- } else {
530
- prompt += `, illustration for ${niche} article`
531
- }
532
-
533
- // Add quality modifiers
534
- prompt += ', high quality, professional, clean composition, no text'
535
-
536
- return {
537
- heading: topic,
538
- prompt: prompt,
539
- type: index === 0 ? 'hero' : 'section',
540
- aspectRatio: '16:9'
541
- }
542
- })
543
- }
544
-
545
- /**
546
- * Save content to a dedicated folder with all assets
547
- * Creates: ~/.suparank/content/{date}-{slug}/
548
- * - article.md (markdown content)
549
- * - metadata.json (title, keywords, etc.)
550
- * - workflow.json (workflow state for resuming)
551
- */
552
- function saveContentToFolder() {
553
- if (!sessionState.title || !sessionState.article) {
554
- return null
555
- }
556
-
557
- try {
558
- ensureContentDir()
559
-
560
- // Create folder name: YYYY-MM-DD-slug (slugify removes dangerous characters)
561
- const date = new Date().toISOString().split('T')[0]
562
- const slug = slugify(sessionState.title)
563
- const folderName = `${date}-${slug}`
564
-
565
- // Use safe path function to prevent any path traversal
566
- const folderPath = getContentFolderSafe(folderName)
567
-
568
- // Create folder if doesn't exist
569
- if (!fs.existsSync(folderPath)) {
570
- fs.mkdirSync(folderPath, { recursive: true })
571
- }
572
-
573
- // Save markdown article
574
- atomicWriteSync(
575
- path.join(folderPath, 'article.md'),
576
- sessionState.article
577
- )
578
-
579
- // Save metadata
580
- const metadata = {
581
- title: sessionState.title,
582
- keywords: sessionState.keywords || [],
583
- metaDescription: sessionState.metaDescription || '',
584
- metaTitle: sessionState.metaTitle || sessionState.title,
585
- imageUrl: sessionState.imageUrl,
586
- inlineImages: sessionState.inlineImages || [],
587
- wordCount: sessionState.article.split(/\s+/).length,
588
- createdAt: new Date().toISOString(),
589
- projectSlug: projectSlug
590
- }
591
- atomicWriteSync(
592
- path.join(folderPath, 'metadata.json'),
593
- JSON.stringify(metadata, null, 2)
594
- )
595
-
596
- // Save workflow state for resuming
597
- if (sessionState.currentWorkflow) {
598
- atomicWriteSync(
599
- path.join(folderPath, 'workflow.json'),
600
- JSON.stringify({
601
- workflow: sessionState.currentWorkflow,
602
- stepResults: sessionState.stepResults,
603
- savedAt: new Date().toISOString()
604
- }, null, 2)
605
- )
606
- }
607
-
608
- // Store folder path in session
609
- sessionState.contentFolder = folderPath
610
-
611
- progress('Content', `Saved to folder: ${folderPath}`)
612
- return folderPath
613
- } catch (error) {
614
- log(`Warning: Failed to save content to folder: ${error.message}`)
615
- return null
616
- }
617
- }
618
-
619
- /**
620
- * Clear session file (called after successful publish or on reset)
621
- */
622
- function clearSessionFile() {
623
- try {
624
- const sessionFile = getSessionFilePath()
625
- if (fs.existsSync(sessionFile)) {
626
- fs.unlinkSync(sessionFile)
627
- }
628
- } catch (error) {
629
- log(`Warning: Failed to clear session file: ${error.message}`)
630
- }
631
- }
632
-
633
- /**
634
- * Reset session state for new workflow (clears everything including all articles)
635
- */
636
- function resetSession() {
637
- sessionState.currentWorkflow = null
638
- sessionState.stepResults = {}
639
- sessionState.articles = [] // Clear all saved articles
640
- sessionState.article = null
641
- sessionState.title = null
642
- sessionState.imageUrl = null
643
- sessionState.inlineImages = []
644
- sessionState.keywords = null
645
- sessionState.metadata = null
646
- sessionState.metaTitle = null
647
- sessionState.metaDescription = null
648
- sessionState.contentFolder = null
649
-
650
- // Clear persisted session file when starting fresh
651
- clearSessionFile()
652
- }
653
-
654
- /**
655
- * Clear current working article without removing saved articles
656
- * Use this after saving an article to prepare for the next one
657
- */
658
- function clearCurrentArticle() {
659
- sessionState.article = null
660
- sessionState.title = null
661
- sessionState.imageUrl = null
662
- sessionState.inlineImages = []
663
- sessionState.keywords = null
664
- sessionState.metadata = null
665
- sessionState.metaTitle = null
666
- sessionState.metaDescription = null
667
- }
668
-
669
- /**
670
- * Load credentials from ~/.suparank/credentials.json
671
- * Falls back to legacy .env.superwriter paths for backward compatibility
672
- */
673
- function loadLocalCredentials() {
674
- const searchPaths = [
675
- path.join(os.homedir(), '.suparank', 'credentials.json'),
676
- path.join(process.cwd(), '.env.superwriter'), // Legacy support
677
- path.join(os.homedir(), '.env.superwriter') // Legacy support
678
- ]
679
-
680
- for (const filePath of searchPaths) {
681
- if (fs.existsSync(filePath)) {
682
- try {
683
- const content = fs.readFileSync(filePath, 'utf-8')
684
- const parsed = JSON.parse(content)
685
- log(`Loaded credentials from: ${filePath}`)
686
- return parsed
687
- } catch (e) {
688
- log(`Warning: Failed to parse ${filePath}: ${e.message}`)
689
- }
690
- }
691
- }
692
-
693
- log('No credentials found. Run "npx suparank setup" to configure. Action tools will be limited.')
694
- return null
695
- }
696
-
697
- /**
698
- * Check if a credential type is available
699
- */
700
- function hasCredential(type) {
701
- if (!localCredentials) return false
702
-
703
- switch (type) {
704
- case 'wordpress':
705
- return !!localCredentials.wordpress?.secret_key || !!localCredentials.wordpress?.app_password
706
- case 'ghost':
707
- return !!localCredentials.ghost?.admin_api_key
708
- case 'fal':
709
- return !!localCredentials.fal?.api_key
710
- case 'gemini':
711
- return !!localCredentials.gemini?.api_key
712
- case 'wiro':
713
- return !!localCredentials.wiro?.api_key
714
- case 'image':
715
- const provider = localCredentials.image_provider
716
- return provider && hasCredential(provider)
717
- case 'webhooks':
718
- return !!localCredentials.webhooks && Object.values(localCredentials.webhooks).some(Boolean)
719
- default:
720
- return false
721
- }
722
- }
723
-
724
- /**
725
- * Get composition hints for a tool from local credentials
726
- */
727
- function getCompositionHints(toolName) {
728
- if (!localCredentials?.tool_instructions) return null
729
-
730
- const instruction = localCredentials.tool_instructions.find(t => t.tool_name === toolName)
731
- return instruction?.composition_hints || null
732
- }
733
-
734
- /**
735
- * Get list of external MCPs configured
736
- */
737
- function getExternalMCPs() {
738
- return localCredentials?.external_mcps || []
739
- }
740
-
741
- // Fetch project config from API
742
- async function fetchProjectConfig() {
743
- try {
744
- const response = await fetchWithRetry(`${apiUrl}/projects/${projectSlug}`, {
745
- headers: {
746
- 'Authorization': `Bearer ${apiKey}`,
747
- 'Content-Type': 'application/json'
748
- }
749
- }, 3, 15000) // 3 retries, 15s timeout
750
-
751
- if (!response.ok) {
752
- const error = await response.text()
753
-
754
- if (response.status === 401) {
755
- throw new Error(`Invalid or expired API key. Please create a new one in the dashboard.`)
756
- }
757
-
758
- throw new Error(`Failed to fetch project: ${error}`)
759
- }
760
-
761
- const data = await response.json()
762
- return data.project
763
- } catch (error) {
764
- log('Error fetching project config:', error.message)
765
- throw error
766
- }
767
- }
768
-
769
- // Call backend API to execute tool
770
- async function callBackendTool(toolName, args) {
771
- try {
772
- const response = await fetch(`${apiUrl}/tools/${projectSlug}/${toolName}`, {
773
- method: 'POST',
774
- headers: {
775
- 'Authorization': `Bearer ${apiKey}`,
776
- 'Content-Type': 'application/json'
777
- },
778
- body: JSON.stringify({ arguments: args })
779
- })
780
-
781
- if (!response.ok) {
782
- const error = await response.text()
783
-
784
- if (response.status === 401) {
785
- throw new Error(`Invalid or expired API key. Please create a new one in the dashboard.`)
786
- }
787
-
788
- throw new Error(`Tool execution failed: ${error}`)
789
- }
790
-
791
- const result = await response.json()
792
- return result
793
- } catch (error) {
794
- log('Error calling tool:', error.message)
795
- throw error
796
- }
797
- }
798
-
799
- // Tool definitions (synced with backend)
800
- const TOOLS = [
801
- {
802
- name: 'keyword_research',
803
- description: `Research keywords for SEO. Use ONLY when user specifically asks for keyword research WITHOUT wanting full article creation.
804
-
805
- TRIGGERS - Use when user says:
806
- - "find keywords for..."
807
- - "research keywords about..."
808
- - "what keywords should I target for..."
809
- - "keyword ideas for..."
810
- - "analyze keywords for..."
811
-
812
- DO NOT USE when user wants to write/create content - use create_content instead (it includes keyword research automatically).
813
-
814
- OUTCOME: List of keywords with search volume, difficulty, and recommendations.`,
815
- inputSchema: {
816
- type: 'object',
817
- properties: {
818
- seed_keyword: {
819
- type: 'string',
820
- description: 'Starting keyword or topic to research (optional - uses project primary keywords if not specified)'
821
- },
822
- content_goal: {
823
- type: 'string',
824
- enum: ['traffic', 'conversions', 'brand-awareness'],
825
- description: 'Primary goal for the content strategy (optional - defaults to traffic)'
826
- },
827
- competitor_domain: {
828
- type: 'string',
829
- description: 'Optional: Competitor domain to analyze'
830
- }
831
- }
832
- }
833
- },
834
- {
835
- name: 'seo_strategy',
836
- description: 'Create comprehensive SEO strategy and content brief. Works with project keywords automatically if none specified.',
837
- inputSchema: {
838
- type: 'object',
839
- properties: {
840
- target_keyword: {
841
- type: 'string',
842
- description: 'Main keyword to target (optional - uses project primary keywords if not specified)'
843
- },
844
- content_type: {
845
- type: 'string',
846
- enum: ['guide', 'listicle', 'how-to', 'comparison', 'review'],
847
- description: 'Type of content to create (optional - defaults to guide)'
848
- },
849
- search_intent: {
850
- type: 'string',
851
- enum: ['informational', 'commercial', 'transactional', 'navigational'],
852
- description: 'Primary search intent to target (optional - auto-detected)'
853
- }
854
- }
855
- }
856
- },
857
- {
858
- name: 'topical_map',
859
- description: 'Design pillar-cluster content architecture for topical authority. Uses project niche and keywords automatically.',
860
- inputSchema: {
861
- type: 'object',
862
- properties: {
863
- core_topic: {
864
- type: 'string',
865
- description: 'Main topic for the content cluster (optional - uses project niche if not specified)'
866
- },
867
- depth: {
868
- type: 'number',
869
- enum: [1, 2, 3],
870
- description: 'Depth of content cluster: 1 (pillar + 5 articles), 2 (+ subtopics), 3 (full hierarchy)',
871
- default: 2
872
- }
873
- }
874
- }
875
- },
876
- {
877
- name: 'content_calendar',
878
- description: 'Create editorial calendar and publication schedule. Uses project keywords and niche automatically.',
879
- inputSchema: {
880
- type: 'object',
881
- properties: {
882
- time_period: {
883
- type: 'string',
884
- enum: ['week', 'month', 'quarter'],
885
- description: 'Planning period for the content calendar (optional - defaults to month)',
886
- default: 'month'
887
- },
888
- content_types: {
889
- type: 'array',
890
- items: { type: 'string' },
891
- description: 'Types of content to include (optional - defaults to blog)'
892
- },
893
- priority_keywords: {
894
- type: 'array',
895
- items: { type: 'string' },
896
- description: 'Keywords to prioritize (optional - uses project keywords)'
897
- }
898
- }
899
- }
900
- },
901
- {
902
- name: 'content_write',
903
- description: 'Write comprehensive, SEO-optimized blog articles. Creates engaging content with proper structure, internal links, and semantic optimization. Uses project brand voice and keywords automatically.',
904
- inputSchema: {
905
- type: 'object',
906
- properties: {
907
- title: {
908
- type: 'string',
909
- description: 'Article title or headline (optional - can be generated from topic)'
910
- },
911
- target_keyword: {
912
- type: 'string',
913
- description: 'Primary keyword to optimize for (optional - uses project keywords)'
914
- },
915
- outline: {
916
- type: 'string',
917
- description: 'Optional: Article outline or structure (H2/H3 headings)'
918
- },
919
- tone: {
920
- type: 'string',
921
- enum: ['professional', 'casual', 'conversational', 'technical'],
922
- description: 'Writing tone (optional - uses project brand voice)'
923
- }
924
- }
925
- }
926
- },
927
- {
928
- name: 'image_prompt',
929
- description: 'Create optimized prompts for AI image generation. Designs prompts for blog hero images, section illustrations, and branded visuals. Uses project visual style and brand automatically.',
930
- inputSchema: {
931
- type: 'object',
932
- properties: {
933
- image_purpose: {
934
- type: 'string',
935
- enum: ['hero', 'section', 'diagram', 'comparison', 'infographic'],
936
- description: 'Purpose of the image (optional - defaults to hero)',
937
- default: 'hero'
938
- },
939
- subject: {
940
- type: 'string',
941
- description: 'Main subject or concept for the image (optional - uses project niche)'
942
- },
943
- mood: {
944
- type: 'string',
945
- description: 'Optional: Desired mood (uses project visual style if not specified)'
946
- }
947
- }
948
- }
949
- },
950
- {
951
- name: 'internal_links',
952
- description: 'Develop strategic internal linking plan. Analyzes existing content and identifies linking opportunities for improved site architecture. Works with project content automatically.',
953
- inputSchema: {
954
- type: 'object',
955
- properties: {
956
- current_page: {
957
- type: 'string',
958
- description: 'URL or title of the page to optimize (optional - can work with last created content)'
959
- },
960
- available_pages: {
961
- type: 'array',
962
- items: { type: 'string' },
963
- description: 'List of existing pages to consider (optional - can analyze site automatically)'
964
- },
965
- link_goal: {
966
- type: 'string',
967
- enum: ['authority-building', 'user-navigation', 'conversion'],
968
- description: 'Primary goal for internal linking (optional - defaults to authority-building)'
969
- }
970
- }
971
- }
972
- },
973
- {
974
- name: 'schema_generate',
975
- description: 'Implement Schema.org structured data markup. Analyzes content to recommend and generate appropriate JSON-LD schemas for enhanced search visibility. Auto-detects page type if not specified.',
976
- inputSchema: {
977
- type: 'object',
978
- properties: {
979
- page_type: {
980
- type: 'string',
981
- enum: ['article', 'product', 'how-to', 'faq', 'review', 'organization'],
982
- description: 'Type of page to generate schema for (optional - auto-detected from content)'
983
- },
984
- content_summary: {
985
- type: 'string',
986
- description: 'Brief summary of the page content (optional - can analyze content)'
987
- }
988
- }
989
- }
990
- },
991
- {
992
- name: 'geo_optimize',
993
- description: 'Optimize content for AI search engines and Google SGE. Implements GEO (Generative Engine Optimization) best practices for LLM-friendly content. Works with project content automatically.',
994
- inputSchema: {
995
- type: 'object',
996
- properties: {
997
- content_url: {
998
- type: 'string',
999
- description: 'URL or title of content to optimize (optional - can work with last created content)'
1000
- },
1001
- target_engines: {
1002
- type: 'array',
1003
- items: {
1004
- type: 'string',
1005
- enum: ['chatgpt', 'perplexity', 'claude', 'gemini', 'google-sge']
1006
- },
1007
- description: 'AI search engines to optimize for (optional - defaults to all)',
1008
- default: ['chatgpt', 'google-sge']
1009
- }
1010
- }
1011
- }
1012
- },
1013
- {
1014
- name: 'quality_check',
1015
- description: 'Perform comprehensive pre-publish quality assurance. Checks grammar, SEO requirements, brand consistency, accessibility, and technical accuracy. Can review last created content automatically.',
1016
- inputSchema: {
1017
- type: 'object',
1018
- properties: {
1019
- content: {
1020
- type: 'string',
1021
- description: 'Full content to review (optional - can review last created content)'
1022
- },
1023
- check_type: {
1024
- type: 'string',
1025
- enum: ['full', 'seo-only', 'grammar-only', 'brand-only'],
1026
- description: 'Type of quality check to perform (optional - defaults to full)',
1027
- default: 'full'
1028
- }
1029
- }
1030
- }
1031
- },
1032
- {
1033
- name: 'full_pipeline',
1034
- description: 'Execute complete 5-phase content creation pipeline. Orchestrates research, planning, creation, optimization, and quality checking in one workflow. Works with project configuration automatically - just describe what you need!',
1035
- inputSchema: {
1036
- type: 'object',
1037
- properties: {
1038
- seed_keyword: {
1039
- type: 'string',
1040
- description: 'Starting keyword for the pipeline (optional - uses project primary keywords and niche)'
1041
- },
1042
- content_type: {
1043
- type: 'string',
1044
- enum: ['guide', 'listicle', 'how-to', 'comparison', 'review'],
1045
- description: 'Type of content to create (optional - defaults to guide)',
1046
- default: 'guide'
1047
- },
1048
- skip_phases: {
1049
- type: 'array',
1050
- items: {
1051
- type: 'string',
1052
- enum: ['research', 'planning', 'creation', 'optimization', 'quality']
1053
- },
1054
- description: 'Optional: Phases to skip in the pipeline'
1055
- }
1056
- }
1057
- }
1058
- }
1059
- ]
1060
-
1061
- // Action tools that require local credentials
1062
- const ACTION_TOOLS = [
1063
- {
1064
- name: 'generate_image',
1065
- description: `Generate AI images. Use when user wants to create, generate, or regenerate images.
1066
-
1067
- TRIGGERS - Use when user says:
1068
- - "create an image for..."
1069
- - "generate image of..."
1070
- - "make a picture of..."
1071
- - "I need an image for..."
1072
- - "regenerate the image"
1073
- - "new hero image"
1074
- - "create thumbnail for..."
1075
-
1076
- NOTE: create_content automatically generates images. Use this tool for:
1077
- - Regenerating/replacing images
1078
- - Creating standalone images
1079
- - Custom image requests outside content workflow
1080
-
1081
- OUTCOME: AI-generated image URL ready for use.`,
1082
- inputSchema: {
1083
- type: 'object',
1084
- properties: {
1085
- prompt: {
1086
- type: 'string',
1087
- description: 'Detailed prompt for image generation'
1088
- },
1089
- style: {
1090
- type: 'string',
1091
- description: 'Style guidance (e.g., "minimalist", "photorealistic", "illustration")'
1092
- },
1093
- aspect_ratio: {
1094
- type: 'string',
1095
- enum: ['1:1', '16:9', '9:16', '4:3', '3:4'],
1096
- description: 'Image aspect ratio',
1097
- default: '16:9'
1098
- }
1099
- },
1100
- required: ['prompt']
1101
- },
1102
- requiresCredential: 'image'
1103
- },
1104
- {
1105
- name: 'publish_wordpress',
1106
- description: 'Publish content directly to WordPress (supports .com and .org). Requires WordPress credentials in ~/.suparank/credentials.json',
1107
- inputSchema: {
1108
- type: 'object',
1109
- properties: {
1110
- title: {
1111
- type: 'string',
1112
- description: 'Post title'
1113
- },
1114
- content: {
1115
- type: 'string',
1116
- description: 'Full post content (HTML or Markdown)'
1117
- },
1118
- status: {
1119
- type: 'string',
1120
- enum: ['draft', 'publish'],
1121
- description: 'Publication status',
1122
- default: 'draft'
1123
- },
1124
- categories: {
1125
- type: 'array',
1126
- items: { type: 'string' },
1127
- description: 'Category names'
1128
- },
1129
- tags: {
1130
- type: 'array',
1131
- items: { type: 'string' },
1132
- description: 'Tag names'
1133
- },
1134
- featured_image_url: {
1135
- type: 'string',
1136
- description: 'URL of featured image to upload'
1137
- }
1138
- },
1139
- required: ['title', 'content']
1140
- },
1141
- requiresCredential: 'wordpress'
1142
- },
1143
- {
1144
- name: 'publish_ghost',
1145
- description: 'Publish content to Ghost CMS. Requires Ghost Admin API key in ~/.suparank/credentials.json',
1146
- inputSchema: {
1147
- type: 'object',
1148
- properties: {
1149
- title: {
1150
- type: 'string',
1151
- description: 'Post title'
1152
- },
1153
- content: {
1154
- type: 'string',
1155
- description: 'Full post content (HTML or Markdown)'
1156
- },
1157
- status: {
1158
- type: 'string',
1159
- enum: ['draft', 'published'],
1160
- description: 'Publication status',
1161
- default: 'draft'
1162
- },
1163
- tags: {
1164
- type: 'array',
1165
- items: { type: 'string' },
1166
- description: 'Tag names'
1167
- },
1168
- featured_image_url: {
1169
- type: 'string',
1170
- description: 'URL of featured image'
1171
- }
1172
- },
1173
- required: ['title', 'content']
1174
- },
1175
- requiresCredential: 'ghost'
1176
- },
1177
- {
1178
- name: 'send_webhook',
1179
- description: 'Send data to configured webhooks (Make.com, n8n, Zapier, Slack). Requires webhook URLs in ~/.suparank/credentials.json',
1180
- inputSchema: {
1181
- type: 'object',
1182
- properties: {
1183
- webhook_type: {
1184
- type: 'string',
1185
- enum: ['default', 'make', 'n8n', 'zapier', 'slack'],
1186
- description: 'Which webhook to use',
1187
- default: 'default'
1188
- },
1189
- payload: {
1190
- type: 'object',
1191
- description: 'Data to send in the webhook'
1192
- },
1193
- message: {
1194
- type: 'string',
1195
- description: 'For Slack: formatted message text'
1196
- }
1197
- },
1198
- required: ['webhook_type']
1199
- },
1200
- requiresCredential: 'webhooks'
1201
- }
1202
- ]
1203
-
1204
- // Orchestrator tools for automated workflows
1205
- const ORCHESTRATOR_TOOLS = [
1206
- {
1207
- name: 'create_content',
1208
- description: `PRIMARY TOOL for content creation. Use this when user wants to write, create, or generate any content.
1209
-
1210
- TRIGGERS - Use when user says:
1211
- - "write a blog post about..."
1212
- - "create an article about..."
1213
- - "I need content for..."
1214
- - "help me write about..."
1215
- - "generate a post on..."
1216
- - "make content about..."
1217
- - any request involving writing/creating/generating articles or blog posts
1218
-
1219
- WORKFLOW (automatic 4-phase):
1220
- 1. RESEARCH: Keywords, SEO strategy, content structure
1221
- 2. CREATION: Outline, write full article, save to session
1222
- 3. OPTIMIZATION: Quality check, GEO optimization for AI search
1223
- 4. PUBLISHING: Generate images, publish to WordPress/Ghost
1224
-
1225
- OUTCOME: Complete article written, optimized, and published to CMS.`,
1226
- inputSchema: {
1227
- type: 'object',
1228
- properties: {
1229
- request: {
1230
- type: 'string',
1231
- description: 'What content do you want? (e.g., "write a blog post about AI", "create 5 articles")'
1232
- },
1233
- count: {
1234
- type: 'number',
1235
- description: 'Number of articles to create (default: 1)',
1236
- default: 1
1237
- },
1238
- publish_to: {
1239
- type: 'array',
1240
- items: { type: 'string', enum: ['ghost', 'wordpress', 'none'] },
1241
- description: 'Where to publish (default: all configured CMS)',
1242
- default: []
1243
- },
1244
- with_images: {
1245
- type: 'boolean',
1246
- description: 'Generate hero images (default: true)',
1247
- default: true
1248
- }
1249
- }
1250
- }
1251
- },
1252
- {
1253
- name: 'save_content',
1254
- description: `Save written article to session. Use after manually writing content outside create_content workflow.
1255
-
1256
- TRIGGERS - Use when:
1257
- - You wrote an article manually and need to save it
1258
- - User says "save this article" / "save my content"
1259
- - Saving edited/revised content
1260
-
1261
- NOTE: create_content saves automatically. Only use this for manual saves.
1262
-
1263
- OUTCOME: Article saved to session, ready for publishing.`,
1264
- inputSchema: {
1265
- type: 'object',
1266
- properties: {
1267
- title: {
1268
- type: 'string',
1269
- description: 'Article title'
1270
- },
1271
- content: {
1272
- type: 'string',
1273
- description: 'Full article content (markdown)'
1274
- },
1275
- keywords: {
1276
- type: 'array',
1277
- items: { type: 'string' },
1278
- description: 'Target keywords used'
1279
- },
1280
- meta_description: {
1281
- type: 'string',
1282
- description: 'SEO meta description'
1283
- }
1284
- },
1285
- required: ['title', 'content']
1286
- }
1287
- },
1288
- {
1289
- name: 'publish_content',
1290
- description: `Publish articles to WordPress/Ghost. Use when user wants to publish saved content.
1291
-
1292
- TRIGGERS - Use when user says:
1293
- - "publish my article"
1294
- - "post this to WordPress/Ghost"
1295
- - "publish to my blog"
1296
- - "make it live"
1297
- - "publish as draft"
1298
-
1299
- NOTE: create_content publishes automatically. Use this for:
1300
- - Manual publishing control
1301
- - Re-publishing edited content
1302
- - Publishing specific articles from session
1303
-
1304
- OUTCOME: Article published to configured CMS platforms.`,
1305
- inputSchema: {
1306
- type: 'object',
1307
- properties: {
1308
- platforms: {
1309
- type: 'array',
1310
- items: { type: 'string', enum: ['ghost', 'wordpress', 'all'] },
1311
- description: 'Platforms to publish to (default: all configured)',
1312
- default: ['all']
1313
- },
1314
- status: {
1315
- type: 'string',
1316
- enum: ['draft', 'publish'],
1317
- description: 'Publication status',
1318
- default: 'draft'
1319
- },
1320
- category: {
1321
- type: 'string',
1322
- description: 'WordPress category name - pick the most relevant one from available categories shown in save_content response'
1323
- },
1324
- article_numbers: {
1325
- type: 'array',
1326
- items: { type: 'number' },
1327
- description: 'Optional: Publish specific articles by number (1, 2, 3...). If not specified, publishes ALL unpublished articles.'
1328
- }
1329
- }
1330
- }
1331
- },
1332
- {
1333
- name: 'get_session',
1334
- description: `View current session status. Shows saved articles, images, and publishing state.
1335
-
1336
- TRIGGERS - Use when user says:
1337
- - "what's in my session"
1338
- - "show my articles"
1339
- - "what have I created"
1340
- - "session status"
1341
- - "list my saved content"
1342
-
1343
- OUTCOME: List of all articles in session with their publish status.`,
1344
- inputSchema: {
1345
- type: 'object',
1346
- properties: {}
1347
- }
1348
- },
1349
- {
1350
- name: 'remove_article',
1351
- description: `Remove article(s) from session. Does NOT delete published content.
1352
-
1353
- TRIGGERS - Use when user says:
1354
- - "remove article 2"
1355
- - "delete the second article"
1356
- - "remove that article"
1357
- - "discard article..."
1358
-
1359
- OUTCOME: Specified article(s) removed from session.`,
1360
- inputSchema: {
1361
- type: 'object',
1362
- properties: {
1363
- article_numbers: {
1364
- type: 'array',
1365
- items: { type: 'number' },
1366
- description: 'Article numbers to remove (1, 2, 3...). Use get_session to see article numbers.'
1367
- }
1368
- },
1369
- required: ['article_numbers']
1370
- }
1371
- },
1372
- {
1373
- name: 'clear_session',
1374
- description: `Clear ALL content from session. DESTRUCTIVE - removes all unpublished articles!
1375
-
1376
- TRIGGERS - Use when user says:
1377
- - "clear my session"
1378
- - "start fresh"
1379
- - "remove all articles"
1380
- - "reset everything"
1381
- - "clear all content"
1382
-
1383
- WARNING: Requires confirm: true. Does NOT affect already-published content.
1384
-
1385
- OUTCOME: Empty session, ready for new content creation.`,
1386
- inputSchema: {
1387
- type: 'object',
1388
- properties: {
1389
- confirm: {
1390
- type: 'boolean',
1391
- description: 'Must be true to confirm clearing all content'
1392
- }
1393
- },
1394
- required: ['confirm']
1395
- }
1396
- },
1397
- {
1398
- name: 'list_content',
1399
- description: `List all saved content from disk. Shows past articles that can be loaded back.
1400
-
1401
- TRIGGERS - Use when user says:
1402
- - "show my past articles"
1403
- - "list saved content"
1404
- - "what articles do I have"
1405
- - "show previous content"
1406
- - "find my old articles"
1407
-
1408
- NOTE: Different from get_session - this shows DISK storage, not current session.
1409
-
1410
- OUTCOME: List of saved article folders with titles and dates.`,
1411
- inputSchema: {
1412
- type: 'object',
1413
- properties: {
1414
- limit: {
1415
- type: 'number',
1416
- description: 'Max number of articles to show (default: 20)',
1417
- default: 20
1418
- }
1419
- }
1420
- }
1421
- },
1422
- {
1423
- name: 'load_content',
1424
- description: `Load a saved article back into session for editing or re-publishing.
1425
-
1426
- TRIGGERS - Use when user says:
1427
- - "load my article about..."
1428
- - "open the previous article"
1429
- - "bring back that article"
1430
- - "edit my old post about..."
1431
- - "reload article..."
1432
-
1433
- WORKFLOW: Run list_content first to see available articles, then load by folder name.
1434
-
1435
- OUTCOME: Article loaded into session, ready for optimization or re-publishing.`,
1436
- inputSchema: {
1437
- type: 'object',
1438
- properties: {
1439
- folder_name: {
1440
- type: 'string',
1441
- description: 'Folder name from list_content (e.g., "2026-01-09-my-article-title")'
1442
- }
1443
- },
1444
- required: ['folder_name']
1445
- }
1446
- }
1447
- ]
1448
-
1449
- /**
1450
- * Build workflow plan based on user request and available credentials
1451
- *
1452
- * ALL data comes from project.config (Supabase database) - NO HARDCODED DEFAULTS
1453
- */
1454
- /**
1455
- * Validate project configuration with helpful error messages
1456
- */
1457
- function validateProjectConfig(config) {
1458
- const errors = []
1459
-
1460
- if (!config) {
1461
- throw new Error('Project configuration not found. Please configure your project in the dashboard.')
1462
- }
1463
-
1464
- // Check required fields
1465
- if (!config.content?.default_word_count) {
1466
- errors.push('Word count: Not set → Dashboard → Project Settings → Content')
1467
- } else if (typeof config.content.default_word_count !== 'number' || config.content.default_word_count < 100) {
1468
- errors.push('Word count: Must be at least 100 words')
1469
- } else if (config.content.default_word_count > 10000) {
1470
- errors.push('Word count: Maximum 10,000 words supported')
1471
- }
1472
-
1473
- if (!config.brand?.voice) {
1474
- errors.push('Brand voice: Not set → Dashboard → Project Settings → Brand')
1475
- }
1476
-
1477
- if (!config.site?.niche) {
1478
- errors.push('Niche: Not set → Dashboard → Project Settings → Site')
1479
- }
1480
-
1481
- // Warnings (non-blocking but helpful)
1482
- const warnings = []
1483
- if (!config.seo?.primary_keywords?.length) {
1484
- warnings.push('No primary keywords set - content may lack SEO focus')
1485
- }
1486
- if (!config.brand?.target_audience) {
1487
- warnings.push('No target audience set - content may be too generic')
1488
- }
1489
-
1490
- if (errors.length > 0) {
1491
- throw new Error(`Project configuration incomplete:\n${errors.map(e => ` • ${e}`).join('\n')}`)
1492
- }
1493
-
1494
- return { warnings }
1495
- }
1496
-
1497
- function buildWorkflowPlan(request, count, publishTo, withImages, project) {
1498
- const steps = []
1499
- const hasGhost = hasCredential('ghost')
1500
- const hasWordPress = hasCredential('wordpress')
1501
- const hasImageGen = hasCredential('image')
1502
-
1503
- // Get project config from database - MUST be dynamic, no hardcoding
1504
- const config = project?.config
1505
-
1506
- // Validate configuration with helpful messages
1507
- const { warnings } = validateProjectConfig(config)
1508
- if (warnings.length > 0) {
1509
- log(`Config warnings: ${warnings.join('; ')}`)
1510
- }
1511
-
1512
- // Extract all settings from project.config (database schema)
1513
- const targetWordCount = config.content?.default_word_count
1514
-
1515
- // LOG ALL CONFIG VALUES FOR DEBUGGING
1516
- log('=== PROJECT CONFIG VALUES ===')
1517
- log(`Word Count Target: ${targetWordCount}`)
1518
- log(`Reading Level: ${config.content?.reading_level}`)
1519
- log(`Brand Voice: ${config.brand?.voice}`)
1520
- log(`Target Audience: ${config.brand?.target_audience}`)
1521
- log(`Primary Keywords: ${config.seo?.primary_keywords?.join(', ')}`)
1522
- log(`Include Images: ${config.content?.include_images}`)
1523
- log('=============================')
1524
-
1525
- // CRITICAL: Validate word count is set
1526
- if (!targetWordCount || targetWordCount < 100) {
1527
- log(`WARNING: Word count not properly set! Got: ${targetWordCount}`)
1528
- }
1529
- const readingLevel = config.content?.reading_level
1530
- const includeImages = config.content?.include_images
1531
- const brandVoice = config.brand?.voice
1532
- const targetAudience = config.brand?.target_audience
1533
- const differentiators = config.brand?.differentiators || []
1534
- const visualStyle = config.visual_style?.image_aesthetic
1535
- const brandColors = config.visual_style?.colors || []
1536
- const primaryKeywords = config.seo?.primary_keywords || []
1537
- const geoFocus = config.seo?.geo_focus
1538
- const niche = config.site?.niche
1539
- const siteName = config.site?.name
1540
- const siteUrl = config.site?.url
1541
- const siteDescription = config.site?.description
1542
-
1543
- // Calculate required images: 1 cover + 1 per 300 words (only if includeImages is true)
1544
- const shouldGenerateImages = withImages && includeImages && hasImageGen
1545
- const contentImageCount = shouldGenerateImages ? Math.floor(targetWordCount / 300) : 0
1546
- const totalImages = shouldGenerateImages ? 1 + contentImageCount : 0 // cover + inline images
1547
-
1548
- // Format reading level for display (stored as number, display as "Grade X")
1549
- const readingLevelDisplay = readingLevel ? `Grade ${readingLevel}` : 'Not set'
1550
-
1551
- // Format keywords for display
1552
- const keywordsDisplay = primaryKeywords.length > 0 ? primaryKeywords.join(', ') : 'No keywords set'
1553
-
1554
- // Determine publish targets
1555
- let targets = publishTo || []
1556
- if (targets.length === 0 || targets.includes('all')) {
1557
- targets = []
1558
- if (hasGhost) targets.push('ghost')
1559
- if (hasWordPress) targets.push('wordpress')
1560
- }
1561
-
1562
- let stepNum = 0
1563
-
1564
- // Step 1: Keyword Research
1565
- // Build dynamic MCP hints from local credentials (user-configured in credentials.json)
1566
- const externalMcps = getExternalMCPs()
1567
- const keywordResearchHints = getCompositionHints('keyword_research')
1568
-
1569
- let mcpInstructions = ''
1570
- if (externalMcps.length > 0) {
1571
- const mcpList = externalMcps.map(m => `- **${m.name}**: ${m.available_tools?.join(', ') || 'tools available'}`).join('\n')
1572
- mcpInstructions = `\n💡 **External MCPs Available (from your credentials.json):**\n${mcpList}`
1573
- if (keywordResearchHints) {
1574
- mcpInstructions += `\n\n**Integration Hint:** ${keywordResearchHints}`
1575
- }
1576
- }
1577
-
1578
- // ═══════════════════════════════════════════════════════════
1579
- // RESEARCH PHASE
1580
- // ═══════════════════════════════════════════════════════════
1581
-
1582
- stepNum++
1583
- steps.push({
1584
- step: stepNum,
1585
- type: 'llm_execute',
1586
- action: 'keyword_research',
1587
- instruction: `Research keywords for: "${request}"
1588
-
1589
- **Project Context (from database):**
1590
- - Site: ${siteName} (${siteUrl})
1591
- - Niche: ${niche}
1592
- - Description: ${siteDescription || 'Not set'}
1593
- - Primary keywords: ${keywordsDisplay}
1594
- - Geographic focus: ${geoFocus || 'Global'}
1595
- ${mcpInstructions}
1596
-
1597
- **Deliverables:**
1598
- - 1 primary keyword to target (lower difficulty preferred)
1599
- - 3-5 secondary/LSI keywords
1600
- - 2-3 question-based keywords for FAQ section`,
1601
- store: 'keywords'
1602
- })
1603
-
1604
- // Step 2: SEO Strategy & Content Brief
1605
- stepNum++
1606
- steps.push({
1607
- step: stepNum,
1608
- type: 'llm_execute',
1609
- action: 'seo_strategy',
1610
- instruction: `Create SEO strategy and content brief for: "${request}"
1611
-
1612
- **Using Keywords from Step 1:**
1613
- - Use the primary keyword you identified
1614
- - Incorporate secondary/LSI keywords naturally
1615
-
1616
- **Project Context:**
1617
- - Site: ${siteName}
1618
- - Niche: ${niche}
1619
- - Target audience: ${targetAudience || 'Not specified'}
1620
- - Brand voice: ${brandVoice}
1621
- - Geographic focus: ${geoFocus || 'Global'}
1622
-
1623
- **Deliverables:**
1624
- 1. **Search Intent Analysis** - What is the user trying to accomplish?
1625
- 2. **Competitor Gap Analysis** - What are top 3 ranking pages missing?
1626
- 3. **Content Brief:**
1627
- - Recommended content type (guide/listicle/how-to/comparison)
1628
- - Unique angle to differentiate from competitors
1629
- - Key points to cover that competitors miss
1630
- 4. **On-Page SEO Checklist:**
1631
- - Title tag format
1632
- - Meta description template
1633
- - Header structure (H1, H2, H3)
1634
- - Internal linking opportunities`,
1635
- store: 'seo_strategy'
1636
- })
1637
-
1638
- // Step 3: Topical Map (Content Architecture)
1639
- stepNum++
1640
- steps.push({
1641
- step: stepNum,
1642
- type: 'llm_execute',
1643
- action: 'topical_map',
1644
- instruction: `Design content architecture for: "${request}"
1645
-
1646
- **Build a Pillar-Cluster Structure:**
1647
- - Main pillar topic (this article)
1648
- - Supporting cluster articles (future content opportunities)
1649
-
1650
- **Project Context:**
1651
- - Site: ${siteName}
1652
- - Niche: ${niche}
1653
- - Primary keywords: ${keywordsDisplay}
1654
-
1655
- **Deliverables:**
1656
- 1. **Pillar Page Concept** - What should this main article establish?
1657
- 2. **Cluster Topics** - 5-7 related subtopics for future articles
1658
- 3. **Internal Linking Plan** - How these articles connect
1659
- 4. **Content Gaps** - What topics are missing in this niche?
1660
-
1661
- Note: Focus on the CURRENT article structure, but identify opportunities for a content cluster.`,
1662
- store: 'topical_map'
1663
- })
1664
-
1665
- // Step 4: Content Calendar (only for multi-article requests)
1666
- if (count > 1) {
1667
- stepNum++
1668
- steps.push({
1669
- step: stepNum,
1670
- type: 'llm_execute',
1671
- action: 'content_calendar',
1672
- instruction: `Plan content calendar for ${count} articles about: "${request}"
1673
-
1674
- **Project Context:**
1675
- - Site: ${siteName}
1676
- - Niche: ${niche}
1677
- - Articles to create: ${count}
1678
-
1679
- **Deliverables:**
1680
- 1. **Article Sequence** - Order to create articles (foundational → specific)
1681
- 2. **Topic List** - ${count} specific titles/topics
1682
- 3. **Keyword Assignment** - Primary keyword for each article
1683
- 4. **Publishing Cadence** - Recommended frequency
1684
-
1685
- Note: This guides the creation of all ${count} articles in this session.`,
1686
- store: 'content_calendar'
1687
- })
1688
- }
1689
-
1690
- // ═══════════════════════════════════════════════════════════
1691
- // CREATION PHASE
1692
- // ═══════════════════════════════════════════════════════════
1693
-
1694
- // Step N: Content Planning with SEO Meta
1695
- stepNum++
1696
- steps.push({
1697
- step: stepNum,
1698
- type: 'llm_execute',
1699
- action: 'content_planning',
1700
- instruction: `Create a detailed content outline with SEO meta:
1701
-
1702
- **Project Requirements (from database):**
1703
- - Site: ${siteName}
1704
- - Target audience: ${targetAudience || 'Not specified'}
1705
- - Brand voice: ${brandVoice}
1706
- - Brand differentiators: ${differentiators.length > 0 ? differentiators.join(', ') : 'Not set'}
1707
- - Word count: **${targetWordCount} words MINIMUM** (this is required!)
1708
- - Reading level: **${readingLevelDisplay}** (use simple sentences, avoid jargon)
1709
-
1710
- **You MUST create:**
1711
-
1712
- 1. **SEO Meta Title** (50-60 characters, include primary keyword)
1713
- 2. **SEO Meta Description** (150-160 characters, compelling, include keyword)
1714
- 3. **URL Slug** (lowercase, hyphens, keyword-rich)
1715
- 4. **Content Outline:**
1716
- - H1: Main title
1717
- - 6-8 H2 sections (to achieve ${targetWordCount} words)
1718
- - H3 subsections where needed
1719
- - FAQ section with 4-5 questions
1720
-
1721
- ${shouldGenerateImages ? `**Image Placeholders:** Mark where ${contentImageCount} inline images should go (1 every ~300 words)
1722
- Use format: [IMAGE: description of what image should show]` : '**Note:** Images disabled for this project.'}`,
1723
- store: 'outline'
1724
- })
1725
-
1726
- // Step 3: Write Content
1727
- stepNum++
1728
- steps.push({
1729
- step: stepNum,
1730
- type: 'llm_execute',
1731
- action: 'content_write',
1732
- instruction: `Write the COMPLETE article following your outline.
1733
-
1734
- ╔══════════════════════════════════════════════════════════════════╗
1735
- ║ 🚨 MANDATORY WORD COUNT: ${targetWordCount} WORDS MINIMUM 🚨 ║
1736
- ║ This is a strict requirement from the project settings. ║
1737
- ║ The article will be REJECTED if under ${targetWordCount} words. ║
1738
- ╚══════════════════════════════════════════════════════════════════╝
1739
-
1740
- **Project Requirements (from Supabase database - DO NOT IGNORE):**
1741
- - Word count: **${targetWordCount} words** (MINIMUM - not a suggestion!)
1742
- - Reading level: **${readingLevelDisplay}** - Simple sentences, short paragraphs
1743
- - Brand voice: ${brandVoice}
1744
- - Target audience: ${targetAudience || 'General readers'}
1745
-
1746
- **To reach ${targetWordCount} words, you MUST:**
1747
- - Write 8-10 substantial H2 sections (each 200-400 words)
1748
- - Include detailed examples, statistics, and actionable advice
1749
- - Add comprehensive FAQ section (5-8 questions)
1750
- - Expand each point with thorough explanations
1751
-
1752
- **Content Structure:**
1753
- - Engaging hook in first 2 sentences
1754
- - All H2/H3 sections from your outline (expand each thoroughly!)
1755
- - Statistics, examples, and actionable tips in EVERY section
1756
- ${shouldGenerateImages ? '- Image placeholders: [IMAGE: description] where images should go' : ''}
1757
- - FAQ section with 5-8 Q&As (detailed answers, not one-liners)
1758
- - Strong conclusion with clear CTA
1759
-
1760
- **After writing ${targetWordCount}+ words, call 'save_content' with:**
1761
- - title: Your SEO-optimized title
1762
- - content: The full article (markdown)
1763
- - keywords: Array of target keywords
1764
- - meta_description: Your 150-160 char meta description
1765
-
1766
- ⛔ STOP! Before calling save_content, verify you have ${targetWordCount}+ words.
1767
- Count the words. If under ${targetWordCount}, ADD MORE CONTENT.`,
1768
- store: 'article'
1769
- })
1770
-
1771
- // ═══════════════════════════════════════════════════════════
1772
- // OPTIMIZATION PHASE
1773
- // ═══════════════════════════════════════════════════════════
1774
-
1775
- // Quality Check - Pre-publish QA
1776
- stepNum++
1777
- steps.push({
1778
- step: stepNum,
1779
- type: 'llm_execute',
1780
- action: 'quality_check',
1781
- instruction: `Perform quality check on the article you just saved.
1782
-
1783
- **Quality Checklist:**
1784
-
1785
- 1. **SEO Check:**
1786
- - ✓ Primary keyword in H1, first 100 words, URL slug
1787
- - ✓ Secondary keywords distributed naturally
1788
- - ✓ Meta title 50-60 characters
1789
- - ✓ Meta description 150-160 characters
1790
- - ✓ Proper header hierarchy (H1 → H2 → H3)
1791
-
1792
- 2. **Content Quality:**
1793
- - ✓ Word count meets requirement (${targetWordCount}+ words)
1794
- - ✓ Reading level appropriate (${readingLevelDisplay})
1795
- - ✓ No grammar or spelling errors
1796
- - ✓ Factual accuracy (no made-up statistics)
1797
-
1798
- 3. **Brand Consistency:**
1799
- - ✓ Voice matches: ${brandVoice}
1800
- - ✓ Speaks to: ${targetAudience || 'target audience'}
1801
- - ✓ Aligns with ${siteName} brand
1802
-
1803
- 4. **Engagement:**
1804
- - ✓ Strong hook in introduction
1805
- - ✓ Clear value proposition
1806
- - ✓ Actionable takeaways
1807
- - ✓ Compelling CTA in conclusion
1808
-
1809
- **Report any issues found and suggest fixes. If major issues exist, fix them before proceeding.**`,
1810
- store: 'quality_report'
1811
- })
1812
-
1813
- // GEO Optimize - AI Search Engine Optimization
1814
- stepNum++
1815
- steps.push({
1816
- step: stepNum,
1817
- type: 'llm_execute',
1818
- action: 'geo_optimize',
1819
- instruction: `Optimize article for AI search engines (ChatGPT, Perplexity, Google SGE, Claude).
1820
-
1821
- **GEO (Generative Engine Optimization) Checklist:**
1822
-
1823
- 1. **Structured Answers:**
1824
- - ✓ Clear, direct answers to common questions
1825
- - ✓ Definition boxes for key terms
1826
- - ✓ TL;DR sections for complex topics
1827
-
1828
- 2. **Citation-Worthy Content:**
1829
- - ✓ Original statistics or data points
1830
- - ✓ Expert quotes or authoritative sources
1831
- - ✓ Unique insights not found elsewhere
1832
-
1833
- 3. **LLM-Friendly Structure:**
1834
- - ✓ Bulleted lists for easy extraction
1835
- - ✓ Tables for comparisons
1836
- - ✓ Step-by-step numbered processes
1837
-
1838
- 4. **Semantic Clarity:**
1839
- - ✓ Clear topic sentences per paragraph
1840
- - ✓ Explicit cause-effect relationships
1841
- - ✓ Avoid ambiguous pronouns
1842
-
1843
- **Target AI Engines:**
1844
- - ChatGPT (conversational answers)
1845
- - Perplexity (citation-heavy)
1846
- - Google SGE (structured snippets)
1847
- - Claude (comprehensive analysis)
1848
-
1849
- **Review the saved article and suggest specific improvements to make it more likely to be cited by AI search engines.**`,
1850
- store: 'geo_report'
1851
- })
1852
-
1853
- // ═══════════════════════════════════════════════════════════
1854
- // PUBLISHING PHASE
1855
- // ═══════════════════════════════════════════════════════════
1856
-
1857
- // Generate Images (if enabled in project settings AND credentials available)
1858
- if (shouldGenerateImages) {
1859
- // Format brand colors for image style guidance
1860
- const colorsDisplay = brandColors.length > 0 ? brandColors.join(', ') : 'Not specified'
1861
-
1862
- stepNum++
1863
- steps.push({
1864
- step: stepNum,
1865
- type: 'llm_execute',
1866
- action: 'generate_images',
1867
- instruction: `Generate ${totalImages} images for the article:
1868
-
1869
- **Required Images:**
1870
- 1. **Cover/Hero Image** - Main article header (16:9 aspect ratio)
1871
- ${Array.from({length: contentImageCount}, (_, i) => `${i + 2}. **Section Image ${i + 1}** - For content section ${i + 1} (16:9 aspect ratio)`).join('\n')}
1872
-
1873
- **For each image, call 'generate_image' tool with:**
1874
- - prompt: Detailed description based on article content
1875
- - style: ${visualStyle || 'professional minimalist'}
1876
- - aspect_ratio: 16:9
1877
-
1878
- **Visual Style (from project database):**
1879
- - Image aesthetic: ${visualStyle || 'Not specified'}
1880
- - Brand colors: ${colorsDisplay}
1881
- - Keep consistent with ${siteName} brand identity
1882
-
1883
- **Image Style Guide:**
1884
- - Professional, clean aesthetic
1885
- - Relevant to the section topic
1886
- - No text in images
1887
- - Consistent style across all images
1888
-
1889
- After generating, note the URLs - they will be saved automatically for publishing.`,
1890
- image_count: totalImages,
1891
- store: 'images'
1892
- })
1893
- }
1894
-
1895
- // Step 5: Publish
1896
- if (targets.length > 0) {
1897
- stepNum++
1898
- steps.push({
1899
- step: stepNum,
1900
- type: 'action',
1901
- action: 'publish',
1902
- instruction: `Publish the article to: ${targets.join(', ')}
1903
-
1904
- Call 'publish_content' tool - it will automatically use:
1905
- - Saved article title and content
1906
- - SEO meta description
1907
- - Generated images (cover + inline)
1908
- - Target keywords as tags`,
1909
- targets: targets
1910
- })
1911
- }
1912
-
1913
- return {
1914
- workflow_id: `wf_${Date.now()}`,
1915
- request: request,
1916
- total_articles: count,
1917
- current_article: 1,
1918
- total_steps: steps.length,
1919
- current_step: 1,
1920
- // All settings come from project.config (database) - no hardcoded values
1921
- project_info: {
1922
- name: siteName,
1923
- url: siteUrl,
1924
- niche: niche
1925
- },
1926
- settings: {
1927
- target_word_count: targetWordCount,
1928
- reading_level: readingLevel,
1929
- reading_level_display: readingLevelDisplay,
1930
- brand_voice: brandVoice,
1931
- target_audience: targetAudience,
1932
- include_images: includeImages,
1933
- total_images: totalImages,
1934
- content_images: contentImageCount,
1935
- visual_style: visualStyle,
1936
- primary_keywords: primaryKeywords,
1937
- geo_focus: geoFocus
1938
- },
1939
- available_integrations: {
1940
- external_mcps: externalMcps.map(m => m.name),
1941
- ghost: hasGhost,
1942
- wordpress: hasWordPress,
1943
- image_generation: hasImageGen
1944
- },
1945
- steps: steps
1946
- }
1947
- }
1948
-
1949
- /**
1950
- * Execute orchestrator tools
1951
- */
1952
- async function executeOrchestratorTool(toolName, args, project) {
1953
- switch (toolName) {
1954
- case 'create_content': {
1955
- resetSession()
1956
- const { request = '', count = 1, publish_to = [], with_images = true } = args
1957
-
1958
- const plan = buildWorkflowPlan(
1959
- request || `content about ${project?.niche || 'the project topic'}`,
1960
- count,
1961
- publish_to,
1962
- with_images,
1963
- project
1964
- )
1965
-
1966
- sessionState.currentWorkflow = plan
1967
-
1968
- // Persist session to file for workflow continuity
1969
- saveSession()
1970
-
1971
- // Build response with clear instructions - all data from database
1972
- const mcpList = plan.available_integrations.external_mcps.length > 0
1973
- ? plan.available_integrations.external_mcps.join(', ')
1974
- : 'None configured'
1975
-
1976
- let response = `# 🚀 Content Creation Workflow Started
1977
-
1978
- ╔══════════════════════════════════════════════════════════════════════════════╗
1979
- ║ 📊 PROJECT REQUIREMENTS (from Supabase database) ║
1980
- ║ Word Count: ${String(plan.settings.target_word_count).padEnd(6)} words (MINIMUM - strictly enforced!) ║
1981
- ║ Brand Voice: ${String(plan.settings.brand_voice || 'Not set').substring(0, 50).padEnd(50)} ║
1982
- ║ Target Audience: ${String(plan.settings.target_audience || 'Not set').substring(0, 45).padEnd(45)} ║
1983
- ╚══════════════════════════════════════════════════════════════════════════════╝
1984
-
1985
- ## Your Request
1986
- "${plan.request}"
1987
-
1988
- ## Project: ${plan.project_info.name}
1989
- - **URL:** ${plan.project_info.url}
1990
- - **Niche:** ${plan.project_info.niche}
1991
-
1992
- ## Content Settings (from database - DO NOT USE DEFAULTS)
1993
- | Setting | Value |
1994
- |---------|-------|
1995
- | **Word Count** | ${plan.settings.target_word_count} words |
1996
- | **Reading Level** | ${plan.settings.reading_level_display} |
1997
- | **Brand Voice** | ${plan.settings.brand_voice} |
1998
- | **Target Audience** | ${plan.settings.target_audience || 'Not specified'} |
1999
- | **Primary Keywords** | ${plan.settings.primary_keywords?.join(', ') || 'Not set'} |
2000
- | **Geographic Focus** | ${plan.settings.geo_focus || 'Global'} |
2001
- | **Visual Style** | ${plan.settings.visual_style || 'Not specified'} |
2002
- | **Include Images** | ${plan.settings.include_images ? 'Yes' : 'No'} |
2003
- | **Images Required** | ${plan.settings.total_images} (1 cover + ${plan.settings.content_images} inline) |
2004
-
2005
- ## Workflow Plan (4 Phases)
2006
-
2007
- ### RESEARCH PHASE
2008
- ${plan.steps.filter(s => ['keyword_research', 'seo_strategy', 'topical_map', 'content_calendar'].includes(s.action)).map(s => `${s.step}. **${s.action}**`).join('\n')}
2009
-
2010
- ### CREATION PHASE
2011
- ${plan.steps.filter(s => ['content_planning', 'content_write'].includes(s.action)).map(s => `${s.step}. **${s.action}**`).join('\n')}
2012
-
2013
- ### OPTIMIZATION PHASE
2014
- ${plan.steps.filter(s => ['quality_check', 'geo_optimize'].includes(s.action)).map(s => `${s.step}. **${s.action}**`).join('\n')}
2015
-
2016
- ### PUBLISHING PHASE
2017
- ${plan.steps.filter(s => ['generate_images', 'publish'].includes(s.action)).map(s => `${s.step}. **${s.action}**`).join('\n')}
2018
-
2019
- ## Available Integrations (from ~/.suparank/credentials.json)
2020
- - External MCPs: ${mcpList}
2021
- - Image Generation: ${plan.available_integrations.image_generation ? '✅ Ready' : '❌ Not configured'}
2022
- - Ghost CMS: ${plan.available_integrations.ghost ? '✅ Ready' : '❌ Not configured'}
2023
- - WordPress: ${plan.available_integrations.wordpress ? '✅ Ready' : '❌ Not configured'}
2024
-
2025
- ---
2026
-
2027
- ## Step 1 of ${plan.total_steps}: ${plan.steps[0].action.toUpperCase()}
2028
-
2029
- ${plan.steps[0].instruction}
2030
-
2031
- ---
2032
-
2033
- **When you complete this step, move to Step 2.**
2034
- `
2035
-
2036
- return {
2037
- content: [{
2038
- type: 'text',
2039
- text: response
2040
- }]
2041
- }
2042
- }
2043
-
2044
- case 'save_content': {
2045
- const { title, content, keywords = [], meta_description = '' } = args
2046
- const wordCount = content.split(/\s+/).length
2047
-
2048
- // Create article object with unique ID
2049
- const articleId = generateArticleId()
2050
- const newArticle = {
2051
- id: articleId,
2052
- title,
2053
- content,
2054
- keywords,
2055
- metaDescription: meta_description,
2056
- metaTitle: title,
2057
- imageUrl: sessionState.imageUrl || null, // Attach any generated cover image
2058
- inlineImages: [...sessionState.inlineImages], // Copy current inline images
2059
- savedAt: new Date().toISOString(),
2060
- published: false,
2061
- publishedTo: [],
2062
- wordCount
2063
- }
2064
-
2065
- // Add to articles array (not overwriting previous articles!)
2066
- sessionState.articles.push(newArticle)
2067
-
2068
- // Track stats
2069
- incrementStat('articles_created')
2070
- incrementStat('words_written', wordCount)
2071
-
2072
- // Also keep in current working fields for backwards compatibility
2073
- sessionState.title = title
2074
- sessionState.article = content
2075
- sessionState.keywords = keywords
2076
- sessionState.metaDescription = meta_description
2077
- sessionState.metadata = { meta_description }
2078
-
2079
- // Persist session to file and save to content folder
2080
- saveSession()
2081
- const contentFolder = saveContentToFolder()
2082
-
2083
- progress('Content', `Saved "${title}" (${wordCount} words) as article #${sessionState.articles.length}${contentFolder ? ` → ${contentFolder}` : ''}`)
2084
-
2085
- // Clear current working images so next article starts fresh
2086
- // (images are already attached to the saved article)
2087
- sessionState.imageUrl = null
2088
- sessionState.inlineImages = []
2089
-
2090
- const workflow = sessionState.currentWorkflow
2091
- const targetWordCount = workflow?.settings?.target_word_count
2092
- // Only 5% tolerance - 2500 word target means minimum 2375 words
2093
- const wordCountOk = targetWordCount ? wordCount >= targetWordCount * 0.95 : true
2094
- const shortfall = targetWordCount ? targetWordCount - wordCount : 0
2095
-
2096
- // Log word count check
2097
- log(`Word count check: ${wordCount} words (target: ${targetWordCount}, ok: ${wordCountOk})`)
2098
-
2099
- // Find next step
2100
- const imageStep = workflow?.steps?.find(s => s.action === 'generate_images')
2101
- const totalImages = workflow?.settings?.total_images || 0
2102
- const includeImages = workflow?.settings?.include_images
2103
-
2104
- // Fetch WordPress categories for intelligent assignment
2105
- let categoriesSection = ''
2106
- if (hasCredential('wordpress')) {
2107
- const wpCategories = await fetchWordPressCategories()
2108
- if (wpCategories && wpCategories.length > 0) {
2109
- const categoryList = wpCategories
2110
- .slice(0, 15) // Show top 15 by post count
2111
- .map(c => `- **${c.name}** (${c.count} posts)${c.description ? `: ${c.description}` : ''}`)
2112
- .join('\n')
2113
- categoriesSection = `\n## WordPress Categories Available
2114
- Pick the most relevant category when publishing:
2115
- ${categoryList}
2116
-
2117
- When calling \`publish_content\`, include the \`category\` parameter with your choice.\n`
2118
- }
2119
- }
2120
-
2121
- // Show all articles in session
2122
- const articlesListSection = sessionState.articles.length > 1 ? `
2123
- ## Articles in Session (${sessionState.articles.length} total)
2124
- ${sessionState.articles.map((art, i) => {
2125
- const status = art.published ? `✅ published to ${art.publishedTo.join(', ')}` : '📝 unpublished'
2126
- return `${i + 1}. **${art.title}** (${art.wordCount} words) - ${status}`
2127
- }).join('\n')}
2128
-
2129
- Use \`publish_content\` to publish all unpublished articles, or \`get_session\` to see full details.
2130
- ` : ''
2131
-
2132
- return {
2133
- content: [{
2134
- type: 'text',
2135
- text: `# ✅ Content Saved to Session (Article #${sessionState.articles.length})
2136
-
2137
- **Title:** ${title}
2138
- **Article ID:** ${articleId}
2139
- **Word Count:** ${wordCount} words ${targetWordCount ? (wordCountOk ? '✅' : `⚠️ (target: ${targetWordCount})`) : '(no target set)'}
2140
- **Meta Description:** ${meta_description ? `${meta_description.length} chars ✅` : '❌ Missing!'}
2141
- **Keywords:** ${keywords.join(', ') || 'none specified'}
2142
- **Images:** ${newArticle.imageUrl ? '1 cover' : 'no cover'}${newArticle.inlineImages.length > 0 ? ` + ${newArticle.inlineImages.length} inline` : ''}
2143
-
2144
- ${targetWordCount && !wordCountOk ? `
2145
- ╔══════════════════════════════════════════════════════════════════════════╗
2146
- ║ ⛔ WORD COUNT NOT MET - ${shortfall} WORDS SHORT! ║
2147
- ║ Target: ${targetWordCount} words | Actual: ${wordCount} words ║
2148
- ║ ║
2149
- ║ The article does not meet the project's word count requirement. ║
2150
- ║ Please EXPAND the content before publishing: ║
2151
- ║ - Add more detailed explanations to each section ║
2152
- ║ - Include additional examples and statistics ║
2153
- ║ - Expand the FAQ section with more questions ║
2154
- ║ - Add more H2 sections if needed ║
2155
- ╚══════════════════════════════════════════════════════════════════════════╝
2156
- ` : ''}
2157
- ${!meta_description ? '⚠️ **Warning:** Meta description is missing. Add it for better SEO.\n' : ''}
2158
- ${articlesListSection}${categoriesSection}
2159
- ## Next Step${includeImages && imageStep ? ': Generate Images' : ': Ready to Publish or Continue'}
2160
- ${includeImages && imageStep ? `Generate **${totalImages} images** (1 cover + ${totalImages - 1} inline images).
2161
-
2162
- Call \`generate_image\` ${totalImages} times with prompts based on your article sections.` : `You can:
2163
- - **Add more articles**: Continue creating content (each save_content adds to the batch)
2164
- - **Publish all**: Call \`publish_content\` to publish all ${sessionState.articles.length} article(s)
2165
- - **View session**: Call \`get_session\` to see all saved articles`}`
2166
- }]
2167
- }
2168
- }
2169
-
2170
- case 'publish_content': {
2171
- const { platforms = ['all'], status = 'draft', category = '', article_numbers = [] } = args
2172
-
2173
- // Determine which articles to publish
2174
- let articlesToPublish = []
2175
-
2176
- if (article_numbers && article_numbers.length > 0) {
2177
- // Publish specific articles by number (1-indexed)
2178
- articlesToPublish = article_numbers
2179
- .map(num => sessionState.articles[num - 1])
2180
- .filter(art => art && !art.published)
2181
-
2182
- if (articlesToPublish.length === 0) {
2183
- return {
2184
- content: [{
2185
- type: 'text',
2186
- text: `❌ No valid unpublished articles found for numbers: ${article_numbers.join(', ')}
2187
-
2188
- Use \`get_session\` to see available articles and their numbers.`
2189
- }]
2190
- }
2191
- }
2192
- } else {
2193
- // Publish all unpublished articles
2194
- articlesToPublish = sessionState.articles.filter(art => !art.published)
2195
- }
2196
-
2197
- // Fallback: Check if there's a current working article not yet saved
2198
- if (articlesToPublish.length === 0 && sessionState.article && sessionState.title) {
2199
- // Create temporary article from current working state for backwards compatibility
2200
- articlesToPublish = [{
2201
- id: 'current',
2202
- title: sessionState.title,
2203
- content: sessionState.article,
2204
- keywords: sessionState.keywords || [],
2205
- metaDescription: sessionState.metaDescription || '',
2206
- imageUrl: sessionState.imageUrl,
2207
- inlineImages: sessionState.inlineImages
2208
- }]
2209
- }
2210
-
2211
- if (articlesToPublish.length === 0) {
2212
- return {
2213
- content: [{
2214
- type: 'text',
2215
- text: `❌ No unpublished articles found in session.
2216
-
2217
- Use \`save_content\` after writing an article, then call \`publish_content\`.
2218
- Or use \`get_session\` to see current session state.`
2219
- }]
2220
- }
2221
- }
2222
-
2223
- const hasGhost = hasCredential('ghost')
2224
- const hasWordPress = hasCredential('wordpress')
2225
- const shouldPublishGhost = hasGhost && (platforms.includes('all') || platforms.includes('ghost'))
2226
- const shouldPublishWordPress = hasWordPress && (platforms.includes('all') || platforms.includes('wordpress'))
2227
-
2228
- // Results for all articles
2229
- const allResults = []
2230
-
2231
- progress('Publishing', `Starting batch publish of ${articlesToPublish.length} article(s)`)
2232
-
2233
- // Publish each article
2234
- for (let i = 0; i < articlesToPublish.length; i++) {
2235
- const article = articlesToPublish[i]
2236
- progress('Publishing', `Article ${i + 1}/${articlesToPublish.length}: "${article.title}"`)
2237
-
2238
- // Inject inline images into content (replace [IMAGE: ...] placeholders)
2239
- let contentWithImages = article.content
2240
- let imageIndex = 0
2241
- const articleInlineImages = article.inlineImages || []
2242
- contentWithImages = contentWithImages.replace(/\[IMAGE:\s*([^\]]+)\]/gi, (match, description) => {
2243
- if (imageIndex < articleInlineImages.length) {
2244
- const imgUrl = articleInlineImages[imageIndex]
2245
- imageIndex++
2246
- return `![${description.trim()}](${imgUrl})`
2247
- }
2248
- return match // Keep placeholder if no image available
2249
- })
2250
-
2251
- const articleResults = {
2252
- article: article.title,
2253
- articleId: article.id,
2254
- wordCount: article.wordCount || contentWithImages.split(/\s+/).length,
2255
- platforms: []
2256
- }
2257
-
2258
- // Publish to Ghost
2259
- if (shouldPublishGhost) {
2260
- try {
2261
- const ghostResult = await executeGhostPublish({
2262
- title: article.title,
2263
- content: contentWithImages,
2264
- status: status,
2265
- tags: article.keywords || [],
2266
- featured_image_url: article.imageUrl
2267
- })
2268
- articleResults.platforms.push({ platform: 'Ghost', success: true, result: ghostResult })
2269
- } catch (e) {
2270
- articleResults.platforms.push({ platform: 'Ghost', success: false, error: e.message })
2271
- }
2272
- }
2273
-
2274
- // Publish to WordPress
2275
- if (shouldPublishWordPress) {
2276
- try {
2277
- const categories = category ? [category] : []
2278
- const wpResult = await executeWordPressPublish({
2279
- title: article.title,
2280
- content: contentWithImages,
2281
- status: status,
2282
- categories: categories,
2283
- tags: article.keywords || [],
2284
- featured_image_url: article.imageUrl
2285
- })
2286
- articleResults.platforms.push({ platform: 'WordPress', success: true, result: wpResult })
2287
- } catch (e) {
2288
- articleResults.platforms.push({ platform: 'WordPress', success: false, error: e.message })
2289
- }
2290
- }
2291
-
2292
- // Mark article as published if at least one platform succeeded
2293
- const hasSuccess = articleResults.platforms.some(p => p.success)
2294
- if (hasSuccess && article.id !== 'current') {
2295
- const articleIndex = sessionState.articles.findIndex(a => a.id === article.id)
2296
- if (articleIndex !== -1) {
2297
- sessionState.articles[articleIndex].published = true
2298
- sessionState.articles[articleIndex].publishedTo = articleResults.platforms
2299
- .filter(p => p.success)
2300
- .map(p => p.platform.toLowerCase())
2301
- sessionState.articles[articleIndex].publishedAt = new Date().toISOString()
2302
- }
2303
- }
2304
-
2305
- allResults.push(articleResults)
2306
- }
2307
-
2308
- // Save updated session state (with published flags)
2309
- saveSession()
2310
-
2311
- // Build response
2312
- const totalArticles = allResults.length
2313
- const successfulArticles = allResults.filter(r => r.platforms.some(p => p.success)).length
2314
- const totalWords = allResults.reduce((sum, r) => sum + r.wordCount, 0)
2315
-
2316
- let response = `# 📤 Batch Publishing Results
2317
-
2318
- ## Summary
2319
- - **Articles Published:** ${successfulArticles}/${totalArticles}
2320
- - **Total Words:** ${totalWords.toLocaleString()}
2321
- - **Status:** ${status}
2322
- - **Platforms:** ${[shouldPublishGhost ? 'Ghost' : null, shouldPublishWordPress ? 'WordPress' : null].filter(Boolean).join(', ') || 'None'}
2323
- ${category ? `- **Category:** ${category}` : ''}
2324
-
2325
- ---
2326
-
2327
- `
2328
-
2329
- // Detail for each article
2330
- for (const result of allResults) {
2331
- const hasAnySuccess = result.platforms.some(p => p.success)
2332
- response += `## ${hasAnySuccess ? '✅' : '❌'} ${result.article}\n`
2333
- response += `**Words:** ${result.wordCount}\n\n`
2334
-
2335
- for (const p of result.platforms) {
2336
- if (p.success) {
2337
- response += `**${p.platform}:** ✅ Published\n`
2338
- // Extract URL if available
2339
- const resultText = p.result?.content?.[0]?.text || ''
2340
- const urlMatch = resultText.match(/https?:\/\/[^\s\)]+/)
2341
- if (urlMatch) {
2342
- response += `URL: ${urlMatch[0]}\n`
2343
- }
2344
- } else {
2345
- response += `**${p.platform}:** ❌ ${p.error}\n`
2346
- }
2347
- }
2348
- response += '\n'
2349
- }
2350
-
2351
- // Show remaining unpublished articles
2352
- const remainingUnpublished = sessionState.articles.filter(a => !a.published)
2353
- if (remainingUnpublished.length > 0) {
2354
- response += `---\n\n**📝 ${remainingUnpublished.length} article(s) still unpublished** in session.\n`
2355
- response += `Call \`publish_content\` again to publish remaining, or \`get_session\` to see details.\n`
2356
- } else if (sessionState.articles.length > 0) {
2357
- response += `---\n\n✅ **All ${sessionState.articles.length} articles published!**\n`
2358
- response += `Session retained for reference. Start a new workflow to clear.\n`
2359
- }
2360
-
2361
- return {
2362
- content: [{
2363
- type: 'text',
2364
- text: response
2365
- }]
2366
- }
2367
- }
2368
-
2369
- case 'get_session': {
2370
- const totalImagesNeeded = sessionState.currentWorkflow?.settings?.total_images || 0
2371
- const imagesGenerated = (sessionState.imageUrl ? 1 : 0) + sessionState.inlineImages.length
2372
- const workflow = sessionState.currentWorkflow
2373
-
2374
- // Count totals across all articles
2375
- const totalArticles = sessionState.articles.length
2376
- const unpublishedArticles = sessionState.articles.filter(a => !a.published)
2377
- const publishedArticles = sessionState.articles.filter(a => a.published)
2378
- const totalWords = sessionState.articles.reduce((sum, a) => sum + (a.wordCount || 0), 0)
2379
- const totalImages = sessionState.articles.reduce((sum, a) => {
2380
- return sum + (a.imageUrl ? 1 : 0) + (a.inlineImages?.length || 0)
2381
- }, 0)
2382
-
2383
- // Build articles list
2384
- const articlesSection = sessionState.articles.length > 0 ? `
2385
- ## 📚 Saved Articles (${totalArticles} total)
2386
-
2387
- | # | Title | Words | Images | Status |
2388
- |---|-------|-------|--------|--------|
2389
- ${sessionState.articles.map((art, i) => {
2390
- const imgCount = (art.imageUrl ? 1 : 0) + (art.inlineImages?.length || 0)
2391
- const status = art.published ? `✅ ${art.publishedTo.join(', ')}` : '📝 Unpublished'
2392
- return `| ${i + 1} | ${art.title.substring(0, 40)}${art.title.length > 40 ? '...' : ''} | ${art.wordCount} | ${imgCount} | ${status} |`
2393
- }).join('\n')}
2394
-
2395
- **Summary:** ${totalWords.toLocaleString()} total words, ${totalImages} total images
2396
- **Unpublished:** ${unpublishedArticles.length} article(s) ready to publish
2397
- ` : `
2398
- ## 📚 Saved Articles
2399
- No articles saved yet. Use \`save_content\` after writing an article.
2400
- `
2401
-
2402
- // Current working article (if any in progress)
2403
- const currentWorkingSection = sessionState.title && sessionState.article ? `
2404
- ## 🖊️ Current Working Article
2405
- **Title:** ${sessionState.title}
2406
- **Word Count:** ${sessionState.article.split(/\s+/).length} words
2407
- **Meta Description:** ${sessionState.metaDescription || 'Not set'}
2408
- **Cover Image:** ${sessionState.imageUrl ? '✅ Generated' : '❌ Not yet'}
2409
- **Inline Images:** ${sessionState.inlineImages.length}
2410
-
2411
- *This article is being edited. Call \`save_content\` to add it to the session.*
2412
- ` : ''
2413
-
2414
- return {
2415
- content: [{
2416
- type: 'text',
2417
- text: `# 📋 Session State
2418
-
2419
- **Workflow:** ${workflow?.workflow_id || 'None active'}
2420
- **Total Articles:** ${totalArticles}
2421
- **Ready to Publish:** ${unpublishedArticles.length}
2422
- **Already Published:** ${publishedArticles.length}
2423
- ${articlesSection}${currentWorkingSection}
2424
- ## 🖼️ Current Working Images (${imagesGenerated}/${totalImagesNeeded})
2425
- **Cover Image:** ${sessionState.imageUrl || 'Not generated'}
2426
- **Inline Images:** ${sessionState.inlineImages.length > 0 ? sessionState.inlineImages.map((url, i) => `\n ${i+1}. ${url.substring(0, 60)}...`).join('') : 'None'}
2427
-
2428
- ${workflow ? `
2429
- ## ⚙️ Project Settings
2430
- - **Project:** ${workflow.project_info?.name || 'Unknown'}
2431
- - **Niche:** ${workflow.project_info?.niche || 'Unknown'}
2432
- - **Word Count Target:** ${workflow.settings?.target_word_count || 'Not set'}
2433
- - **Reading Level:** ${workflow.settings?.reading_level_display || 'Not set'}
2434
- - **Brand Voice:** ${workflow.settings?.brand_voice || 'Not set'}
2435
- - **Include Images:** ${workflow.settings?.include_images ? 'Yes' : 'No'}
2436
- ` : ''}
2437
- ## 🚀 Actions
2438
- - **Publish all unpublished:** Call \`publish_content\`
2439
- - **Add more articles:** Use \`create_content\` or \`content_write\` then \`save_content\`
2440
- - **Remove articles:** Call \`remove_article\` with article numbers
2441
- - **Clear session:** Call \`clear_session\` with confirm: true`
2442
- }]
2443
- }
2444
- }
2445
-
2446
- case 'remove_article': {
2447
- const { article_numbers } = args
2448
-
2449
- if (!article_numbers || article_numbers.length === 0) {
2450
- return {
2451
- content: [{
2452
- type: 'text',
2453
- text: `❌ Please specify article numbers to remove. Use \`get_session\` to see article numbers.`
2454
- }]
2455
- }
2456
- }
2457
-
2458
- // Sort in descending order to avoid index shifting issues
2459
- const sortedNumbers = [...article_numbers].sort((a, b) => b - a)
2460
- const removed = []
2461
- const skipped = []
2462
-
2463
- for (const num of sortedNumbers) {
2464
- const index = num - 1
2465
- if (index < 0 || index >= sessionState.articles.length) {
2466
- skipped.push({ num, reason: 'not found' })
2467
- continue
2468
- }
2469
-
2470
- const article = sessionState.articles[index]
2471
- if (article.published) {
2472
- skipped.push({ num, reason: 'already published', title: article.title })
2473
- continue
2474
- }
2475
-
2476
- // Remove the article
2477
- const [removedArticle] = sessionState.articles.splice(index, 1)
2478
- removed.push({ num, title: removedArticle.title })
2479
- }
2480
-
2481
- // Save session
2482
- saveSession()
2483
-
2484
- let response = `# 🗑️ Article Removal Results\n\n`
2485
-
2486
- if (removed.length > 0) {
2487
- response += `## ✅ Removed (${removed.length})\n`
2488
- for (const r of removed) {
2489
- response += `- #${r.num}: "${r.title}"\n`
2490
- }
2491
- response += '\n'
2492
- }
2493
-
2494
- if (skipped.length > 0) {
2495
- response += `## ⚠️ Skipped (${skipped.length})\n`
2496
- for (const s of skipped) {
2497
- if (s.reason === 'already published') {
2498
- response += `- #${s.num}: "${s.title}" (already published - cannot remove)\n`
2499
- } else {
2500
- response += `- #${s.num}: not found\n`
2501
- }
2502
- }
2503
- response += '\n'
2504
- }
2505
-
2506
- response += `---\n\n**${sessionState.articles.length} article(s) remaining in session.**`
2507
-
2508
- return {
2509
- content: [{
2510
- type: 'text',
2511
- text: response
2512
- }]
2513
- }
2514
- }
2515
-
2516
- case 'clear_session': {
2517
- const { confirm } = args
2518
-
2519
- if (!confirm) {
2520
- return {
2521
- content: [{
2522
- type: 'text',
2523
- text: `⚠️ **Clear Session requires confirmation**
2524
-
2525
- This will permanently remove:
2526
- - ${sessionState.articles.length} saved article(s)
2527
- - All generated images
2528
- - Current workflow state
2529
-
2530
- To confirm, call \`clear_session\` with \`confirm: true\``
2531
- }]
2532
- }
2533
- }
2534
-
2535
- const articleCount = sessionState.articles.length
2536
- const unpublishedCount = sessionState.articles.filter(a => !a.published).length
2537
-
2538
- // Clear everything
2539
- resetSession()
2540
-
2541
- return {
2542
- content: [{
2543
- type: 'text',
2544
- text: `# ✅ Session Cleared
2545
-
2546
- Removed:
2547
- - ${articleCount} article(s) (${unpublishedCount} unpublished)
2548
- - All workflow state
2549
- - All generated images
2550
-
2551
- Session is now empty. Ready for new content creation.`
2552
- }]
2553
- }
2554
- }
2555
-
2556
- case 'list_content': {
2557
- const { limit = 20 } = args
2558
- const contentDir = getContentDir()
2559
-
2560
- if (!fs.existsSync(contentDir)) {
2561
- return {
2562
- content: [{
2563
- type: 'text',
2564
- text: `# 📂 Saved Content
2565
-
2566
- No content directory found at \`${contentDir}\`.
2567
-
2568
- Save articles using \`save_content\` and they will appear here.`
2569
- }]
2570
- }
2571
- }
2572
-
2573
- // Get all content folders
2574
- const folders = fs.readdirSync(contentDir, { withFileTypes: true })
2575
- .filter(dirent => dirent.isDirectory())
2576
- .map(dirent => {
2577
- const folderPath = path.join(contentDir, dirent.name)
2578
- const metadataPath = path.join(folderPath, 'metadata.json')
2579
-
2580
- let metadata = null
2581
- if (fs.existsSync(metadataPath)) {
2582
- try {
2583
- metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'))
2584
- } catch (e) {
2585
- // Ignore parse errors
2586
- }
2587
- }
2588
-
2589
- return {
2590
- name: dirent.name,
2591
- path: folderPath,
2592
- metadata,
2593
- mtime: fs.statSync(folderPath).mtime
2594
- }
2595
- })
2596
- .sort((a, b) => b.mtime - a.mtime) // Most recent first
2597
- .slice(0, limit)
2598
-
2599
- if (folders.length === 0) {
2600
- return {
2601
- content: [{
2602
- type: 'text',
2603
- text: `# 📂 Saved Content
2604
-
2605
- No saved articles found in \`${contentDir}\`.
2606
-
2607
- Save articles using \`save_content\` and they will appear here.`
2608
- }]
2609
- }
2610
- }
2611
-
2612
- let response = `# 📂 Saved Content (${folders.length} articles)
2613
-
2614
- | # | Date | Title | Words | Project |
2615
- |---|------|-------|-------|---------|
2616
- `
2617
- folders.forEach((folder, i) => {
2618
- const date = folder.name.split('-').slice(0, 3).join('-')
2619
- const title = folder.metadata?.title || folder.name.split('-').slice(3).join('-')
2620
- const words = folder.metadata?.wordCount || '?'
2621
- const project = folder.metadata?.projectSlug || '-'
2622
- response += `| ${i + 1} | ${date} | ${title.substring(0, 35)}${title.length > 35 ? '...' : ''} | ${words} | ${project} |\n`
2623
- })
2624
-
2625
- response += `
2626
- ---
2627
-
2628
- ## To Load an Article
2629
-
2630
- Call \`load_content\` with the folder name:
2631
- \`\`\`
2632
- load_content({ folder_name: "${folders[0]?.name}" })
2633
- \`\`\`
2634
-
2635
- Once loaded, you can run optimization tools:
2636
- - \`quality_check\` - Pre-publish quality assurance
2637
- - \`geo_optimize\` - AI search engine optimization
2638
- - \`internal_links\` - Internal linking suggestions
2639
- - \`schema_generate\` - JSON-LD structured data
2640
- - \`save_content\` - Re-save with changes
2641
- - \`publish_content\` - Publish to CMS`
2642
-
2643
- return {
2644
- content: [{
2645
- type: 'text',
2646
- text: response
2647
- }]
2648
- }
2649
- }
2650
-
2651
- case 'load_content': {
2652
- const { folder_name } = args
2653
-
2654
- if (!folder_name) {
2655
- return {
2656
- content: [{
2657
- type: 'text',
2658
- text: `❌ Please specify a folder_name. Use \`list_content\` to see available articles.`
2659
- }]
2660
- }
2661
- }
2662
-
2663
- // Sanitize folder_name to prevent path traversal attacks
2664
- let folderPath
2665
- try {
2666
- folderPath = getContentFolderSafe(folder_name)
2667
- } catch (error) {
2668
- return {
2669
- content: [{
2670
- type: 'text',
2671
- text: `❌ Invalid folder name: ${error.message}`
2672
- }]
2673
- }
2674
- }
2675
-
2676
- if (!fs.existsSync(folderPath)) {
2677
- return {
2678
- content: [{
2679
- type: 'text',
2680
- text: `❌ Folder not found: \`${folder_name}\`
2681
-
2682
- Use \`list_content\` to see available articles.`
2683
- }]
2684
- }
2685
- }
2686
-
2687
- // Load article and metadata
2688
- const articlePath = path.join(folderPath, 'article.md')
2689
- const metadataPath = path.join(folderPath, 'metadata.json')
2690
-
2691
- if (!fs.existsSync(articlePath)) {
2692
- return {
2693
- content: [{
2694
- type: 'text',
2695
- text: `❌ No article.md found in \`${folder_name}\``
2696
- }]
2697
- }
2698
- }
2699
-
2700
- const articleContent = fs.readFileSync(articlePath, 'utf-8')
2701
- let metadata = {}
2702
- if (fs.existsSync(metadataPath)) {
2703
- try {
2704
- metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'))
2705
- } catch (e) {
2706
- log(`Warning: Failed to parse metadata.json: ${e.message}`)
2707
- }
2708
- }
2709
-
2710
- // Load into session state
2711
- sessionState.title = metadata.title || folder_name
2712
- sessionState.article = articleContent
2713
- sessionState.keywords = metadata.keywords || []
2714
- sessionState.metaDescription = metadata.metaDescription || ''
2715
- sessionState.metaTitle = metadata.metaTitle || metadata.title || folder_name
2716
- sessionState.imageUrl = metadata.imageUrl || null
2717
- sessionState.inlineImages = metadata.inlineImages || []
2718
- sessionState.contentFolder = folderPath
2719
-
2720
- // Also add to articles array if not already there
2721
- const existingIndex = sessionState.articles.findIndex(a => a.title === sessionState.title)
2722
- if (existingIndex === -1) {
2723
- const loadedArticle = {
2724
- id: generateArticleId(),
2725
- title: sessionState.title,
2726
- content: articleContent,
2727
- keywords: sessionState.keywords,
2728
- metaDescription: sessionState.metaDescription,
2729
- metaTitle: sessionState.metaTitle,
2730
- imageUrl: sessionState.imageUrl,
2731
- inlineImages: sessionState.inlineImages,
2732
- savedAt: metadata.createdAt || new Date().toISOString(),
2733
- published: false,
2734
- publishedTo: [],
2735
- wordCount: articleContent.split(/\s+/).length,
2736
- loadedFrom: folderPath
2737
- }
2738
- sessionState.articles.push(loadedArticle)
2739
- }
2740
-
2741
- // Save session
2742
- saveSession()
2743
-
2744
- const wordCount = articleContent.split(/\s+/).length
2745
- progress('Content', `Loaded "${sessionState.title}" (${wordCount} words) from ${folder_name}`)
2746
-
2747
- return {
2748
- content: [{
2749
- type: 'text',
2750
- text: `# ✅ Content Loaded
2751
-
2752
- **Title:** ${sessionState.title}
2753
- **Word Count:** ${wordCount}
2754
- **Keywords:** ${sessionState.keywords.join(', ') || 'None'}
2755
- **Meta Description:** ${sessionState.metaDescription ? `${sessionState.metaDescription.length} chars` : 'None'}
2756
- **Cover Image:** ${sessionState.imageUrl ? '✅' : '❌'}
2757
- **Inline Images:** ${sessionState.inlineImages.length}
2758
- **Source:** \`${folderPath}\`
2759
-
2760
- ---
2761
-
2762
- ## Now you can run optimization tools:
2763
-
2764
- - **\`quality_check\`** - Pre-publish quality assurance
2765
- - **\`geo_optimize\`** - Optimize for AI search engines (ChatGPT, Perplexity)
2766
- - **\`internal_links\`** - Get internal linking suggestions
2767
- - **\`schema_generate\`** - Generate JSON-LD structured data
2768
- - **\`save_content\`** - Re-save after making changes
2769
- - **\`publish_content\`** - Publish to WordPress/Ghost
2770
-
2771
- Article is now in session (#${sessionState.articles.length}) and ready for further processing.`
2772
- }]
2773
- }
2774
- }
2775
-
2776
- default:
2777
- throw new Error(`Unknown orchestrator tool: ${toolName}`)
2778
- }
2779
- }
2780
-
2781
- /**
2782
- * Execute an action tool locally using credentials
2783
- */
2784
- async function executeActionTool(toolName, args) {
2785
- switch (toolName) {
2786
- case 'generate_image':
2787
- return await executeImageGeneration(args)
2788
- case 'publish_wordpress':
2789
- return await executeWordPressPublish(args)
2790
- case 'publish_ghost':
2791
- return await executeGhostPublish(args)
2792
- case 'send_webhook':
2793
- return await executeSendWebhook(args)
2794
- default:
2795
- throw new Error(`Unknown action tool: ${toolName}`)
2796
- }
2797
- }
2798
-
2799
- /**
2800
- * Generate image using configured provider
2801
- */
2802
- async function executeImageGeneration(args) {
2803
- const provider = localCredentials.image_provider
2804
- const config = localCredentials[provider]
2805
-
2806
- if (!config?.api_key) {
2807
- throw new Error(`${provider} API key not configured`)
2808
- }
2809
-
2810
- progress('Image', `Generating with ${provider}...`)
2811
-
2812
- const { prompt, style, aspect_ratio = '16:9' } = args
2813
- const fullPrompt = style ? `${prompt}, ${style}` : prompt
2814
-
2815
- log(`Generating image with ${provider}: ${fullPrompt.substring(0, 50)}...`)
2816
-
2817
- switch (provider) {
2818
- case 'fal': {
2819
- // fal.ai Nano Banana Pro (gemini-3-pro-image)
2820
- const response = await fetchWithRetry(API_ENDPOINTS.fal, {
2821
- method: 'POST',
2822
- headers: {
2823
- 'Authorization': `Key ${config.api_key}`,
2824
- 'Content-Type': 'application/json'
2825
- },
2826
- body: JSON.stringify({
2827
- prompt: fullPrompt,
2828
- aspect_ratio: aspect_ratio,
2829
- output_format: 'png',
2830
- resolution: '1K',
2831
- num_images: 1
2832
- })
2833
- }, 2, 60000) // 2 retries, 60s timeout for image generation
2834
-
2835
- if (!response.ok) {
2836
- const error = await response.text()
2837
- throw new Error(`fal.ai error: ${error}`)
2838
- }
2839
-
2840
- const result = await response.json()
2841
- const imageUrl = result.images?.[0]?.url
2842
-
2843
- // Store in session for orchestrated workflows
2844
- // First image is cover, subsequent are inline
2845
- if (!sessionState.imageUrl) {
2846
- sessionState.imageUrl = imageUrl
2847
- } else {
2848
- sessionState.inlineImages.push(imageUrl)
2849
- }
2850
-
2851
- // Persist session to file
2852
- saveSession()
2853
-
2854
- // Track stats
2855
- incrementStat('images_generated')
2856
-
2857
- const imageNumber = 1 + sessionState.inlineImages.length
2858
- const totalImages = sessionState.currentWorkflow?.settings?.total_images || 1
2859
- const imageType = imageNumber === 1 ? 'Cover Image' : `Inline Image ${imageNumber - 1}`
2860
-
2861
- return {
2862
- content: [{
2863
- type: 'text',
2864
- text: `# ✅ ${imageType} Generated (${imageNumber}/${totalImages})
2865
-
2866
- **URL:** ${imageUrl}
2867
-
2868
- **Prompt:** ${fullPrompt}
2869
- **Provider:** fal.ai (nano-banana-pro)
2870
- **Aspect Ratio:** ${aspect_ratio}
2871
-
2872
- ${imageNumber < totalImages ? `\n**Next:** Generate ${totalImages - imageNumber} more image(s).` : '\n**All images generated!** Proceed to publish.'}`
2873
- }]
2874
- }
2875
- }
2876
-
2877
- case 'gemini': {
2878
- // Google Gemini 3 Pro Image (Nano Banana Pro) - generateContent API
2879
- const model = config.model || 'gemini-3-pro-image-preview'
2880
- const response = await fetch(
2881
- `${API_ENDPOINTS.gemini}/${model}:generateContent`,
2882
- {
2883
- method: 'POST',
2884
- headers: {
2885
- 'Content-Type': 'application/json',
2886
- 'x-goog-api-key': config.api_key
2887
- },
2888
- body: JSON.stringify({
2889
- contents: [{
2890
- parts: [{ text: fullPrompt }]
2891
- }],
2892
- generationConfig: {
2893
- responseModalities: ['IMAGE'],
2894
- imageConfig: {
2895
- aspectRatio: aspect_ratio,
2896
- imageSize: '1K'
2897
- }
2898
- }
2899
- })
2900
- }
2901
- )
2902
-
2903
- if (!response.ok) {
2904
- const error = await response.text()
2905
- throw new Error(`Gemini error: ${error}`)
2906
- }
2907
-
2908
- const result = await response.json()
2909
- const imagePart = result.candidates?.[0]?.content?.parts?.find(p => p.inlineData)
2910
- const imageData = imagePart?.inlineData?.data
2911
- const mimeType = imagePart?.inlineData?.mimeType || 'image/png'
2912
-
2913
- if (!imageData) {
2914
- throw new Error('No image data in Gemini response')
2915
- }
2916
-
2917
- // Return base64 data URI
2918
- const dataUri = `data:${mimeType};base64,${imageData}`
2919
-
2920
- // Track stats
2921
- incrementStat('images_generated')
2922
-
2923
- return {
2924
- content: [{
2925
- type: 'text',
2926
- text: `Image generated successfully!\n\n**Format:** Base64 Data URI\n**Prompt:** ${fullPrompt}\n**Provider:** Google Gemini (${model})\n**Aspect Ratio:** ${aspect_ratio}\n\n**Data URI:** ${dataUri.substring(0, 100)}...\n\n[Full base64 data: ${imageData.length} chars]`
2927
- }]
2928
- }
2929
- }
2930
-
2931
- case 'wiro': {
2932
- // wiro.ai API with HMAC signature authentication
2933
- const crypto = await import('crypto')
2934
- const apiKey = config.api_key
2935
- const apiSecret = config.api_secret
2936
-
2937
- if (!apiSecret) {
2938
- throw new Error('Wiro API secret not configured. Add api_secret to wiro config in ~/.suparank/credentials.json')
2939
- }
2940
-
2941
- // Generate nonce and signature
2942
- const nonce = Math.floor(Date.now() / 1000).toString()
2943
- const signatureData = `${apiSecret}${nonce}`
2944
- const signature = crypto.createHmac('sha256', apiKey)
2945
- .update(signatureData)
2946
- .digest('hex')
2947
-
2948
- const model = config.model || 'google/nano-banana-pro'
2949
-
2950
- // Submit task
2951
- log(`Submitting wiro.ai task for model: ${model}`)
2952
- const submitResponse = await fetch(`${API_ENDPOINTS.wiro}/Run/${model}`, {
2953
- method: 'POST',
2954
- headers: {
2955
- 'Content-Type': 'application/json',
2956
- 'x-api-key': apiKey,
2957
- 'x-nonce': nonce,
2958
- 'x-signature': signature
2959
- },
2960
- body: JSON.stringify({
2961
- prompt: fullPrompt,
2962
- aspectRatio: aspect_ratio,
2963
- resolution: '1K',
2964
- safetySetting: 'BLOCK_ONLY_HIGH'
2965
- })
2966
- })
2967
-
2968
- if (!submitResponse.ok) {
2969
- const error = await submitResponse.text()
2970
- throw new Error(`wiro.ai submit error: ${error}`)
2971
- }
2972
-
2973
- const submitResult = await submitResponse.json()
2974
- if (!submitResult.result || !submitResult.taskid) {
2975
- throw new Error(`wiro.ai task submission failed: ${JSON.stringify(submitResult.errors)}`)
2976
- }
2977
-
2978
- const taskId = submitResult.taskid
2979
- log(`wiro.ai task submitted: ${taskId}`)
2980
-
2981
- // Poll for completion
2982
- const maxAttempts = 60 // 60 seconds max
2983
- const pollInterval = 2000 // 2 seconds
2984
-
2985
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
2986
- await new Promise(resolve => setTimeout(resolve, pollInterval))
2987
-
2988
- // Generate new signature for poll request
2989
- const pollNonce = Math.floor(Date.now() / 1000).toString()
2990
- const pollSignatureData = `${apiSecret}${pollNonce}`
2991
- const pollSignature = crypto.createHmac('sha256', apiKey)
2992
- .update(pollSignatureData)
2993
- .digest('hex')
2994
-
2995
- const pollResponse = await fetch(API_ENDPOINTS.wiroTaskDetail, {
2996
- method: 'POST',
2997
- headers: {
2998
- 'Content-Type': 'application/json',
2999
- 'x-api-key': apiKey,
3000
- 'x-nonce': pollNonce,
3001
- 'x-signature': pollSignature
3002
- },
3003
- body: JSON.stringify({ taskid: taskId })
3004
- })
3005
-
3006
- if (!pollResponse.ok) {
3007
- log(`wiro.ai poll error: ${await pollResponse.text()}`)
3008
- continue
3009
- }
3010
-
3011
- const pollResult = await pollResponse.json()
3012
- const task = pollResult.tasklist?.[0]
3013
-
3014
- if (!task) continue
3015
-
3016
- const status = task.status
3017
- log(`wiro.ai task status: ${status}`)
3018
-
3019
- // Check for completion
3020
- if (status === 'task_postprocess_end') {
3021
- const imageUrl = task.outputs?.[0]?.url
3022
- if (!imageUrl) {
3023
- throw new Error('wiro.ai task completed but no output URL')
3024
- }
3025
-
3026
- // Store in session for orchestrated workflows
3027
- // First image is cover, subsequent are inline
3028
- if (!sessionState.imageUrl) {
3029
- sessionState.imageUrl = imageUrl
3030
- } else {
3031
- sessionState.inlineImages.push(imageUrl)
3032
- }
3033
-
3034
- // Persist session to file
3035
- saveSession()
3036
-
3037
- // Track stats
3038
- incrementStat('images_generated')
3039
-
3040
- const imageNumber = 1 + sessionState.inlineImages.length
3041
- const totalImages = sessionState.currentWorkflow?.settings?.total_images || 1
3042
- const imageType = imageNumber === 1 ? 'Cover Image' : `Inline Image ${imageNumber - 1}`
3043
-
3044
- return {
3045
- content: [{
3046
- type: 'text',
3047
- text: `# ✅ ${imageType} Generated (${imageNumber}/${totalImages})
3048
-
3049
- **URL:** ${imageUrl}
3050
-
3051
- **Prompt:** ${fullPrompt}
3052
- **Provider:** wiro.ai (${model})
3053
- **Aspect Ratio:** ${aspect_ratio}
3054
- **Processing Time:** ${task.elapsedseconds}s
3055
-
3056
- ${imageNumber < totalImages ? `\n**Next:** Generate ${totalImages - imageNumber} more image(s).` : '\n**All images generated!** Proceed to publish.'}`
3057
- }]
3058
- }
3059
- }
3060
-
3061
- // Check for failure
3062
- if (status === 'task_cancel') {
3063
- throw new Error('wiro.ai task was cancelled')
3064
- }
3065
- }
3066
-
3067
- throw new Error('wiro.ai task timed out after 60 seconds')
3068
- }
3069
-
3070
- default:
3071
- throw new Error(`Unknown image provider: ${provider}`)
3072
- }
3073
- }
3074
-
3075
- /**
3076
- * Convert aspect ratio string to fal.ai image size
3077
- */
3078
- function aspectRatioToSize(ratio) {
3079
- const sizes = {
3080
- '1:1': 'square',
3081
- '16:9': 'landscape_16_9',
3082
- '9:16': 'portrait_16_9',
3083
- '4:3': 'landscape_4_3',
3084
- '3:4': 'portrait_4_3'
3085
- }
3086
- return sizes[ratio] || 'landscape_16_9'
3087
- }
3088
-
3089
- /**
3090
- * Convert markdown to HTML using marked library
3091
- * Configured for WordPress/Ghost CMS compatibility
3092
- */
3093
- function markdownToHtml(markdown) {
3094
- // Configure marked for CMS compatibility
3095
- marked.setOptions({
3096
- gfm: true, // GitHub Flavored Markdown
3097
- breaks: true, // Convert line breaks to <br>
3098
- pedantic: false,
3099
- silent: true // Don't throw on errors
3100
- })
3101
-
3102
- try {
3103
- return marked.parse(markdown)
3104
- } catch (error) {
3105
- log(`Markdown conversion error: ${error.message}`)
3106
- // Fallback: return markdown wrapped in <p> tags
3107
- return `<p>${markdown.replace(/\n\n+/g, '</p><p>')}</p>`
3108
- }
3109
- }
3110
-
3111
- /**
3112
- * Fetch available categories from WordPress
3113
- */
3114
- async function fetchWordPressCategories() {
3115
- const wpConfig = localCredentials?.wordpress
3116
- if (!wpConfig?.secret_key || !wpConfig?.site_url) {
3117
- return null
3118
- }
3119
-
3120
- try {
3121
- log('Fetching WordPress categories...')
3122
-
3123
- // Try new Suparank endpoint first, then fall back to legacy
3124
- const endpoints = [
3125
- { url: `${wpConfig.site_url}/wp-json/suparank/v1/categories`, header: 'X-Suparank-Key' },
3126
- { url: `${wpConfig.site_url}/wp-json/writer-mcp/v1/categories`, header: 'X-Writer-MCP-Key' }
3127
- ]
3128
-
3129
- for (const endpoint of endpoints) {
3130
- try {
3131
- const response = await fetchWithTimeout(endpoint.url, {
3132
- method: 'GET',
3133
- headers: {
3134
- [endpoint.header]: wpConfig.secret_key
3135
- }
3136
- }, 10000) // 10s timeout
3137
-
3138
- if (response.ok) {
3139
- const result = await response.json()
3140
- if (result.success && result.categories) {
3141
- log(`Found ${result.categories.length} WordPress categories`)
3142
- return result.categories
3143
- }
3144
- }
3145
- } catch (e) {
3146
- // Try next endpoint
3147
- }
3148
- }
3149
-
3150
- log('Failed to fetch categories from any endpoint')
3151
- return null
3152
- } catch (error) {
3153
- log(`Error fetching categories: ${error.message}`)
3154
- return null
3155
- }
3156
- }
3157
-
3158
- /**
3159
- * Publish to WordPress using REST API or custom plugin
3160
- */
3161
- async function executeWordPressPublish(args) {
3162
- const wpConfig = localCredentials.wordpress
3163
- const { title, content, status = 'draft', categories = [], tags = [], featured_image_url } = args
3164
-
3165
- progress('Publish', `Publishing to WordPress: "${title}"`)
3166
- log(`Publishing to WordPress: ${title}`)
3167
-
3168
- // Convert markdown to HTML for WordPress
3169
- const htmlContent = markdownToHtml(content)
3170
-
3171
- // Method 1: Use Suparank Connector plugin (secret_key auth)
3172
- if (wpConfig.secret_key) {
3173
- log('Using Suparank/Writer MCP Connector plugin')
3174
-
3175
- // Try new Suparank endpoint first, then fall back to legacy
3176
- const endpoints = [
3177
- { url: `${wpConfig.site_url}/wp-json/suparank/v1/publish`, header: 'X-Suparank-Key' },
3178
- { url: `${wpConfig.site_url}/wp-json/writer-mcp/v1/publish`, header: 'X-Writer-MCP-Key' }
3179
- ]
3180
-
3181
- const postBody = JSON.stringify({
3182
- title,
3183
- content: htmlContent,
3184
- status,
3185
- categories,
3186
- tags,
3187
- featured_image_url,
3188
- excerpt: sessionState.metaDescription || ''
3189
- })
3190
-
3191
- let lastError = null
3192
- for (const endpoint of endpoints) {
3193
- try {
3194
- const response = await fetchWithRetry(endpoint.url, {
3195
- method: 'POST',
3196
- headers: {
3197
- 'Content-Type': 'application/json',
3198
- [endpoint.header]: wpConfig.secret_key
3199
- },
3200
- body: postBody
3201
- }, 2, 30000) // 2 retries, 30s timeout
3202
-
3203
- if (response.ok) {
3204
- const result = await response.json()
3205
-
3206
- if (result.success) {
3207
- const categoriesInfo = result.post.categories?.length
3208
- ? `\n**Categories:** ${result.post.categories.join(', ')}`
3209
- : ''
3210
- const tagsInfo = result.post.tags?.length
3211
- ? `\n**Tags:** ${result.post.tags.join(', ')}`
3212
- : ''
3213
- const imageInfo = result.post.featured_image
3214
- ? `\n**Featured Image:** ✅ Uploaded`
3215
- : ''
3216
-
3217
- return {
3218
- content: [{
3219
- type: 'text',
3220
- text: `Post published to WordPress!\n\n**Title:** ${result.post.title}\n**Status:** ${result.post.status}\n**URL:** ${result.post.url}\n**Edit:** ${result.post.edit_url}\n**ID:** ${result.post.id}${categoriesInfo}${tagsInfo}${imageInfo}\n\n${status === 'draft' ? 'The post is saved as a draft. Edit and publish from WordPress dashboard.' : 'The post is now live!'}`
3221
- }]
3222
- }
3223
- }
3224
- }
3225
- lastError = await response.text()
3226
- } catch (e) {
3227
- lastError = e.message
3228
- }
3229
- }
3230
-
3231
- throw new Error(`WordPress error: ${lastError}`)
3232
- }
3233
-
3234
- // Method 2: Use standard REST API with application password
3235
- if (wpConfig.app_password && wpConfig.username) {
3236
- log('Using WordPress REST API with application password')
3237
-
3238
- const auth = Buffer.from(`${wpConfig.username}:${wpConfig.app_password}`).toString('base64')
3239
- const postData = {
3240
- title,
3241
- content: htmlContent,
3242
- status,
3243
- categories: [],
3244
- tags: []
3245
- }
3246
-
3247
- const response = await fetch(`${wpConfig.site_url}/wp-json/wp/v2/posts`, {
3248
- method: 'POST',
3249
- headers: {
3250
- 'Authorization': `Basic ${auth}`,
3251
- 'Content-Type': 'application/json'
3252
- },
3253
- body: JSON.stringify(postData)
3254
- })
3255
-
3256
- if (!response.ok) {
3257
- const error = await response.text()
3258
- throw new Error(`WordPress error: ${error}`)
3259
- }
3260
-
3261
- const post = await response.json()
3262
-
3263
- return {
3264
- content: [{
3265
- type: 'text',
3266
- text: `Post published to WordPress!\n\n**Title:** ${post.title.rendered}\n**Status:** ${post.status}\n**URL:** ${post.link}\n**ID:** ${post.id}\n\n${status === 'draft' ? 'The post is saved as a draft. Edit and publish from WordPress dashboard.' : 'The post is now live!'}`
3267
- }]
3268
- }
3269
- }
3270
-
3271
- throw new Error('WordPress credentials not configured. Add either secret_key (with plugin) or username + app_password to ~/.suparank/credentials.json')
3272
- }
3273
-
3274
- /**
3275
- * Publish to Ghost using Admin API
3276
- */
3277
- async function executeGhostPublish(args) {
3278
- const { api_url, admin_api_key } = localCredentials.ghost
3279
- const { title, content, status = 'draft', tags = [], featured_image_url } = args
3280
-
3281
- progress('Publish', `Publishing to Ghost: "${title}"`)
3282
- log(`Publishing to Ghost: ${title}`)
3283
-
3284
- // Create JWT for Ghost Admin API
3285
- const [id, secret] = admin_api_key.split(':')
3286
- const token = await createGhostJWT(id, secret)
3287
-
3288
- // Convert markdown to HTML for proper element separation
3289
- const htmlContent = markdownToHtml(content)
3290
-
3291
- // Use HTML card for proper rendering (each element separate)
3292
- const mobiledoc = JSON.stringify({
3293
- version: '0.3.1',
3294
- atoms: [],
3295
- cards: [['html', { html: htmlContent }]],
3296
- markups: [],
3297
- sections: [[10, 0]]
3298
- })
3299
-
3300
- const postData = {
3301
- posts: [{
3302
- title,
3303
- mobiledoc,
3304
- status,
3305
- tags: tags.map(name => ({ name })),
3306
- feature_image: featured_image_url
3307
- }]
3308
- }
3309
-
3310
- const response = await fetchWithRetry(`${api_url}/ghost/api/admin/posts/`, {
3311
- method: 'POST',
3312
- headers: {
3313
- 'Authorization': `Ghost ${token}`,
3314
- 'Content-Type': 'application/json'
3315
- },
3316
- body: JSON.stringify(postData)
3317
- }, 2, 30000) // 2 retries, 30s timeout
3318
-
3319
- if (!response.ok) {
3320
- const error = await response.text()
3321
- throw new Error(`Ghost error: ${error}`)
3322
- }
3323
-
3324
- const result = await response.json()
3325
- const post = result.posts[0]
3326
-
3327
- return {
3328
- content: [{
3329
- type: 'text',
3330
- text: `Post published to Ghost!\n\n**Title:** ${post.title}\n**Status:** ${post.status}\n**URL:** ${post.url}\n**ID:** ${post.id}\n\n${status === 'draft' ? 'The post is saved as a draft. Edit and publish from Ghost dashboard.' : 'The post is now live!'}`
3331
- }]
3332
- }
3333
- }
3334
-
3335
- /**
3336
- * Create JWT for Ghost Admin API
3337
- */
3338
- async function createGhostJWT(id, secret) {
3339
- // Simple JWT creation for Ghost
3340
- const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT', kid: id })).toString('base64url')
3341
- const now = Math.floor(Date.now() / 1000)
3342
- const payload = Buffer.from(JSON.stringify({
3343
- iat: now,
3344
- exp: now + 300, // 5 minutes
3345
- aud: '/admin/'
3346
- })).toString('base64url')
3347
-
3348
- // Create signature using crypto
3349
- const crypto = await import('crypto')
3350
- const key = Buffer.from(secret, 'hex')
3351
- const signature = crypto.createHmac('sha256', key)
3352
- .update(`${header}.${payload}`)
3353
- .digest('base64url')
3354
-
3355
- return `${header}.${payload}.${signature}`
3356
- }
3357
-
3358
- /**
3359
- * Send data to webhook
3360
- */
3361
- async function executeSendWebhook(args) {
3362
- const { webhook_type = 'default', payload = {}, message } = args
3363
- const webhooks = localCredentials.webhooks
3364
-
3365
- // Get webhook URL
3366
- const urlMap = {
3367
- default: webhooks.default_url,
3368
- make: webhooks.make_url,
3369
- n8n: webhooks.n8n_url,
3370
- zapier: webhooks.zapier_url,
3371
- slack: webhooks.slack_url
3372
- }
3373
-
3374
- const url = urlMap[webhook_type]
3375
- if (!url) {
3376
- throw new Error(`No ${webhook_type} webhook URL configured`)
3377
- }
3378
-
3379
- log(`Sending webhook to ${webhook_type}: ${url}`)
3380
-
3381
- // Format payload based on type
3382
- let body
3383
- let headers = { 'Content-Type': 'application/json' }
3384
-
3385
- if (webhook_type === 'slack') {
3386
- body = JSON.stringify({
3387
- text: message || 'Message from Writer MCP',
3388
- ...payload
3389
- })
3390
- } else {
3391
- body = JSON.stringify({
3392
- source: 'suparank',
3393
- timestamp: new Date().toISOString(),
3394
- project: projectSlug,
3395
- data: payload,
3396
- message
3397
- })
3398
- }
3399
-
3400
- const response = await fetch(url, {
3401
- method: 'POST',
3402
- headers,
3403
- body
3404
- })
3405
-
3406
- if (!response.ok) {
3407
- const error = await response.text()
3408
- throw new Error(`Webhook error (${response.status}): ${error}`)
3409
- }
3410
-
3411
- return {
3412
- content: [{
3413
- type: 'text',
3414
- text: `Webhook sent successfully!\n\n**Type:** ${webhook_type}\n**URL:** ${url.substring(0, 50)}...\n**Status:** ${response.status}\n\nThe data has been sent to your ${webhook_type} webhook.`
3415
- }]
3416
- }
3417
- }
3418
-
3419
- /**
3420
- * Essential tools shown in the tool list
3421
- * MCP protocol requires tools to be listed for clients to call them
3422
- */
3423
- const VISIBLE_TOOLS = [
3424
- // Essential (5) - Main workflow
3425
- 'create_content', // Main entry point - creates & publishes automatically
3426
- 'keyword_research', // Research keywords separately (on-demand)
3427
- 'generate_image', // Generate/regenerate images (on-demand)
3428
- 'publish_content', // Manual publish trigger (on-demand)
3429
- 'get_session', // Check status (on-demand)
3430
-
3431
- // Session Management (5) - Content lifecycle
3432
- 'save_content', // Save article to session
3433
- 'list_content', // List saved content
3434
- 'load_content', // Load past content into session
3435
- 'remove_article', // Remove article from session
3436
- 'clear_session' // Clear all session content
3437
- ]
3438
-
3439
- /**
3440
- * Get all available tools based on configured credentials
3441
- * Shows 10 essential tools (instead of 24) for cleaner UX
3442
- */
3443
- function getAvailableTools() {
3444
- const tools = []
3445
-
3446
- // Add visible TOOLS (keyword_research only from main tools)
3447
- for (const tool of TOOLS) {
3448
- if (VISIBLE_TOOLS.includes(tool.name)) {
3449
- tools.push({
3450
- name: tool.name,
3451
- description: tool.description,
3452
- inputSchema: tool.inputSchema
3453
- })
3454
- }
3455
- }
3456
-
3457
- // Add visible orchestrator tools
3458
- for (const tool of ORCHESTRATOR_TOOLS) {
3459
- if (VISIBLE_TOOLS.includes(tool.name)) {
3460
- tools.push({
3461
- name: tool.name,
3462
- description: tool.description,
3463
- inputSchema: tool.inputSchema
3464
- })
3465
- }
3466
- }
3467
-
3468
- // Add visible action tools (only if credentials are configured)
3469
- for (const tool of ACTION_TOOLS) {
3470
- if (VISIBLE_TOOLS.includes(tool.name)) {
3471
- if (hasCredential(tool.requiresCredential)) {
3472
- tools.push({
3473
- name: tool.name,
3474
- description: tool.description,
3475
- inputSchema: tool.inputSchema
3476
- })
3477
- } else {
3478
- // Add disabled version with note
3479
- tools.push({
3480
- name: tool.name,
3481
- description: `[DISABLED - requires ${tool.requiresCredential} credentials] ${tool.description}`,
3482
- inputSchema: tool.inputSchema
3483
- })
3484
- }
3485
- }
3486
- }
3487
-
3488
- return tools
3489
- }
3490
-
3491
- /**
3492
- * Get ALL tools (visible + hidden) for tool execution
3493
- * This is used by CallToolRequestSchema to find tools by name
3494
- */
3495
- function getAllTools() {
3496
- const tools = [...TOOLS]
3497
-
3498
- // Add all orchestrator tools
3499
- for (const tool of ORCHESTRATOR_TOOLS) {
3500
- tools.push({
3501
- name: tool.name,
3502
- description: tool.description,
3503
- inputSchema: tool.inputSchema
3504
- })
3505
- }
3506
-
3507
- // Add all action tools
3508
- for (const tool of ACTION_TOOLS) {
3509
- tools.push({
3510
- name: tool.name,
3511
- description: tool.description,
3512
- inputSchema: tool.inputSchema,
3513
- requiresCredential: tool.requiresCredential
3514
- })
3515
- }
3516
-
3517
- return tools
3518
- }
3519
-
3520
- // Main function
3521
- async function main() {
3522
- log(`Starting MCP client for project: ${projectSlug}`)
3523
- log(`API URL: ${apiUrl}`)
3524
-
3525
- // Load local credentials
3526
- localCredentials = loadLocalCredentials()
3527
- if (localCredentials) {
3528
- const configured = []
3529
- if (hasCredential('wordpress')) configured.push('wordpress')
3530
- if (hasCredential('ghost')) configured.push('ghost')
3531
- if (hasCredential('image')) configured.push(`image:${localCredentials.image_provider}`)
3532
- if (hasCredential('webhooks')) configured.push('webhooks')
3533
- if (localCredentials.external_mcps?.length) {
3534
- configured.push(`mcps:${localCredentials.external_mcps.map(m => m.name).join(',')}`)
3535
- }
3536
- if (configured.length > 0) {
3537
- log(`Configured integrations: ${configured.join(', ')}`)
3538
- }
3539
- }
3540
-
3541
- // Restore session state from previous run
3542
- if (loadSession()) {
3543
- progress('Session', 'Restored previous workflow state')
3544
- }
3545
-
3546
- // Fetch project configuration
3547
- progress('Init', 'Connecting to platform...')
3548
- let project
3549
- try {
3550
- project = await fetchProjectConfig()
3551
- progress('Init', `Connected to project: ${project.name}`)
3552
- } catch (error) {
3553
- log('Failed to load project config. Exiting.')
3554
- process.exit(1)
3555
- }
3556
-
3557
- // Create MCP server
3558
- const server = new Server(
3559
- {
3560
- name: 'suparank',
3561
- version: '1.0.0'
3562
- },
3563
- {
3564
- capabilities: {
3565
- tools: {}
3566
- }
3567
- }
3568
- )
3569
-
3570
- // Handle initialization
3571
- server.setRequestHandler(InitializeRequestSchema, async (request) => {
3572
- log('Received initialize request')
3573
- return {
3574
- protocolVersion: '2024-11-05',
3575
- capabilities: {
3576
- tools: {}
3577
- },
3578
- serverInfo: {
3579
- name: 'suparank',
3580
- version: '1.0.0'
3581
- }
3582
- }
3583
- })
3584
-
3585
- // Handle tools list
3586
- server.setRequestHandler(ListToolsRequestSchema, async () => {
3587
- log('Received list tools request')
3588
- const tools = getAvailableTools()
3589
- log(`Returning ${tools.length} tools (${ACTION_TOOLS.length} action tools)`)
3590
- return { tools }
3591
- })
3592
-
3593
- // Handle tool calls
3594
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
3595
- const { name, arguments: args } = request.params
3596
- progress('Tool', `Executing ${name}`)
3597
- log(`Executing tool: ${name}`)
3598
-
3599
- // Track tool call stats
3600
- incrementStat('tool_calls')
3601
-
3602
- // Check if this is an orchestrator tool
3603
- const orchestratorTool = ORCHESTRATOR_TOOLS.find(t => t.name === name)
3604
-
3605
- if (orchestratorTool) {
3606
- try {
3607
- const result = await executeOrchestratorTool(name, args || {}, project)
3608
- log(`Orchestrator tool ${name} completed successfully`)
3609
- return result
3610
- } catch (error) {
3611
- log(`Orchestrator tool ${name} failed:`, error.message)
3612
- return {
3613
- content: [{
3614
- type: 'text',
3615
- text: `Error executing ${name}: ${error.message}`
3616
- }]
3617
- }
3618
- }
3619
- }
3620
-
3621
- // Check if this is an action tool
3622
- const actionTool = ACTION_TOOLS.find(t => t.name === name)
3623
-
3624
- if (actionTool) {
3625
- // Check credentials
3626
- if (!hasCredential(actionTool.requiresCredential)) {
3627
- return {
3628
- content: [{
3629
- type: 'text',
3630
- text: `Error: ${name} requires ${actionTool.requiresCredential} credentials.\n\nTo enable this tool:\n1. Run: npx suparank setup\n2. Add your ${actionTool.requiresCredential} credentials to ~/.suparank/credentials.json\n3. Restart the MCP server\n\nSee dashboard Settings > Credentials for setup instructions.`
3631
- }]
3632
- }
3633
- }
3634
-
3635
- // Execute action tool locally
3636
- try {
3637
- const result = await executeActionTool(name, args || {})
3638
- log(`Action tool ${name} completed successfully`)
3639
- return result
3640
- } catch (error) {
3641
- log(`Action tool ${name} failed:`, error.message)
3642
- return {
3643
- content: [{
3644
- type: 'text',
3645
- text: `Error executing ${name}: ${error.message}`
3646
- }]
3647
- }
3648
- }
3649
- }
3650
-
3651
- // Regular tool - call backend
3652
- try {
3653
- // Add composition hints if configured
3654
- const hints = getCompositionHints(name)
3655
- const externalMcps = getExternalMCPs()
3656
-
3657
- const result = await callBackendTool(name, args || {})
3658
-
3659
- // Inject composition hints into response if available
3660
- if (hints && result.content && result.content[0]?.text) {
3661
- const mcpList = externalMcps.length > 0
3662
- ? `\n\n## External MCPs Available\n${externalMcps.map(m => `- **${m.name}**: ${m.available_tools.join(', ')}`).join('\n')}`
3663
- : ''
3664
-
3665
- result.content[0].text = result.content[0].text +
3666
- `\n\n---\n## Integration Hints\n${hints}${mcpList}`
3667
- }
3668
-
3669
- log(`Tool ${name} completed successfully`)
3670
- return result
3671
- } catch (error) {
3672
- log(`Tool ${name} failed:`, error.message)
3673
- throw error
3674
- }
3675
- })
3676
-
3677
- // Error handler
3678
- server.onerror = (error) => {
3679
- log('Server error:', error)
3680
- }
3681
-
3682
- // Connect to stdio transport
3683
- const transport = new StdioServerTransport()
3684
- await server.connect(transport)
3685
-
3686
- log('MCP server ready and listening on stdio')
3687
- }
3688
-
3689
- // Run
3690
- main().catch((error) => {
3691
- log('Fatal error:', error)
3692
- process.exit(1)
3693
- })