crawlforge-mcp-server 3.5.1 → 4.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -4
- package/server.js +138 -26
- package/src/cli/commands/actions.js +36 -0
- package/src/cli/commands/analyze.js +19 -0
- package/src/cli/commands/batch.js +45 -0
- package/src/cli/commands/crawl.js +30 -0
- package/src/cli/commands/extract.js +45 -0
- package/src/cli/commands/install-skills.js +46 -0
- package/src/cli/commands/llmstxt.js +24 -0
- package/src/cli/commands/localize.js +29 -0
- package/src/cli/commands/map.js +26 -0
- package/src/cli/commands/monitor.js +29 -0
- package/src/cli/commands/research.js +26 -0
- package/src/cli/commands/scrape.js +37 -0
- package/src/cli/commands/search.js +28 -0
- package/src/cli/commands/stealth.js +29 -0
- package/src/cli/commands/template.js +26 -0
- package/src/cli/commands/track.js +24 -0
- package/src/cli/commands/uninstall-skills.js +35 -0
- package/src/cli/formatter.js +57 -0
- package/src/cli/index.js +94 -0
- package/src/cli/lib/runTool.js +40 -0
- package/src/core/ActionExecutor.js +8 -6
- package/src/core/AuthManager.js +103 -3
- package/src/core/ChangeTracker.js +34 -0
- package/src/core/ElicitationHelper.js +112 -0
- package/src/core/JobManager.js +36 -2
- package/src/core/LocalizationManager.js +19 -5
- package/src/core/PerformanceManager.js +53 -17
- package/src/core/ResearchOrchestrator.js +40 -5
- package/src/core/SamplingClient.js +191 -0
- package/src/core/StealthBrowserManager.js +248 -2
- package/src/core/WebhookDispatcher.js +18 -10
- package/src/prompts/PromptRegistry.js +199 -0
- package/src/resources/ResourceRegistry.js +273 -0
- package/src/server/withAuth.js +25 -0
- package/src/skills/crawlforge-cli.md +157 -0
- package/src/skills/crawlforge-mcp.md +80 -0
- package/src/skills/crawlforge-research.md +104 -0
- package/src/skills/crawlforge-stealth.md +98 -0
- package/src/skills/installer.js +141 -0
- package/src/tools/advanced/batchScrape/index.js +30 -0
- package/src/tools/advanced/batchScrape/schema.js +1 -1
- package/src/tools/basic/extractText.js +19 -8
- package/src/tools/crawl/crawlDeep.js +27 -0
- package/src/tools/extract/extractContent.js +5 -17
- package/src/tools/extract/extractStructured.js +8 -0
- package/src/tools/extract/extractWithLlm.js +25 -5
- package/src/tools/extract/processDocument.js +7 -1
- package/src/tools/extract/summarizeContent.js +17 -0
- package/src/tools/research/deepResearch.js +34 -0
- package/src/tools/templates/ScrapeTemplateTool.js +68 -0
- package/src/tools/templates/TemplateRegistry.js +311 -0
- package/src/utils/Logger.js +15 -0
- package/src/utils/htmlToMarkdown.js +54 -0
- package/src/utils/secretMask.js +86 -0
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with
|
|
3
|
+
"version": "4.2.1",
|
|
4
|
+
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with 23 web scraping, crawling, and content processing tools. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. v4.0 adds Markdown-first output, pre-built site templates, Camoufox stealth engine, and cost transparency.",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
7
|
-
"crawlforge": "
|
|
7
|
+
"crawlforge": "src/cli/index.js",
|
|
8
8
|
"crawlforge-setup": "setup.js"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"test:tools": "node test-tools.js",
|
|
20
20
|
"test:real-world": "node test-real-world.js",
|
|
21
21
|
"test:all": "bash run-all-tests.sh",
|
|
22
|
-
"postinstall": "echo '\n
|
|
22
|
+
"postinstall": "echo '\n\ud83c\udf89 CrawlForge MCP Server installed!\n\nRun \"npx crawlforge-setup\" to configure your API key and get started.\n'",
|
|
23
23
|
"docker:build": "docker build -t crawlforge .",
|
|
24
24
|
"docker:dev": "docker-compose up crawlforge-dev",
|
|
25
25
|
"docker:prod": "docker-compose up crawlforge-prod"
|
|
@@ -96,6 +96,7 @@
|
|
|
96
96
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
97
97
|
"@mozilla/readability": "^0.6.0",
|
|
98
98
|
"cheerio": "^1.1.2",
|
|
99
|
+
"commander": "^12.1.0",
|
|
99
100
|
"compromise": "^14.14.4",
|
|
100
101
|
"diff": "^8.0.2",
|
|
101
102
|
"dotenv": "^17.2.1",
|
|
@@ -109,6 +110,7 @@
|
|
|
109
110
|
"pdf-parse": "^1.1.1",
|
|
110
111
|
"playwright": "^1.54.2",
|
|
111
112
|
"robots-parser": "^3.0.1",
|
|
113
|
+
"turndown": "^7.2.4",
|
|
112
114
|
"winston": "^3.11.0",
|
|
113
115
|
"zod": "^3.23.8"
|
|
114
116
|
},
|
package/server.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
export { isCreatorModeVerified } from './src/core/creatorMode.js';
|
|
6
6
|
|
|
7
7
|
// Import everything else
|
|
8
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
|
+
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
9
9
|
import { z } from "zod";
|
|
10
10
|
import { logger } from "./src/utils/Logger.js";
|
|
11
11
|
import { SearchWebTool } from "./src/tools/search/searchWeb.js";
|
|
@@ -23,6 +23,7 @@ import { ScrapeWithActionsTool } from "./src/tools/advanced/ScrapeWithActionsToo
|
|
|
23
23
|
import { DeepResearchTool } from "./src/tools/research/deepResearch.js";
|
|
24
24
|
import { TrackChangesTool } from "./src/tools/tracking/trackChanges/index.js";
|
|
25
25
|
import { GenerateLLMsTxtTool } from "./src/tools/llmstxt/generateLLMsTxt.js";
|
|
26
|
+
import { ScrapeTemplateTool } from "./src/tools/templates/ScrapeTemplateTool.js"; // D3.3
|
|
26
27
|
import { StealthBrowserManager } from "./src/core/StealthBrowserManager.js";
|
|
27
28
|
import { LocalizationManager } from "./src/core/LocalizationManager.js";
|
|
28
29
|
import { memoryMonitor } from "./src/utils/MemoryMonitor.js";
|
|
@@ -43,6 +44,10 @@ import { extractTextHandler } from "./src/tools/basic/extractText.js";
|
|
|
43
44
|
import { extractLinksHandler } from "./src/tools/basic/extractLinks.js";
|
|
44
45
|
import { extractMetadataHandler } from "./src/tools/basic/extractMetadata.js";
|
|
45
46
|
import { scrapeStructuredHandler } from "./src/tools/basic/scrapeStructured.js";
|
|
47
|
+
// D1.1 Resources + D1.2 Prompts + D1.4 Elicitation
|
|
48
|
+
import { ResourceRegistry } from "./src/resources/ResourceRegistry.js";
|
|
49
|
+
import { PROMPTS, getPromptMessages } from "./src/prompts/PromptRegistry.js";
|
|
50
|
+
import { ElicitationHelper } from "./src/core/ElicitationHelper.js";
|
|
46
51
|
|
|
47
52
|
// Initialize Authentication Manager
|
|
48
53
|
await AuthManager.initialize();
|
|
@@ -90,8 +95,8 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
|
|
|
90
95
|
// Create the server
|
|
91
96
|
const server = new McpServer({
|
|
92
97
|
name: "crawlforge",
|
|
93
|
-
version: "
|
|
94
|
-
description: "Production-ready MCP server with
|
|
98
|
+
version: "4.2.1",
|
|
99
|
+
description: "Production-ready MCP server with 23 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, deep research, structured extraction, change tracking, and local-LLM extraction via Ollama.",
|
|
95
100
|
homepage: "https://www.crawlforge.dev",
|
|
96
101
|
icon: "https://www.crawlforge.dev/icon.png"
|
|
97
102
|
});
|
|
@@ -154,14 +159,99 @@ const scrapeWithActionsTool = new ScrapeWithActionsTool();
|
|
|
154
159
|
const deepResearchTool = new DeepResearchTool();
|
|
155
160
|
const trackChangesTool = new TrackChangesTool();
|
|
156
161
|
const generateLLMsTxtTool = new GenerateLLMsTxtTool();
|
|
162
|
+
const scrapeTemplateTool = new ScrapeTemplateTool(); // D3.3
|
|
157
163
|
const stealthBrowserManager = new StealthBrowserManager();
|
|
158
164
|
const localizationManager = new LocalizationManager();
|
|
159
165
|
|
|
166
|
+
// D1.1: Resource Registry (wired to existing singletons)
|
|
167
|
+
const resourceRegistry = new ResourceRegistry({
|
|
168
|
+
researchOrchestrator: deepResearchTool, // exposes activeSessions
|
|
169
|
+
snapshotManager: null, // SnapshotManager not directly instantiated in server.js
|
|
170
|
+
jobManager: batchScrapeTool.jobManager,
|
|
171
|
+
mapSiteTool,
|
|
172
|
+
scrapeWithActionsTool,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// D1.4: Elicitation helper (client may not support — fails open)
|
|
176
|
+
const elicitation = new ElicitationHelper({ mcpServer: server, logger });
|
|
177
|
+
|
|
178
|
+
// D1.4: Wire elicitation into tools and AuthManager
|
|
179
|
+
deepResearchTool.setMcpServer(server);
|
|
180
|
+
batchScrapeTool.setMcpServer(server);
|
|
181
|
+
crawlDeepTool.setMcpServer(server);
|
|
182
|
+
extractStructuredTool.setMcpServer(server);
|
|
183
|
+
AuthManager.setElicitation(elicitation);
|
|
184
|
+
|
|
185
|
+
// ─── D1.1 Resource Templates (MCP Resources) ─────────────────────────────────
|
|
186
|
+
// Resources use the MCP ResourceTemplate URI pattern for dynamic crawlforge:// URIs.
|
|
187
|
+
// The registry is populated at runtime as tools produce artifacts.
|
|
188
|
+
|
|
189
|
+
// Research sessions: crawlforge://research/{sessionId}
|
|
190
|
+
server.resource(
|
|
191
|
+
"crawlforge-research",
|
|
192
|
+
new ResourceTemplate("crawlforge://research/{sessionId}", {
|
|
193
|
+
list: async () => ({
|
|
194
|
+
resources: resourceRegistry.listResources().filter(r => r.uri.startsWith("crawlforge://research/"))
|
|
195
|
+
})
|
|
196
|
+
}),
|
|
197
|
+
{ description: "Completed deep_research report stored in the server session" },
|
|
198
|
+
async (uri) => resourceRegistry.readResource(uri)
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// Job results: crawlforge://job/{jobId}
|
|
202
|
+
server.resource(
|
|
203
|
+
"crawlforge-job",
|
|
204
|
+
new ResourceTemplate("crawlforge://job/{jobId}", {
|
|
205
|
+
list: async () => ({
|
|
206
|
+
resources: resourceRegistry.listResources().filter(r => r.uri.startsWith("crawlforge://job/"))
|
|
207
|
+
})
|
|
208
|
+
}),
|
|
209
|
+
{ description: "Completed batch_scrape job result" },
|
|
210
|
+
async (uri) => resourceRegistry.readResource(uri)
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// Crawl sitemaps: crawlforge://crawl/{sessionId}/sitemap
|
|
214
|
+
server.resource(
|
|
215
|
+
"crawlforge-crawl-sitemap",
|
|
216
|
+
new ResourceTemplate("crawlforge://crawl/{sessionId}/sitemap", {
|
|
217
|
+
list: async () => ({
|
|
218
|
+
resources: resourceRegistry.listResources().filter(r => r.uri.startsWith("crawlforge://crawl/"))
|
|
219
|
+
})
|
|
220
|
+
}),
|
|
221
|
+
{ description: "map_site output stored for a crawl session" },
|
|
222
|
+
async (uri) => resourceRegistry.readResource(uri)
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
// Screenshots: crawlforge://screenshot/{actionId}
|
|
226
|
+
server.resource(
|
|
227
|
+
"crawlforge-screenshot",
|
|
228
|
+
new ResourceTemplate("crawlforge://screenshot/{actionId}", {
|
|
229
|
+
list: async () => ({
|
|
230
|
+
resources: resourceRegistry.listResources().filter(r => r.uri.startsWith("crawlforge://screenshot/"))
|
|
231
|
+
})
|
|
232
|
+
}),
|
|
233
|
+
{ description: "Screenshot from scrape_with_actions" },
|
|
234
|
+
async (uri) => resourceRegistry.readResource(uri)
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
// ─── D1.2 Prompts (workflow templates) ────────────────────────────────────────
|
|
238
|
+
// Register the 5 CrawlForge workflow prompts from PromptRegistry.
|
|
239
|
+
|
|
240
|
+
for (const p of PROMPTS) {
|
|
241
|
+
const argsShape = {};
|
|
242
|
+
for (const arg of p.arguments) {
|
|
243
|
+
argsShape[arg.name] = z.string().optional().describe(arg.description);
|
|
244
|
+
}
|
|
245
|
+
server.registerPrompt(p.name, { description: p.description, argsSchema: argsShape }, async (args) => {
|
|
246
|
+
return getPromptMessages(p.name, args || {});
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
160
250
|
// ─── Tool registrations ────────────────────────────────────────────────────────
|
|
161
251
|
|
|
162
252
|
// Tool: fetch_url
|
|
163
253
|
server.registerTool("fetch_url", {
|
|
164
|
-
description: "
|
|
254
|
+
description: "Use this when you need raw HTTP content from a URL — HTML, JSON, XML, or plain text. Ideal as the first step before extract_text or extract_content. Supports custom headers (e.g. auth tokens) and configurable timeout. Example: fetch_url({url: \"https://example.com\", timeout: 15000})",
|
|
165
255
|
annotations: { title: "Fetch URL", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
166
256
|
inputSchema: {
|
|
167
257
|
url: z.string().url().describe("The URL to fetch content from"),
|
|
@@ -172,18 +262,19 @@ server.registerTool("fetch_url", {
|
|
|
172
262
|
|
|
173
263
|
// Tool: extract_text
|
|
174
264
|
server.registerTool("extract_text", {
|
|
175
|
-
description: "
|
|
265
|
+
description: "Use this when you need a page's human-readable text or markdown stripped of HTML tags, scripts, and styles — e.g. for keyword search, summarization, RAG ingestion, or NLP. Use output_format:\"markdown\" for RAG workflows. Faster than extract_content but returns unstructured content. Example: extract_text({url: \"https://example.com/article\", output_format:\"markdown\"})",
|
|
176
266
|
annotations: { title: "Extract Text", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
177
267
|
inputSchema: {
|
|
178
268
|
url: z.string().url().describe("The URL to extract text from"),
|
|
179
269
|
remove_scripts: z.boolean().optional().default(true).describe("Remove script tags before extraction"),
|
|
180
|
-
remove_styles: z.boolean().optional().default(true).describe("Remove style tags before extraction")
|
|
270
|
+
remove_styles: z.boolean().optional().default(true).describe("Remove style tags before extraction"),
|
|
271
|
+
output_format: z.enum(["text", "markdown"]).optional().default("text").describe("Output format: \"text\" (default) or \"markdown\" — use markdown for RAG workflows")
|
|
181
272
|
}
|
|
182
273
|
}, withAuth("extract_text", extractTextHandler));
|
|
183
274
|
|
|
184
275
|
// Tool: extract_links
|
|
185
276
|
server.registerTool("extract_links", {
|
|
186
|
-
description: "
|
|
277
|
+
description: "Use this when you need to discover all hyperlinks on a page — e.g. to build a crawl seed list, audit broken links, or find related resources. Use filter_external:true to get only outbound links. Example: extract_links({url: \"https://example.com\", filter_external: true})",
|
|
187
278
|
annotations: { title: "Extract Links", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
188
279
|
inputSchema: {
|
|
189
280
|
url: z.string().url().describe("The URL to extract links from"),
|
|
@@ -194,7 +285,7 @@ server.registerTool("extract_links", {
|
|
|
194
285
|
|
|
195
286
|
// Tool: extract_metadata
|
|
196
287
|
server.registerTool("extract_metadata", {
|
|
197
|
-
description: "
|
|
288
|
+
description: "Use this when you need a page's SEO metadata: title, meta description, Open Graph tags, canonical URL, schema.org data. Ideal for site audits and competitive SEO analysis. Example: extract_metadata({url: \"https://example.com\"})",
|
|
198
289
|
annotations: { title: "Extract Metadata", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
199
290
|
inputSchema: {
|
|
200
291
|
url: z.string().url().describe("The URL to extract metadata from")
|
|
@@ -203,7 +294,7 @@ server.registerTool("extract_metadata", {
|
|
|
203
294
|
|
|
204
295
|
// Tool: scrape_structured
|
|
205
296
|
server.registerTool("scrape_structured", {
|
|
206
|
-
description: "
|
|
297
|
+
description: "Use this when you know the exact CSS selectors for the data you want — e.g. scraping a pricing table or product list with consistent markup. More reliable than LLM extraction for well-structured pages. Example: scrape_structured({url: \"https://shop.com/products\", selectors: {price: \".price\", name: \".product-title\"}})",
|
|
207
298
|
annotations: { title: "Scrape Structured Data", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
208
299
|
inputSchema: {
|
|
209
300
|
url: z.string().url().describe("The URL to scrape"),
|
|
@@ -213,7 +304,7 @@ server.registerTool("scrape_structured", {
|
|
|
213
304
|
|
|
214
305
|
// Tool: search_web
|
|
215
306
|
server.registerTool("search_web", {
|
|
216
|
-
description: "
|
|
307
|
+
description: "Use this when you need web search results for a query — returns titles, URLs, snippets, and optional metadata. Supports language, date range, and site filters. Start research workflows here before using fetch_url or deep_research. Example: search_web({query: \"best MCP servers 2025\", limit: 10, time_range: \"month\"})",
|
|
217
308
|
annotations: { title: "Search the Web", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
218
309
|
inputSchema: {
|
|
219
310
|
query: z.string().describe("Search query string"),
|
|
@@ -239,7 +330,7 @@ server.registerTool("search_web", {
|
|
|
239
330
|
|
|
240
331
|
// Tool: crawl_deep
|
|
241
332
|
server.registerTool("crawl_deep", {
|
|
242
|
-
description: "
|
|
333
|
+
description: "Use this when you need to discover and optionally extract content from many pages within a site — e.g. building a knowledge base, indexing docs, or auditing all pages. Use map_site first to estimate scope, then crawl_deep for content. Example: crawl_deep({url: \"https://docs.example.com\", max_depth: 3, max_pages: 200, extract_content: true})",
|
|
243
334
|
annotations: { title: "Deep Crawl", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
244
335
|
inputSchema: {
|
|
245
336
|
url: z.string().url().describe("Starting URL for the crawl"),
|
|
@@ -266,7 +357,7 @@ server.registerTool("crawl_deep", {
|
|
|
266
357
|
|
|
267
358
|
// Tool: map_site
|
|
268
359
|
server.registerTool("map_site", {
|
|
269
|
-
description: "
|
|
360
|
+
description: "Use this when you need to know all URLs on a domain without fetching full page content — e.g. before a crawl_deep, for a site audit, or to find specific section URLs. Reads sitemap.xml when available. Example: map_site({url: \"https://example.com\", include_sitemap: true, max_urls: 500})",
|
|
270
361
|
annotations: { title: "Map Website", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
271
362
|
inputSchema: {
|
|
272
363
|
url: z.string().url().describe("The website URL to map"),
|
|
@@ -289,7 +380,7 @@ server.registerTool("map_site", {
|
|
|
289
380
|
|
|
290
381
|
// Tool: extract_content
|
|
291
382
|
server.registerTool("extract_content", {
|
|
292
|
-
description: "
|
|
383
|
+
description: "Use this when you need a clean, readable version of a web article or page — removes ads, nav, footers, and boilerplate. Ideal for RAG ingestion, summarization, or LLM context. Prefer this over extract_text for article-style pages. Example: extract_content({url: \"https://blog.example.com/post-title\"})",
|
|
293
384
|
annotations: { title: "Extract Content", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
294
385
|
inputSchema: {
|
|
295
386
|
url: z.string().url().describe("The URL to extract content from"),
|
|
@@ -309,7 +400,7 @@ server.registerTool("extract_content", {
|
|
|
309
400
|
|
|
310
401
|
// Tool: process_document
|
|
311
402
|
server.registerTool("process_document", {
|
|
312
|
-
description: "
|
|
403
|
+
description: "Use this when you need to extract text from a PDF URL or file — e.g. research papers, contracts, reports. Also handles HTML URLs. Returns structured sections, metadata, and word count. Example: process_document({source: \"https://example.com/report.pdf\", sourceType: \"pdf_url\"})",
|
|
313
404
|
annotations: { title: "Process Document", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
314
405
|
inputSchema: {
|
|
315
406
|
source: z.string().describe("Document source - URL or file path"),
|
|
@@ -330,7 +421,7 @@ server.registerTool("process_document", {
|
|
|
330
421
|
|
|
331
422
|
// Tool: summarize_content
|
|
332
423
|
server.registerTool("summarize_content", {
|
|
333
|
-
description: "
|
|
424
|
+
description: "Use this when you have text content (from extract_text or extract_content) and need a condensed version — e.g. for briefings, comparison tables, or LLM context reduction. Supports extractive (sentence selection) and abstractive (rewrite via Ollama/sampling) modes. Example: summarize_content({text: \"..long article..\", options: {summaryLength: \"short\", summaryType: \"abstractive\"}})",
|
|
334
425
|
annotations: { title: "Summarize Content", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
335
426
|
inputSchema: {
|
|
336
427
|
text: z.string().describe("The text content to summarize"),
|
|
@@ -350,7 +441,7 @@ server.registerTool("summarize_content", {
|
|
|
350
441
|
|
|
351
442
|
// Tool: analyze_content
|
|
352
443
|
server.registerTool("analyze_content", {
|
|
353
|
-
description: "
|
|
444
|
+
description: "Use this when you need NLP metrics for text — language detection, sentiment, topic extraction, entity recognition, readability score. Good for content auditing and classification. Example: analyze_content({text: \"..article text..\", options: {extractTopics: true, includeSentiment: true}})",
|
|
354
445
|
annotations: { title: "Analyze Content", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
355
446
|
inputSchema: {
|
|
356
447
|
text: z.string().describe("The text content to analyze"),
|
|
@@ -370,7 +461,7 @@ server.registerTool("analyze_content", {
|
|
|
370
461
|
|
|
371
462
|
// Tool: extract_structured
|
|
372
463
|
server.registerTool("extract_structured", {
|
|
373
|
-
description: "
|
|
464
|
+
description: "Use this when you need a specific data shape extracted from a page using a JSON schema — e.g. product details, job listings, event data. Uses LLM by default; falls back to CSS selectors when no LLM is configured. Example: extract_structured({url: \"https://jobs.example.com/post/123\", schema: {properties: {title: {type:\"string\"}, salary: {type:\"string\"}}, required:[\"title\"]}})",
|
|
374
465
|
annotations: { title: "Extract Structured Data", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
375
466
|
inputSchema: {
|
|
376
467
|
url: z.string().url().describe("The URL to extract structured data from"),
|
|
@@ -437,7 +528,7 @@ server.registerTool("list_ollama_models", {
|
|
|
437
528
|
|
|
438
529
|
// Tool: batch_scrape
|
|
439
530
|
server.registerTool("batch_scrape", {
|
|
440
|
-
description: "
|
|
531
|
+
description: "Use this when you need to scrape 2–50 URLs in parallel — e.g. batch-collecting product pages, news articles, or competitor pages. Use mode:\"async\" with a webhook for large batches; mode:\"sync\" for up to ~25 URLs when you need results immediately. Example: batch_scrape({urls: [\"https://a.com\",\"https://b.com\"], formats: [\"json\"], maxConcurrency: 5})",
|
|
441
532
|
annotations: { title: "Batch Scrape", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
442
533
|
inputSchema: {
|
|
443
534
|
urls: z.array(z.union([
|
|
@@ -482,7 +573,7 @@ server.registerTool("batch_scrape", {
|
|
|
482
573
|
|
|
483
574
|
// Tool: scrape_with_actions
|
|
484
575
|
server.registerTool("scrape_with_actions", {
|
|
485
|
-
description: "
|
|
576
|
+
description: "Use this when you need to interact with a page before scraping — login, click buttons, fill forms, scroll, or wait for dynamic content to load. Use for SPAs, login-gated content, or multi-step flows. Screenshots from this tool are stored as crawlforge://screenshot/{actionId} resources. Example: scrape_with_actions({url: \"https://app.com/dashboard\", actions: [{type:\"click\",selector:\"#login\"},{type:\"type\",selector:\"#email\",text:\"user@a.com\"}]})",
|
|
486
577
|
annotations: { title: "Scrape with Browser Actions", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
487
578
|
inputSchema: {
|
|
488
579
|
url: z.string().url().describe("The URL to scrape"),
|
|
@@ -538,7 +629,7 @@ server.registerTool("scrape_with_actions", {
|
|
|
538
629
|
|
|
539
630
|
// Tool: deep_research
|
|
540
631
|
server.registerTool("deep_research", {
|
|
541
|
-
description: "
|
|
632
|
+
description: "Use this when you need exhaustive multi-source research on a topic — it searches the web, fetches and analyses sources, detects conflicts, and (when LLM keys or Ollama are configured) synthesizes a report. Best for complex questions needing 10+ sources. Will request confirmation (elicitation) if maxUrls > 50. Results are stored as crawlforge://research/{sessionId} resources. Example: deep_research({topic: \"quantum computing NISQ devices 2025\", maxUrls: 30, researchApproach: \"academic\"})",
|
|
542
633
|
annotations: { title: "Deep Research", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
543
634
|
inputSchema: {
|
|
544
635
|
topic: z.string().min(3).max(500).describe("Research topic or question"),
|
|
@@ -594,7 +685,7 @@ server.registerTool("deep_research", {
|
|
|
594
685
|
|
|
595
686
|
// Tool: track_changes
|
|
596
687
|
server.registerTool("track_changes", {
|
|
597
|
-
description: "
|
|
688
|
+
description: "Use this when you need to monitor a URL for content changes over time — e.g. competitor pricing, regulation updates, product availability. Start with operation:\"create_baseline\", then periodically use operation:\"compare\" to diff. Supports webhooks and scheduled monitoring. Example: track_changes({url: \"https://example.com/pricing\", operation: \"create_baseline\"})",
|
|
598
689
|
annotations: { title: "Track Changes", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
599
690
|
inputSchema: {
|
|
600
691
|
url: z.string().url().describe("The URL to track changes for"),
|
|
@@ -699,7 +790,7 @@ server.registerTool("track_changes", {
|
|
|
699
790
|
|
|
700
791
|
// Tool: generate_llms_txt
|
|
701
792
|
server.registerTool("generate_llms_txt", {
|
|
702
|
-
description: "
|
|
793
|
+
description: "Use this when you need to generate an llms.txt file for a website — the standard that tells AI models how to interact with a site's content. Useful for site owners preparing for AI discoverability, or for understanding a site's AI access policy. Example: generate_llms_txt({url: \"https://example.com\"})",
|
|
703
794
|
annotations: { title: "Generate llms.txt", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
704
795
|
inputSchema: {
|
|
705
796
|
url: z.string().url().describe("The website URL to generate llms.txt for"),
|
|
@@ -733,7 +824,7 @@ server.registerTool("generate_llms_txt", {
|
|
|
733
824
|
|
|
734
825
|
// Tool: stealth_mode
|
|
735
826
|
server.registerTool("stealth_mode", {
|
|
736
|
-
description: "
|
|
827
|
+
description: "Use this when a site blocks normal scraping — Cloudflare, Datadome, or other bot-detection systems. Manages a Playwright browser with randomized fingerprints, human behavior simulation, WebRTC/canvas spoofing. Start with operation:\"create_context\" then use the contextId. Example: stealth_mode({operation:\"create_context\", stealthConfig:{level:\"advanced\", simulateHumanBehavior:true}})",
|
|
737
828
|
annotations: { title: "Stealth Mode", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
738
829
|
inputSchema: {
|
|
739
830
|
operation: z.enum(['configure', 'enable', 'disable', 'create_context', 'create_page', 'get_stats', 'cleanup']).default('configure').describe("Stealth operation to perform"),
|
|
@@ -775,6 +866,7 @@ server.registerTool("stealth_mode", {
|
|
|
775
866
|
hardwareSpoofing: z.boolean().default(true)
|
|
776
867
|
}).optional()
|
|
777
868
|
}).optional().describe("Stealth browser configuration with anti-detection settings"),
|
|
869
|
+
engine: z.enum(["playwright", "camoufox"]).optional().default("playwright").describe("Browser engine: \"playwright\" (Chromium, default) or \"camoufox\" (Firefox-based, higher anti-detect score — install with npm install camoufox)"),
|
|
778
870
|
contextId: z.string().optional().describe("Browser context ID for page operations"),
|
|
779
871
|
urlToTest: z.string().url().optional().describe("URL to navigate to when creating a page")
|
|
780
872
|
}
|
|
@@ -827,7 +919,7 @@ server.registerTool("stealth_mode", {
|
|
|
827
919
|
|
|
828
920
|
// Tool: localization
|
|
829
921
|
server.registerTool("localization", {
|
|
830
|
-
description: "
|
|
922
|
+
description: "Use this when you need to scrape geo-restricted content or emulate a specific locale/timezone — e.g. seeing region-specific pricing, bypassing geo-blocks, or searching in another language. Use operation:\"configure_country\" to set country context. Example: localization({operation:\"configure_country\", countryCode:\"DE\", language:\"de\"})",
|
|
831
923
|
annotations: { title: "Localization", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
832
924
|
inputSchema: {
|
|
833
925
|
operation: z.enum(['configure_country', 'localize_search', 'localize_browser', 'generate_timezone_spoof', 'handle_geo_blocking', 'auto_detect', 'get_stats', 'get_supported_countries']).default('configure_country').describe("Localization operation to perform"),
|
|
@@ -931,6 +1023,25 @@ server.registerTool("localization", {
|
|
|
931
1023
|
}
|
|
932
1024
|
}));
|
|
933
1025
|
|
|
1026
|
+
|
|
1027
|
+
// Tool: scrape_template (D3.3 — pre-built site templates)
|
|
1028
|
+
server.registerTool("scrape_template", {
|
|
1029
|
+
description: "Use this when you want structured data from a well-known site without writing custom selectors. Pass template:\"list\" to see all available templates. Supports: amazon-product, linkedin-profile, github-repo, youtube-video, tweet, reddit-thread, hacker-news-front-page, producthunt-launch, stackoverflow-question, npm-package. Example: scrape_template({template:\"github-repo\", url:\"https://github.com/user/repo\"})",
|
|
1030
|
+
annotations: { title: "Scrape Template", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1031
|
+
inputSchema: {
|
|
1032
|
+
template: z.string().describe("Template ID (e.g. github-repo) or list to enumerate available templates"),
|
|
1033
|
+
url: z.string().url().optional().describe("URL to scrape — required unless template is list"),
|
|
1034
|
+
timeout: z.number().min(5000).max(60000).optional().default(15000).describe("Request timeout in milliseconds")
|
|
1035
|
+
}
|
|
1036
|
+
}, withAuth("scrape_template", async (params) => {
|
|
1037
|
+
try {
|
|
1038
|
+
const result = await scrapeTemplateTool.execute(params);
|
|
1039
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
1040
|
+
} catch (error) {
|
|
1041
|
+
return { content: [{ type: "text", text: `Template scrape failed: ${error.message}` }], isError: true };
|
|
1042
|
+
}
|
|
1043
|
+
}));
|
|
1044
|
+
|
|
934
1045
|
// ─── Transport + startup ───────────────────────────────────────────────────────
|
|
935
1046
|
|
|
936
1047
|
const useHttp = process.argv.includes('--http') || process.env.MCP_HTTP === 'true';
|
|
@@ -980,9 +1091,10 @@ async function runServer() {
|
|
|
980
1091
|
"extract_content", "process_document", "summarize_content", "analyze_content",
|
|
981
1092
|
"batch_scrape", "scrape_with_actions",
|
|
982
1093
|
"deep_research", "track_changes", "generate_llms_txt",
|
|
983
|
-
"stealth_mode", "localization", "extract_structured", "extract_with_llm"
|
|
1094
|
+
"stealth_mode", "localization", "extract_structured", "extract_with_llm",
|
|
1095
|
+
"scrape_template" // D3.3
|
|
984
1096
|
];
|
|
985
|
-
console.error(`Tools available: ${allTools.join(
|
|
1097
|
+
console.error(`Tools available (23): ${allTools.join(", ")}`);
|
|
986
1098
|
|
|
987
1099
|
// Start memory monitoring in development
|
|
988
1100
|
if (config.server.nodeEnv === "development") {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* actions command — run browser automation actions from a script file.
|
|
3
|
+
*/
|
|
4
|
+
import { ScrapeWithActionsTool } from '../../tools/advanced/ScrapeWithActionsTool.js';
|
|
5
|
+
import { getToolConfig } from '../../constants/config.js';
|
|
6
|
+
import { runTool } from '../lib/runTool.js';
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
|
|
9
|
+
export function register(program) {
|
|
10
|
+
program
|
|
11
|
+
.command('actions <url>')
|
|
12
|
+
.description('Run browser automation actions against a URL')
|
|
13
|
+
.requiredOption('--script <file>', 'JSON file containing action script')
|
|
14
|
+
.option('--screenshot', 'Capture screenshot after actions')
|
|
15
|
+
.option('--wait <ms>', 'Wait time between actions in milliseconds', '500')
|
|
16
|
+
.action(async (url, opts, cmd) => {
|
|
17
|
+
const globals = cmd.parent.opts();
|
|
18
|
+
const cliFlags = { json: globals.json, pretty: globals.pretty, quiet: globals.quiet };
|
|
19
|
+
|
|
20
|
+
let actions;
|
|
21
|
+
try {
|
|
22
|
+
actions = JSON.parse(readFileSync(opts.script, 'utf8'));
|
|
23
|
+
} catch (e) {
|
|
24
|
+
process.stderr.write(`Error reading script file: ${e.message}\n`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const tool = new ScrapeWithActionsTool(getToolConfig('scrape_with_actions'));
|
|
29
|
+
await runTool(tool, {
|
|
30
|
+
url,
|
|
31
|
+
actions,
|
|
32
|
+
screenshot: !!opts.screenshot,
|
|
33
|
+
wait_between_actions: parseInt(opts.wait, 10)
|
|
34
|
+
}, cliFlags);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* analyze command — analyze content of a URL.
|
|
3
|
+
*/
|
|
4
|
+
import { AnalyzeContentTool } from '../../tools/extract/analyzeContent.js';
|
|
5
|
+
import { getToolConfig } from '../../constants/config.js';
|
|
6
|
+
import { runTool } from '../lib/runTool.js';
|
|
7
|
+
|
|
8
|
+
export function register(program) {
|
|
9
|
+
program
|
|
10
|
+
.command('analyze <url>')
|
|
11
|
+
.description('Analyze content of a URL (sentiment, entities, readability)')
|
|
12
|
+
.option('--depth <level>', 'Analysis depth: basic or full', 'basic')
|
|
13
|
+
.action(async (url, opts, cmd) => {
|
|
14
|
+
const globals = cmd.parent.opts();
|
|
15
|
+
const cliFlags = { json: globals.json, pretty: globals.pretty, quiet: globals.quiet };
|
|
16
|
+
const tool = new AnalyzeContentTool(getToolConfig('analyze_content'));
|
|
17
|
+
await runTool(tool, { url, analysis_depth: opts.depth }, cliFlags);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* batch command — scrape multiple URLs from a file.
|
|
3
|
+
* Reads newline-delimited URLs from the specified file.
|
|
4
|
+
*/
|
|
5
|
+
import { BatchScrapeTool } from '../../tools/advanced/BatchScrapeTool.js';
|
|
6
|
+
import { getToolConfig } from '../../constants/config.js';
|
|
7
|
+
import { runTool } from '../lib/runTool.js';
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
|
|
10
|
+
export function register(program) {
|
|
11
|
+
program
|
|
12
|
+
.command('batch <urls-file>')
|
|
13
|
+
.description('Scrape multiple URLs from a newline-delimited file')
|
|
14
|
+
.option('--format <fmt>', 'Output format: text, markdown, html', 'markdown')
|
|
15
|
+
.option('--concurrency <n>', 'Concurrent requests', '5')
|
|
16
|
+
.option('--max-retries <n>', 'Maximum retries per URL', '2')
|
|
17
|
+
.action(async (urlsFile, opts, cmd) => {
|
|
18
|
+
const globals = cmd.parent.opts();
|
|
19
|
+
const cliFlags = { json: globals.json, pretty: globals.pretty, quiet: globals.quiet };
|
|
20
|
+
|
|
21
|
+
let urls;
|
|
22
|
+
try {
|
|
23
|
+
urls = readFileSync(urlsFile, 'utf8')
|
|
24
|
+
.split('\n')
|
|
25
|
+
.map(l => l.trim())
|
|
26
|
+
.filter(l => l && !l.startsWith('#'));
|
|
27
|
+
} catch (e) {
|
|
28
|
+
process.stderr.write(`Error reading URLs file: ${e.message}\n`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (urls.length === 0) {
|
|
33
|
+
process.stderr.write('Error: No URLs found in file\n');
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const tool = new BatchScrapeTool(getToolConfig('batch_scrape'));
|
|
38
|
+
await runTool(tool, {
|
|
39
|
+
urls,
|
|
40
|
+
formats: [opts.format],
|
|
41
|
+
maxConcurrency: parseInt(opts.concurrency, 10),
|
|
42
|
+
jobOptions: { maxRetries: parseInt(opts.maxRetries, 10) }
|
|
43
|
+
}, cliFlags);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* crawl command — deep crawl a website using crawl_deep tool.
|
|
3
|
+
*/
|
|
4
|
+
import { CrawlDeepTool } from '../../tools/crawl/crawlDeep.js';
|
|
5
|
+
import { getToolConfig } from '../../constants/config.js';
|
|
6
|
+
import { runTool } from '../lib/runTool.js';
|
|
7
|
+
|
|
8
|
+
export function register(program) {
|
|
9
|
+
program
|
|
10
|
+
.command('crawl <url>')
|
|
11
|
+
.description('Deep crawl a website and extract its content')
|
|
12
|
+
.option('--depth <n>', 'Maximum crawl depth (1-5)', '3')
|
|
13
|
+
.option('--max-pages <n>', 'Maximum pages to crawl', '100')
|
|
14
|
+
.option('--no-robots', 'Ignore robots.txt')
|
|
15
|
+
.option('--follow-external', 'Follow external links')
|
|
16
|
+
.option('--concurrency <n>', 'Concurrent requests (1-20)', '10')
|
|
17
|
+
.action(async (url, opts, cmd) => {
|
|
18
|
+
const globals = cmd.parent.opts();
|
|
19
|
+
const cliFlags = { json: globals.json, pretty: globals.pretty, quiet: globals.quiet };
|
|
20
|
+
const tool = new CrawlDeepTool(getToolConfig('crawl_deep'));
|
|
21
|
+
await runTool(tool, {
|
|
22
|
+
url,
|
|
23
|
+
max_depth: parseInt(opts.depth, 10),
|
|
24
|
+
max_pages: parseInt(opts.maxPages, 10),
|
|
25
|
+
respect_robots: opts.robots !== false,
|
|
26
|
+
follow_external: !!opts.followExternal,
|
|
27
|
+
concurrency: parseInt(opts.concurrency, 10)
|
|
28
|
+
}, cliFlags);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* extract command — extract structured data or LLM-guided extraction.
|
|
3
|
+
* With --schema: uses extract_structured (JSON schema-based).
|
|
4
|
+
* With --prompt: uses extract_with_llm (natural language).
|
|
5
|
+
*/
|
|
6
|
+
import { ExtractStructuredTool } from '../../tools/extract/extractStructured.js';
|
|
7
|
+
import { ExtractWithLlm } from '../../tools/extract/extractWithLlm.js';
|
|
8
|
+
import { getToolConfig } from '../../constants/config.js';
|
|
9
|
+
import { runTool } from '../lib/runTool.js';
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
|
|
12
|
+
export function register(program) {
|
|
13
|
+
program
|
|
14
|
+
.command('extract <url>')
|
|
15
|
+
.description('Extract structured data from a URL')
|
|
16
|
+
.option('--schema <file>', 'JSON schema file for structured extraction')
|
|
17
|
+
.option('--prompt <text>', 'Natural language prompt for LLM-guided extraction')
|
|
18
|
+
.option('--model <model>', 'LLM model to use (ollama model name or openai/anthropic)')
|
|
19
|
+
.action(async (url, opts, cmd) => {
|
|
20
|
+
const globals = cmd.parent.opts();
|
|
21
|
+
const cliFlags = { json: globals.json, pretty: globals.pretty, quiet: globals.quiet };
|
|
22
|
+
|
|
23
|
+
if (opts.schema) {
|
|
24
|
+
let schema;
|
|
25
|
+
try {
|
|
26
|
+
schema = JSON.parse(readFileSync(opts.schema, 'utf8'));
|
|
27
|
+
} catch (e) {
|
|
28
|
+
process.stderr.write(`Error reading schema file: ${e.message}\n`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
const tool = new ExtractStructuredTool(getToolConfig('extract_structured'));
|
|
32
|
+
await runTool(tool, { url, schema }, cliFlags);
|
|
33
|
+
} else if (opts.prompt) {
|
|
34
|
+
const tool = new ExtractWithLlm(getToolConfig('extract_with_llm'));
|
|
35
|
+
await runTool(tool, {
|
|
36
|
+
url,
|
|
37
|
+
prompt: opts.prompt,
|
|
38
|
+
model: opts.model
|
|
39
|
+
}, cliFlags);
|
|
40
|
+
} else {
|
|
41
|
+
process.stderr.write('Error: extract requires --schema <file> or --prompt <text>\n');
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* install-skills command -- install CrawlForge skill files into AI coding tools.
|
|
3
|
+
*/
|
|
4
|
+
import { install } from '../../skills/installer.js';
|
|
5
|
+
|
|
6
|
+
export function register(program) {
|
|
7
|
+
program
|
|
8
|
+
.command('install-skills')
|
|
9
|
+
.description('Install CrawlForge skill files into Claude Code, Cursor, or VS Code')
|
|
10
|
+
.option('--target <target>', 'Target: claude-code, cursor, vscode, or all', 'all')
|
|
11
|
+
.option('--force', 'Overwrite existing skill files')
|
|
12
|
+
.option('--dry-run', 'Show what would be installed without writing files')
|
|
13
|
+
.action(async (opts) => {
|
|
14
|
+
try {
|
|
15
|
+
const results = await install({
|
|
16
|
+
target: opts.target,
|
|
17
|
+
force: Boolean(opts.force),
|
|
18
|
+
dryRun: Boolean(opts.dryRun),
|
|
19
|
+
cwd: process.cwd()
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
if (opts.dryRun) {
|
|
23
|
+
process.stdout.write('Dry run -- would install to:\n');
|
|
24
|
+
results.paths.forEach(p => process.stdout.write(' ' + p + '\n'));
|
|
25
|
+
process.exit(0);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (results.installed.length > 0) {
|
|
30
|
+
process.stdout.write('Installed:\n');
|
|
31
|
+
results.installed.forEach(p => process.stdout.write(' ' + p + '\n'));
|
|
32
|
+
}
|
|
33
|
+
if (results.skipped.length > 0) {
|
|
34
|
+
process.stdout.write('Skipped (already installed; use --force to overwrite):\n');
|
|
35
|
+
results.skipped.forEach(p => process.stdout.write(' ' + p + '\n'));
|
|
36
|
+
}
|
|
37
|
+
if (results.installed.length === 0 && results.skipped.length === 0) {
|
|
38
|
+
process.stdout.write('Nothing to install.\n');
|
|
39
|
+
}
|
|
40
|
+
process.exit(0);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
process.stderr.write('Error: ' + err.message + '\n');
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|