Skip to content

Commit cf7ab50

Browse files
committed
feat: add CLI support for direct command-line usage
- Added CLI interface using commander, chalk, and ora - Tool can now be used with npx or global installation - Supports all four main operations: save, get, search, status - Automatically detects CLI vs MCP server mode based on TTY and arguments - Added wayback command alias for easier CLI usage - Updated README with CLI documentation and examples - Added comprehensive CLI tests Users can now run: npx mcp-wayback-machine save https://example.com wayback get https://example.com --timestamp latest wayback search https://example.com --from 2023-01-01 wayback status https://example.com BREAKING CHANGE: The tool now checks for CLI arguments and will run in CLI mode if any are provided
1 parent 7b16208 commit cf7ab50

File tree

7 files changed

+444
-11
lines changed

7 files changed

+444
-11
lines changed

README.md

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@
44
[![GitHub](https://img.shields.io/github/license/Mearman/mcp-wayback-machine)](https://github.com/Mearman/mcp-wayback-machine)
55
[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/Mearman/mcp-wayback-machine/ci.yml?branch=main)](https://github.com/Mearman/mcp-wayback-machine/actions)
66

7-
An MCP (Model Context Protocol) server for interacting with the Internet Archive's Wayback Machine without requiring API keys.
7+
An MCP (Model Context Protocol) server and CLI tool for interacting with the Internet Archive's Wayback Machine without requiring API keys.
88

99
## Overview
1010

11-
This MCP server provides tools to:
11+
This tool can be used in two ways:
12+
1. **As an MCP server** - Integrate with Claude Desktop for AI-powered interactions
13+
2. **As a CLI tool** - Use directly from the command line with `npx` or global installation
14+
15+
Features:
1216
- Save web pages to the Wayback Machine
1317
- Retrieve archived versions of web pages
1418
- Check archive status and statistics
@@ -100,12 +104,27 @@ mcp-wayback-machine/
100104

101105
## Installation
102106

103-
### From npm
107+
### As a CLI Tool (Quick Start)
108+
109+
Use directly with npx (no installation needed):
110+
```bash
111+
npx mcp-wayback-machine save https://example.com
112+
```
113+
114+
Or install globally:
115+
```bash
116+
npm install -g mcp-wayback-machine
117+
wayback save https://example.com
118+
```
119+
120+
### As an MCP Server
121+
122+
Install for use with Claude Desktop:
104123
```bash
105124
npm install -g mcp-wayback-machine
106125
```
107126

108-
### From source
127+
### From Source
109128
```bash
110129
git clone https://github.com/Mearman/mcp-wayback-machine.git
111130
cd mcp-wayback-machine
@@ -115,6 +134,42 @@ yarn build
115134

116135
## Usage
117136

137+
### CLI Usage
138+
139+
The tool provides a `wayback` command (or use `npx mcp-wayback-machine`):
140+
141+
#### Save a URL
142+
```bash
143+
wayback save https://example.com
144+
# or
145+
npx mcp-wayback-machine save https://example.com
146+
```
147+
148+
#### Get an archived version
149+
```bash
150+
wayback get https://example.com
151+
wayback get https://example.com --timestamp 20231225120000
152+
wayback get https://example.com --timestamp latest
153+
```
154+
155+
#### Search archives
156+
```bash
157+
wayback search https://example.com
158+
wayback search https://example.com --limit 20
159+
wayback search https://example.com --from 2023-01-01 --to 2023-12-31
160+
```
161+
162+
#### Check archive status
163+
```bash
164+
wayback status https://example.com
165+
```
166+
167+
#### Get help
168+
```bash
169+
wayback --help
170+
wayback save --help
171+
```
172+
118173
### Claude Desktop Configuration
119174

120175
Add to your Claude Desktop settings:

package.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
{
22
"name": "mcp-wayback-machine",
33
"version": "1.0.4",
4-
"description": "MCP server for interacting with the Wayback Machine without API keys",
4+
"description": "MCP server and CLI tool for interacting with the Wayback Machine without API keys",
55
"main": "dist/index.js",
66
"type": "module",
7-
"bin": "dist/index.js",
7+
"bin": {
8+
"mcp-wayback-machine": "dist/index.js",
9+
"wayback": "dist/index.js"
10+
},
811
"scripts": {
912
"build": "tsc",
1013
"dev": "tsx watch src/index.ts",
@@ -47,6 +50,9 @@
4750
],
4851
"dependencies": {
4952
"@modelcontextprotocol/sdk": "^1.12.1",
53+
"chalk": "^5.4.1",
54+
"commander": "^14.0.0",
55+
"ora": "^8.2.0",
5056
"zod": "^3.25.51"
5157
},
5258
"devDependencies": {

src/cli.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { createCLI } from './cli.js';
3+
import * as saveModule from './tools/save.js';
4+
import * as retrieveModule from './tools/retrieve.js';
5+
import * as searchModule from './tools/search.js';
6+
import * as statusModule from './tools/status.js';
7+
8+
vi.mock('./tools/save.js');
9+
vi.mock('./tools/retrieve.js');
10+
vi.mock('./tools/search.js');
11+
vi.mock('./tools/status.js');
12+
13+
describe('CLI', () => {
14+
let consoleLogSpy: any;
15+
let consoleErrorSpy: any;
16+
17+
beforeEach(() => {
18+
vi.clearAllMocks();
19+
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
20+
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
21+
});
22+
23+
it('should create CLI program', () => {
24+
const program = createCLI();
25+
expect(program.name()).toBe('wayback');
26+
expect(program.description()).toContain('Wayback Machine');
27+
});
28+
29+
it('should handle save command', async () => {
30+
vi.spyOn(saveModule, 'saveUrl').mockResolvedValue({
31+
success: true,
32+
message: 'Saved',
33+
archivedUrl: 'https://web.archive.org/web/123/https://example.com',
34+
timestamp: '123',
35+
});
36+
37+
const program = createCLI();
38+
await program.parseAsync(['node', 'cli', 'save', 'https://example.com']);
39+
40+
expect(saveModule.saveUrl).toHaveBeenCalledWith({ url: 'https://example.com' });
41+
});
42+
43+
it('should handle get command', async () => {
44+
vi.spyOn(retrieveModule, 'getArchivedUrl').mockResolvedValue({
45+
success: true,
46+
message: 'Archive found',
47+
available: true,
48+
archivedUrl: 'https://web.archive.org/web/123/https://example.com',
49+
timestamp: '123',
50+
});
51+
52+
const program = createCLI();
53+
await program.parseAsync(['node', 'cli', 'get', 'https://example.com']);
54+
55+
expect(retrieveModule.getArchivedUrl).toHaveBeenCalledWith({
56+
url: 'https://example.com',
57+
timestamp: undefined
58+
});
59+
});
60+
61+
it('should handle search command', async () => {
62+
vi.spyOn(searchModule, 'searchArchives').mockResolvedValue({
63+
success: true,
64+
message: 'Found archives',
65+
results: [{
66+
url: 'https://example.com',
67+
archivedUrl: 'https://web.archive.org/web/123/https://example.com',
68+
timestamp: '123',
69+
date: '2023-01-01',
70+
statusCode: '200',
71+
mimeType: 'text/html',
72+
}],
73+
totalResults: 1,
74+
});
75+
76+
const program = createCLI();
77+
await program.parseAsync(['node', 'cli', 'search', 'https://example.com']);
78+
79+
expect(searchModule.searchArchives).toHaveBeenCalledWith({
80+
url: 'https://example.com',
81+
limit: 10,
82+
});
83+
});
84+
85+
it('should handle status command', async () => {
86+
vi.spyOn(statusModule, 'checkArchiveStatus').mockResolvedValue({
87+
success: true,
88+
message: 'Status checked',
89+
isArchived: true,
90+
totalCaptures: 100,
91+
firstCapture: '2020-01-01',
92+
lastCapture: '2023-12-31',
93+
});
94+
95+
const program = createCLI();
96+
await program.parseAsync(['node', 'cli', 'status', 'https://example.com']);
97+
98+
expect(statusModule.checkArchiveStatus).toHaveBeenCalledWith({
99+
url: 'https://example.com'
100+
});
101+
});
102+
});

src/cli.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { Command } from 'commander';
2+
import chalk from 'chalk';
3+
import ora from 'ora';
4+
import { saveUrl } from './tools/save.js';
5+
import { getArchivedUrl } from './tools/retrieve.js';
6+
import { searchArchives } from './tools/search.js';
7+
import { checkArchiveStatus } from './tools/status.js';
8+
9+
export function createCLI() {
10+
const program = new Command();
11+
12+
program
13+
.name('wayback')
14+
.description('CLI tool for interacting with the Wayback Machine')
15+
.version('1.0.0');
16+
17+
// Save URL command
18+
program
19+
.command('save <url>')
20+
.description('Save a URL to the Wayback Machine')
21+
.action(async (url: string) => {
22+
const spinner = ora('Saving URL to Wayback Machine...').start();
23+
try {
24+
const result = await saveUrl({ url });
25+
if (result.success) {
26+
spinner.succeed(chalk.green('URL saved successfully!'));
27+
console.log(chalk.blue('Archive URL:'), result.archivedUrl);
28+
if (result.timestamp) {
29+
console.log(chalk.blue('Timestamp:'), result.timestamp);
30+
}
31+
if (result.jobId) {
32+
console.log(chalk.blue('Job ID:'), result.jobId);
33+
}
34+
} else {
35+
spinner.fail(chalk.red(result.message));
36+
}
37+
} catch (error) {
38+
spinner.fail(chalk.red('Error saving URL'));
39+
console.error(error);
40+
}
41+
});
42+
43+
// Get archived URL command
44+
program
45+
.command('get <url>')
46+
.description('Get the archived version of a URL')
47+
.option('-t, --timestamp <timestamp>', 'Specific timestamp (YYYYMMDDHHMMSS) or "latest"')
48+
.action(async (url: string, options: { timestamp?: string }) => {
49+
const spinner = ora('Retrieving archived URL...').start();
50+
try {
51+
const result = await getArchivedUrl({ url, timestamp: options.timestamp });
52+
if (result.success && result.available) {
53+
spinner.succeed(chalk.green('Archive found!'));
54+
console.log(chalk.blue('Archived URL:'), result.archivedUrl);
55+
console.log(chalk.blue('Timestamp:'), result.timestamp);
56+
} else {
57+
spinner.fail(chalk.yellow(result.message || 'No archive found'));
58+
}
59+
} catch (error) {
60+
spinner.fail(chalk.red('Error retrieving archive'));
61+
console.error(error);
62+
}
63+
});
64+
65+
// Search archives command
66+
program
67+
.command('search <url>')
68+
.description('Search for all archived versions of a URL')
69+
.option('-f, --from <date>', 'Start date (YYYY-MM-DD)')
70+
.option('-t, --to <date>', 'End date (YYYY-MM-DD)')
71+
.option('-l, --limit <number>', 'Maximum number of results', '10')
72+
.action(async (url: string, options: { from?: string; to?: string; limit: string }) => {
73+
const spinner = ora('Searching archives...').start();
74+
try {
75+
const result = await searchArchives({
76+
url,
77+
from: options.from,
78+
to: options.to,
79+
limit: parseInt(options.limit, 10),
80+
});
81+
if (result.success && result.results && result.results.length > 0) {
82+
spinner.succeed(chalk.green(`Found ${result.totalResults} archives`));
83+
console.log('\n' + chalk.bold('Archive snapshots:'));
84+
result.results.forEach((snapshot) => {
85+
console.log(chalk.gray('─'.repeat(60)));
86+
console.log(chalk.blue('Date:'), snapshot.date);
87+
console.log(chalk.blue('URL:'), snapshot.archivedUrl);
88+
console.log(chalk.blue('Status:'), snapshot.statusCode);
89+
console.log(chalk.blue('Type:'), snapshot.mimeType);
90+
});
91+
} else {
92+
spinner.fail(chalk.yellow(result.message || 'No archives found'));
93+
}
94+
} catch (error) {
95+
spinner.fail(chalk.red('Error searching archives'));
96+
console.error(error);
97+
}
98+
});
99+
100+
// Check status command
101+
program
102+
.command('status <url>')
103+
.description('Check the archive status of a URL')
104+
.action(async (url: string) => {
105+
const spinner = ora('Checking archive status...').start();
106+
try {
107+
const result = await checkArchiveStatus({ url });
108+
if (result.success) {
109+
if (result.isArchived) {
110+
spinner.succeed(chalk.green('URL is archived!'));
111+
console.log(chalk.blue('Total captures:'), result.totalCaptures);
112+
console.log(chalk.blue('First capture:'), result.firstCapture);
113+
console.log(chalk.blue('Last capture:'), result.lastCapture);
114+
if (result.yearlyCaptures) {
115+
console.log('\n' + chalk.bold('Yearly captures:'));
116+
Object.entries(result.yearlyCaptures).forEach(([year, count]) => {
117+
console.log(chalk.blue(year + ':'), count);
118+
});
119+
}
120+
} else {
121+
spinner.warn(chalk.yellow('URL has not been archived'));
122+
}
123+
} else {
124+
spinner.fail(chalk.red(result.message || 'Error checking status'));
125+
}
126+
} catch (error) {
127+
spinner.fail(chalk.red('Error checking status'));
128+
console.error(error);
129+
}
130+
});
131+
132+
return program;
133+
}

src/index.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,9 +166,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
166166

167167
// Start the server
168168
async function main() {
169-
const transport = new StdioServerTransport();
170-
await server.connect(transport);
171-
console.error('MCP Wayback Machine server running on stdio');
169+
// Check if running as CLI (has TTY or has arguments beyond node and script)
170+
const isCliMode = process.stdin.isTTY || process.argv.length > 2;
171+
172+
if (isCliMode && process.argv.length > 2) {
173+
// Running as CLI tool
174+
const { createCLI } = await import('./cli.js');
175+
const program = createCLI();
176+
await program.parseAsync(process.argv);
177+
} else {
178+
// Running as MCP server
179+
const transport = new StdioServerTransport();
180+
await server.connect(transport);
181+
console.error('MCP Wayback Machine server running on stdio');
182+
}
172183
}
173184

174185
main().catch((error) => {

src/integration.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@ describe('Build artifact integration tests', () => {
2727
});
2828
});
2929

30-
// Start the server
30+
// Start the server in MCP mode (no TTY, no extra args)
3131
serverProcess = spawn('node', [join(__dirname, '../dist/index.js')], {
3232
stdio: ['pipe', 'pipe', 'pipe'],
33+
env: { ...process.env, NODE_ENV: 'test' },
3334
});
3435

3536
// Capture output

0 commit comments

Comments
 (0)