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.
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Suparank MCP - Tool Discovery
3
+ *
4
+ * Functions for discovering and filtering available tools
5
+ * based on configuration and credentials
6
+ */
7
+
8
+ import { TOOLS, ACTION_TOOLS, ORCHESTRATOR_TOOLS, VISIBLE_TOOLS } from './definitions.js'
9
+ import { hasCredential } from '../services/credentials.js'
10
+
11
+ /**
12
+ * Get tools to show in ListToolsRequestSchema
13
+ * Only returns visible tools, with action tools marked as disabled if no credentials
14
+ * @returns {Array} Array of tool definitions for MCP clients
15
+ */
16
+ export function getAvailableTools() {
17
+ const tools = []
18
+
19
+ // Add visible TOOLS (keyword_research only from main tools)
20
+ for (const tool of TOOLS) {
21
+ if (VISIBLE_TOOLS.includes(tool.name)) {
22
+ tools.push({
23
+ name: tool.name,
24
+ description: tool.description,
25
+ inputSchema: tool.inputSchema
26
+ })
27
+ }
28
+ }
29
+
30
+ // Add visible orchestrator tools
31
+ for (const tool of ORCHESTRATOR_TOOLS) {
32
+ if (VISIBLE_TOOLS.includes(tool.name)) {
33
+ tools.push({
34
+ name: tool.name,
35
+ description: tool.description,
36
+ inputSchema: tool.inputSchema
37
+ })
38
+ }
39
+ }
40
+
41
+ // Add visible action tools (only if credentials are configured)
42
+ for (const tool of ACTION_TOOLS) {
43
+ if (VISIBLE_TOOLS.includes(tool.name)) {
44
+ if (hasCredential(tool.requiresCredential)) {
45
+ tools.push({
46
+ name: tool.name,
47
+ description: tool.description,
48
+ inputSchema: tool.inputSchema
49
+ })
50
+ } else {
51
+ // Add disabled version with note
52
+ tools.push({
53
+ name: tool.name,
54
+ description: `[DISABLED - requires ${tool.requiresCredential} credentials] ${tool.description}`,
55
+ inputSchema: tool.inputSchema
56
+ })
57
+ }
58
+ }
59
+ }
60
+
61
+ return tools
62
+ }
63
+
64
+ /**
65
+ * Get ALL tools (visible + hidden) for tool execution
66
+ * This is used by CallToolRequestSchema to find tools by name
67
+ * @returns {Array} Array of all tool definitions
68
+ */
69
+ export function getAllTools() {
70
+ const tools = [...TOOLS]
71
+
72
+ // Add all orchestrator tools
73
+ for (const tool of ORCHESTRATOR_TOOLS) {
74
+ tools.push({
75
+ name: tool.name,
76
+ description: tool.description,
77
+ inputSchema: tool.inputSchema
78
+ })
79
+ }
80
+
81
+ // Add all action tools
82
+ for (const tool of ACTION_TOOLS) {
83
+ tools.push({
84
+ name: tool.name,
85
+ description: tool.description,
86
+ inputSchema: tool.inputSchema,
87
+ requiresCredential: tool.requiresCredential
88
+ })
89
+ }
90
+
91
+ return tools
92
+ }
93
+
94
+ /**
95
+ * Find a tool by name across all tool arrays
96
+ * @param {string} name - Tool name to find
97
+ * @returns {object|null} Tool definition or null
98
+ */
99
+ export function findTool(name) {
100
+ // Check backend tools
101
+ const backendTool = TOOLS.find(t => t.name === name)
102
+ if (backendTool) return { ...backendTool, type: 'backend' }
103
+
104
+ // Check orchestrator tools
105
+ const orchestratorTool = ORCHESTRATOR_TOOLS.find(t => t.name === name)
106
+ if (orchestratorTool) return { ...orchestratorTool, type: 'orchestrator' }
107
+
108
+ // Check action tools
109
+ const actionTool = ACTION_TOOLS.find(t => t.name === name)
110
+ if (actionTool) return { ...actionTool, type: 'action' }
111
+
112
+ return null
113
+ }
114
+
115
+ /**
116
+ * Check if a tool name exists
117
+ * @param {string} name - Tool name to check
118
+ * @returns {boolean} Whether the tool exists
119
+ */
120
+ export function toolExists(name) {
121
+ return findTool(name) !== null
122
+ }
123
+
124
+ /**
125
+ * Get the tool type (backend, orchestrator, action)
126
+ * @param {string} name - Tool name
127
+ * @returns {string|null} Tool type or null
128
+ */
129
+ export function getToolType(name) {
130
+ const tool = findTool(name)
131
+ return tool ? tool.type : null
132
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Suparank MCP - Tools Module
3
+ *
4
+ * Re-exports all tool definitions and discovery functions
5
+ */
6
+
7
+ // Tool definitions
8
+ export {
9
+ TOOLS,
10
+ ACTION_TOOLS,
11
+ ORCHESTRATOR_TOOLS,
12
+ VISIBLE_TOOLS
13
+ } from './definitions.js'
14
+
15
+ // Tool discovery functions
16
+ export {
17
+ getAvailableTools,
18
+ getAllTools,
19
+ findTool,
20
+ toolExists,
21
+ getToolType
22
+ } from './discovery.js'
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Suparank MCP - Content Utilities
3
+ *
4
+ * Content saving and folder management utilities
5
+ */
6
+
7
+ import * as fs from 'fs'
8
+ import * as path from 'path'
9
+ import { log, progress } from './logging.js'
10
+ import {
11
+ ensureContentDir,
12
+ getContentFolderSafe,
13
+ atomicWriteSync,
14
+ slugify
15
+ } from './paths.js'
16
+ import { sessionState } from '../services/session-state.js'
17
+ import { projectSlug } from '../config.js'
18
+
19
+ /**
20
+ * Save current article content to a folder on disk
21
+ * Creates a folder with article.md, metadata.json, and optional workflow.json
22
+ * @returns {string|null} Folder path on success, null on failure
23
+ */
24
+ export function saveContentToFolder() {
25
+ if (!sessionState.title || !sessionState.article) {
26
+ return null
27
+ }
28
+
29
+ try {
30
+ ensureContentDir()
31
+
32
+ // Create folder name: YYYY-MM-DD-slug (slugify removes dangerous characters)
33
+ const date = new Date().toISOString().split('T')[0]
34
+ const slug = slugify(sessionState.title)
35
+ const folderName = `${date}-${slug}`
36
+
37
+ // Use safe path function to prevent any path traversal
38
+ const folderPath = getContentFolderSafe(folderName)
39
+
40
+ // Create folder if doesn't exist
41
+ if (!fs.existsSync(folderPath)) {
42
+ fs.mkdirSync(folderPath, { recursive: true })
43
+ }
44
+
45
+ // Save markdown article
46
+ atomicWriteSync(
47
+ path.join(folderPath, 'article.md'),
48
+ sessionState.article
49
+ )
50
+
51
+ // Save metadata
52
+ const metadata = {
53
+ title: sessionState.title,
54
+ keywords: sessionState.keywords || [],
55
+ metaDescription: sessionState.metaDescription || '',
56
+ metaTitle: sessionState.metaTitle || sessionState.title,
57
+ imageUrl: sessionState.imageUrl,
58
+ inlineImages: sessionState.inlineImages || [],
59
+ wordCount: sessionState.article.split(/\s+/).length,
60
+ createdAt: new Date().toISOString(),
61
+ projectSlug: projectSlug
62
+ }
63
+ atomicWriteSync(
64
+ path.join(folderPath, 'metadata.json'),
65
+ JSON.stringify(metadata, null, 2)
66
+ )
67
+
68
+ // Save workflow state for resuming
69
+ if (sessionState.currentWorkflow) {
70
+ atomicWriteSync(
71
+ path.join(folderPath, 'workflow.json'),
72
+ JSON.stringify({
73
+ workflow: sessionState.currentWorkflow,
74
+ stepResults: sessionState.stepResults,
75
+ savedAt: new Date().toISOString()
76
+ }, null, 2)
77
+ )
78
+ }
79
+
80
+ // Store folder path in session
81
+ sessionState.contentFolder = folderPath
82
+
83
+ progress('Content', `Saved to folder: ${folderPath}`)
84
+ return folderPath
85
+ } catch (error) {
86
+ log(`Warning: Failed to save content to folder: ${error.message}`)
87
+ return null
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Extract image prompts from article content
93
+ * Looks for [IMAGE: description] placeholders
94
+ * @param {string} content - Article content
95
+ * @returns {string[]} Array of image prompt descriptions
96
+ */
97
+ export function extractImagePromptsFromArticle(content) {
98
+ const prompts = []
99
+ const regex = /\[IMAGE:\s*([^\]]+)\]/gi
100
+ let match
101
+
102
+ while ((match = regex.exec(content)) !== null) {
103
+ prompts.push(match[1].trim())
104
+ }
105
+
106
+ return prompts
107
+ }
108
+
109
+ /**
110
+ * Inject image URLs into article content
111
+ * Replaces [IMAGE: ...] placeholders with markdown images
112
+ * @param {string} content - Article content with placeholders
113
+ * @param {string[]} imageUrls - Array of image URLs to inject
114
+ * @returns {string} Content with images injected
115
+ */
116
+ export function injectImagesIntoContent(content, imageUrls) {
117
+ let imageIndex = 0
118
+ return content.replace(/\[IMAGE:\s*([^\]]+)\]/gi, (match, description) => {
119
+ if (imageIndex < imageUrls.length) {
120
+ const imgUrl = imageUrls[imageIndex]
121
+ imageIndex++
122
+ return `![${description.trim()}](${imgUrl})`
123
+ }
124
+ return match // Keep placeholder if no image available
125
+ })
126
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Suparank MCP - Formatting Utilities
3
+ *
4
+ * Content formatting and conversion utilities
5
+ */
6
+
7
+ import { marked } from 'marked'
8
+ import { log } from './logging.js'
9
+
10
+ /**
11
+ * Convert markdown to HTML using marked library
12
+ * Configured for WordPress/Ghost CMS compatibility
13
+ * @param {string} markdown - Markdown content
14
+ * @returns {string} HTML content
15
+ */
16
+ export function markdownToHtml(markdown) {
17
+ // Configure marked for CMS compatibility
18
+ marked.setOptions({
19
+ gfm: true, // GitHub Flavored Markdown
20
+ breaks: true, // Convert line breaks to <br>
21
+ pedantic: false,
22
+ silent: true // Don't throw on errors
23
+ })
24
+
25
+ try {
26
+ return marked.parse(markdown)
27
+ } catch (error) {
28
+ log(`Markdown conversion error: ${error.message}`)
29
+ // Fallback: return markdown wrapped in <p> tags
30
+ return `<p>${markdown.replace(/\n\n+/g, '</p><p>')}</p>`
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Convert aspect ratio string to fal.ai image size
36
+ * @param {string} ratio - Aspect ratio (e.g., '16:9', '1:1')
37
+ * @returns {string} fal.ai size identifier
38
+ */
39
+ export function aspectRatioToSize(ratio) {
40
+ const sizes = {
41
+ '1:1': 'square',
42
+ '16:9': 'landscape_16_9',
43
+ '9:16': 'portrait_16_9',
44
+ '4:3': 'landscape_4_3',
45
+ '3:4': 'portrait_4_3'
46
+ }
47
+ return sizes[ratio] || 'landscape_16_9'
48
+ }
49
+
50
+ /**
51
+ * Truncate text to a maximum length
52
+ * @param {string} text - Text to truncate
53
+ * @param {number} maxLength - Maximum length
54
+ * @returns {string} Truncated text
55
+ */
56
+ export function truncate(text, maxLength = 100) {
57
+ if (!text || text.length <= maxLength) return text
58
+ return text.substring(0, maxLength) + '...'
59
+ }
60
+
61
+ /**
62
+ * Sanitize a string for use as a slug/filename
63
+ * @param {string} str - String to sanitize
64
+ * @returns {string} Sanitized string
65
+ */
66
+ export function slugify(str) {
67
+ return str
68
+ .toLowerCase()
69
+ .replace(/[^a-z0-9]+/g, '-')
70
+ .replace(/^-|-$/g, '')
71
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Suparank MCP - Utils Index
3
+ *
4
+ * Re-exports all utility modules
5
+ */
6
+
7
+ export * from './logging.js'
8
+ export * from './paths.js'
9
+ export * from './formatting.js'
10
+ export * from './content.js'
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Suparank MCP - Logging Utilities
3
+ *
4
+ * Standardized logging to stderr (stdout is for MCP protocol)
5
+ */
6
+
7
+ /**
8
+ * Log message to stderr with [suparank] prefix
9
+ * @param {...any} args - Arguments to log
10
+ */
11
+ export function log(...args) {
12
+ console.error('[suparank]', ...args)
13
+ }
14
+
15
+ /**
16
+ * Structured progress logging for user visibility
17
+ * @param {string} step - Current step name
18
+ * @param {string} message - Progress message
19
+ */
20
+ export function progress(step, message) {
21
+ console.error(`[suparank] ${step}: ${message}`)
22
+ }
23
+
24
+ /**
25
+ * Log warning message
26
+ * @param {...any} args - Arguments to log
27
+ */
28
+ export function warn(...args) {
29
+ console.error('[suparank] WARNING:', ...args)
30
+ }
31
+
32
+ /**
33
+ * Log error message
34
+ * @param {...any} args - Arguments to log
35
+ */
36
+ export function error(...args) {
37
+ console.error('[suparank] ERROR:', ...args)
38
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Suparank MCP - Path Utilities
3
+ *
4
+ * Safe path handling and directory management
5
+ */
6
+
7
+ import * as fs from 'fs'
8
+ import * as path from 'path'
9
+ import * as os from 'os'
10
+
11
+ /**
12
+ * Get the Suparank configuration directory
13
+ * @returns {string} Path to ~/.suparank
14
+ */
15
+ export function getSuparankDir() {
16
+ return path.join(os.homedir(), '.suparank')
17
+ }
18
+
19
+ /**
20
+ * Get the content storage directory
21
+ * @returns {string} Path to ~/.suparank/content
22
+ */
23
+ export function getContentDir() {
24
+ return path.join(getSuparankDir(), 'content')
25
+ }
26
+
27
+ /**
28
+ * Get session file path
29
+ * @returns {string} Path to ~/.suparank/session.json
30
+ */
31
+ export function getSessionFilePath() {
32
+ return path.join(getSuparankDir(), 'session.json')
33
+ }
34
+
35
+ /**
36
+ * Get credentials file path
37
+ * @returns {string} Path to ~/.suparank/credentials.json
38
+ */
39
+ export function getCredentialsFilePath() {
40
+ return path.join(getSuparankDir(), 'credentials.json')
41
+ }
42
+
43
+ /**
44
+ * Get stats file path
45
+ * @returns {string} Path to ~/.suparank/stats.json
46
+ */
47
+ export function getStatsFilePath() {
48
+ return path.join(getSuparankDir(), 'stats.json')
49
+ }
50
+
51
+ /**
52
+ * Ensure the Suparank directory exists
53
+ */
54
+ export function ensureSuparankDir() {
55
+ const dir = getSuparankDir()
56
+ if (!fs.existsSync(dir)) {
57
+ fs.mkdirSync(dir, { recursive: true })
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Ensure the content directory exists
63
+ */
64
+ export function ensureContentDir() {
65
+ const dir = getContentDir()
66
+ if (!fs.existsSync(dir)) {
67
+ fs.mkdirSync(dir, { recursive: true })
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Sanitize and validate a path to prevent traversal attacks
73
+ * @param {string} userPath - User-provided path segment
74
+ * @param {string} allowedBase - Base directory that paths must stay within
75
+ * @returns {string} Resolved safe path
76
+ * @throws {Error} If path would escape the allowed base
77
+ */
78
+ export function sanitizePath(userPath, allowedBase) {
79
+ // Remove any null bytes (common attack vector)
80
+ const cleanPath = userPath.replace(/\0/g, '')
81
+
82
+ // Resolve to absolute path
83
+ const resolved = path.resolve(allowedBase, cleanPath)
84
+
85
+ // Ensure the resolved path starts with the allowed base
86
+ const normalizedBase = path.normalize(allowedBase + path.sep)
87
+ const normalizedResolved = path.normalize(resolved + path.sep)
88
+
89
+ if (!normalizedResolved.startsWith(normalizedBase)) {
90
+ throw new Error(`Path traversal detected: "${userPath}" would escape allowed directory`)
91
+ }
92
+
93
+ return resolved
94
+ }
95
+
96
+ /**
97
+ * Get content folder path safely
98
+ * @param {string} folderName - Folder name (article ID or title slug)
99
+ * @returns {string} Safe path to content folder
100
+ */
101
+ export function getContentFolderSafe(folderName) {
102
+ const contentDir = getContentDir()
103
+ return sanitizePath(folderName, contentDir)
104
+ }
105
+
106
+ /**
107
+ * Generate a slug from title for folder naming
108
+ * @param {string} text - Text to slugify
109
+ * @returns {string} URL-safe slug
110
+ */
111
+ export function slugify(text) {
112
+ return text
113
+ .toLowerCase()
114
+ .replace(/[^a-z0-9]+/g, '-')
115
+ .replace(/^-|-$/g, '')
116
+ .substring(0, 50)
117
+ }
118
+
119
+ /**
120
+ * Atomic file write - prevents corruption on concurrent writes
121
+ * @param {string} filePath - Target file path
122
+ * @param {string} data - Data to write
123
+ */
124
+ export function atomicWriteSync(filePath, data) {
125
+ const tmpFile = filePath + '.tmp.' + process.pid
126
+ try {
127
+ fs.writeFileSync(tmpFile, data)
128
+ fs.renameSync(tmpFile, filePath) // Atomic on POSIX
129
+ } catch (error) {
130
+ // Clean up temp file if rename failed
131
+ try { fs.unlinkSync(tmpFile) } catch (e) { /* ignore */ }
132
+ throw error
133
+ }
134
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Suparank MCP - Workflow Module
3
+ *
4
+ * Re-exports workflow planning functions
5
+ */
6
+
7
+ export {
8
+ buildWorkflowPlan,
9
+ validateProjectConfig
10
+ } from './planner.js'