@squidcode/forever-plugin 0.3.0 → 0.5.0
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/README.md +66 -7
- package/dist/client.d.ts +3 -1
- package/dist/client.js +2 -2
- package/dist/files.d.ts +8 -0
- package/dist/files.js +31 -0
- package/dist/index.js +433 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
MCP (Model Context Protocol) plugin for [Forever](https://forever.squidcode.com) — a centralized persistent memory layer for Claude Code instances.
|
|
4
4
|
|
|
5
|
-
Forever lets multiple Claude Code sessions share memory across machines, projects, and time. This plugin connects Claude Code to your Forever server via MCP.
|
|
5
|
+
Forever lets multiple Claude Code sessions share memory and files across machines, projects, and time. This plugin connects Claude Code to your Forever server via MCP.
|
|
6
6
|
|
|
7
7
|
## Prerequisites
|
|
8
8
|
|
|
@@ -34,30 +34,41 @@ This registers the plugin as an MCP server that Claude Code will start automatic
|
|
|
34
34
|
|
|
35
35
|
## Tools
|
|
36
36
|
|
|
37
|
-
The plugin exposes
|
|
37
|
+
The plugin exposes the following MCP tools:
|
|
38
38
|
|
|
39
|
-
###
|
|
39
|
+
### Memory Tools
|
|
40
|
+
|
|
41
|
+
#### `memory_log`
|
|
40
42
|
|
|
41
43
|
Log an entry to Forever memory.
|
|
42
44
|
|
|
43
45
|
| Parameter | Type | Required | Description |
|
|
44
46
|
|-------------|----------|----------|--------------------------------------|
|
|
45
|
-
| `project` | string |
|
|
47
|
+
| `project` | string | no | Project name or git remote URL (auto-detected) |
|
|
46
48
|
| `type` | enum | yes | `summary`, `decision`, or `error` |
|
|
47
49
|
| `content` | string | yes | The content to log |
|
|
48
50
|
| `tags` | string[] | no | Tags for categorization |
|
|
49
51
|
| `sessionId` | string | no | Session ID for grouping entries |
|
|
50
52
|
|
|
51
|
-
|
|
53
|
+
#### `memory_get_recent`
|
|
52
54
|
|
|
53
55
|
Get recent memory entries for a project.
|
|
54
56
|
|
|
55
57
|
| Parameter | Type | Required | Description |
|
|
56
58
|
|-----------|--------|----------|--------------------------------|
|
|
57
|
-
| `project` | string |
|
|
59
|
+
| `project` | string | no | Project name or git remote URL (auto-detected) |
|
|
58
60
|
| `limit` | number | no | Number of entries (default 20) |
|
|
59
61
|
|
|
60
|
-
|
|
62
|
+
#### `memory_get_sessions`
|
|
63
|
+
|
|
64
|
+
Get recent sessions grouped by session with machine info. Use at startup to detect cross-machine handoffs.
|
|
65
|
+
|
|
66
|
+
| Parameter | Type | Required | Description |
|
|
67
|
+
|-----------|--------|----------|--------------------------------|
|
|
68
|
+
| `project` | string | no | Project name or git remote URL (auto-detected) |
|
|
69
|
+
| `limit` | number | no | Number of sessions (default 10) |
|
|
70
|
+
|
|
71
|
+
#### `memory_search`
|
|
61
72
|
|
|
62
73
|
Search memory entries across projects.
|
|
63
74
|
|
|
@@ -68,11 +79,59 @@ Search memory entries across projects.
|
|
|
68
79
|
| `type` | enum | no | Filter by entry type |
|
|
69
80
|
| `limit` | number | no | Max results (default 20) |
|
|
70
81
|
|
|
82
|
+
### File Tools
|
|
83
|
+
|
|
84
|
+
#### `memory_store_file`
|
|
85
|
+
|
|
86
|
+
Store a file in Forever for cross-machine access.
|
|
87
|
+
|
|
88
|
+
| Parameter | Type | Required | Description |
|
|
89
|
+
|------------|--------|----------|---------------------------------|
|
|
90
|
+
| `filePath` | string | yes | Path to the file (relative or absolute) |
|
|
91
|
+
| `project` | string | no | Project name (auto-detected) |
|
|
92
|
+
|
|
93
|
+
#### `memory_restore_file`
|
|
94
|
+
|
|
95
|
+
Restore a file from Forever to the local disk.
|
|
96
|
+
|
|
97
|
+
| Parameter | Type | Required | Description |
|
|
98
|
+
|------------|--------|----------|---------------------------------|
|
|
99
|
+
| `filePath` | string | yes | Path of the file to restore |
|
|
100
|
+
| `project` | string | no | Project name (auto-detected) |
|
|
101
|
+
|
|
102
|
+
#### `memory_share_file`
|
|
103
|
+
|
|
104
|
+
Mark a file for auto-sync across machines (also stores it immediately).
|
|
105
|
+
|
|
106
|
+
| Parameter | Type | Required | Description |
|
|
107
|
+
|------------|--------|----------|---------------------------------|
|
|
108
|
+
| `filePath` | string | yes | Path to the file to share |
|
|
109
|
+
| `project` | string | no | Project name (auto-detected) |
|
|
110
|
+
|
|
111
|
+
#### `memory_unshare_file`
|
|
112
|
+
|
|
113
|
+
Stop auto-syncing a file across machines.
|
|
114
|
+
|
|
115
|
+
| Parameter | Type | Required | Description |
|
|
116
|
+
|------------|--------|----------|---------------------------------|
|
|
117
|
+
| `filePath` | string | yes | Path of the file to stop sharing |
|
|
118
|
+
| `project` | string | no | Project name (auto-detected) |
|
|
119
|
+
|
|
120
|
+
#### `memory_sync_files`
|
|
121
|
+
|
|
122
|
+
Sync all shared files for a project — downloads newer versions, uploads local changes.
|
|
123
|
+
|
|
124
|
+
| Parameter | Type | Required | Description |
|
|
125
|
+
|-----------|--------|----------|------------------------------|
|
|
126
|
+
| `project` | string | no | Project name (auto-detected) |
|
|
127
|
+
|
|
71
128
|
## How It Works
|
|
72
129
|
|
|
73
130
|
- The plugin runs as an MCP stdio server, started by Claude Code on demand.
|
|
74
131
|
- Each machine gets a unique ID (stored in `~/.forever/machine.json`) for tracking which machine produced each memory entry.
|
|
75
132
|
- All API calls are authenticated via JWT token obtained during login.
|
|
133
|
+
- Files up to 1MB are supported; binary files are automatically base64-encoded.
|
|
134
|
+
- File deduplication uses MD5 hashing — unchanged files are not re-uploaded.
|
|
76
135
|
|
|
77
136
|
## Development
|
|
78
137
|
|
package/dist/client.d.ts
CHANGED
|
@@ -11,5 +11,7 @@ export declare function getCredentials(): Credentials | null;
|
|
|
11
11
|
export declare function saveCredentials(creds: Credentials): void;
|
|
12
12
|
export declare function getMachineConfig(): MachineConfig | null;
|
|
13
13
|
export declare function saveMachineConfig(config: MachineConfig): void;
|
|
14
|
-
export declare function createApiClient(
|
|
14
|
+
export declare function createApiClient(options?: {
|
|
15
|
+
timeout?: number;
|
|
16
|
+
}): AxiosInstance | null;
|
|
15
17
|
export {};
|
package/dist/client.js
CHANGED
|
@@ -42,7 +42,7 @@ export function saveMachineConfig(config) {
|
|
|
42
42
|
mode: 0o600,
|
|
43
43
|
});
|
|
44
44
|
}
|
|
45
|
-
export function createApiClient() {
|
|
45
|
+
export function createApiClient(options) {
|
|
46
46
|
const creds = getCredentials();
|
|
47
47
|
if (!creds)
|
|
48
48
|
return null;
|
|
@@ -52,6 +52,6 @@ export function createApiClient() {
|
|
|
52
52
|
Authorization: `Bearer ${creds.token}`,
|
|
53
53
|
'Content-Type': 'application/json',
|
|
54
54
|
},
|
|
55
|
-
timeout: 10000,
|
|
55
|
+
timeout: options?.timeout ?? 10000,
|
|
56
56
|
});
|
|
57
57
|
}
|
package/dist/files.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function computeMd5(buffer: Buffer): string;
|
|
2
|
+
export declare function isBinary(buffer: Buffer): boolean;
|
|
3
|
+
export declare function readAndEncodeFile(filePath: string): {
|
|
4
|
+
content: string;
|
|
5
|
+
hash: string;
|
|
6
|
+
size: number;
|
|
7
|
+
};
|
|
8
|
+
export declare function writeDecodedFile(filePath: string, content: string): void;
|
package/dist/files.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
3
|
+
import { dirname } from 'path';
|
|
4
|
+
export function computeMd5(buffer) {
|
|
5
|
+
return createHash('md5').update(buffer).digest('hex');
|
|
6
|
+
}
|
|
7
|
+
export function isBinary(buffer) {
|
|
8
|
+
const sample = buffer.subarray(0, 8192);
|
|
9
|
+
return sample.includes(0);
|
|
10
|
+
}
|
|
11
|
+
export function readAndEncodeFile(filePath) {
|
|
12
|
+
const buffer = readFileSync(filePath);
|
|
13
|
+
if (buffer.length > 1_048_576) {
|
|
14
|
+
throw new Error(`File exceeds 1MB limit (${buffer.length} bytes)`);
|
|
15
|
+
}
|
|
16
|
+
const hash = computeMd5(buffer);
|
|
17
|
+
const content = isBinary(buffer)
|
|
18
|
+
? `base64:${buffer.toString('base64')}`
|
|
19
|
+
: buffer.toString('utf-8');
|
|
20
|
+
return { content, hash, size: buffer.length };
|
|
21
|
+
}
|
|
22
|
+
export function writeDecodedFile(filePath, content) {
|
|
23
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
24
|
+
if (content.startsWith('base64:')) {
|
|
25
|
+
const data = Buffer.from(content.slice(7), 'base64');
|
|
26
|
+
writeFileSync(filePath, data);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
writeFileSync(filePath, content, 'utf-8');
|
|
30
|
+
}
|
|
31
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -7,9 +7,12 @@ import { randomBytes } from 'crypto';
|
|
|
7
7
|
import { basename } from 'path';
|
|
8
8
|
import { createApiClient } from './client.js';
|
|
9
9
|
import { getOrCreateMachineId } from './machine.js';
|
|
10
|
+
import { readAndEncodeFile, writeDecodedFile, computeMd5 } from './files.js';
|
|
11
|
+
import { readFileSync, existsSync } from 'fs';
|
|
12
|
+
import { resolve } from 'path';
|
|
10
13
|
const server = new McpServer({
|
|
11
14
|
name: 'forever',
|
|
12
|
-
version: '0.
|
|
15
|
+
version: '0.5.0',
|
|
13
16
|
});
|
|
14
17
|
const machineId = getOrCreateMachineId();
|
|
15
18
|
const sessionId = `${Date.now()}-${randomBytes(4).toString('hex')}`;
|
|
@@ -310,36 +313,445 @@ server.tool('memory_search', 'Search memory entries across projects', {
|
|
|
310
313
|
};
|
|
311
314
|
}
|
|
312
315
|
});
|
|
316
|
+
// --- File Storage & Sharing Tools ---
|
|
317
|
+
function resolveFilePath(filePath) {
|
|
318
|
+
return resolve(process.cwd(), filePath);
|
|
319
|
+
}
|
|
320
|
+
server.tool('memory_store_file', 'Store a file in Forever for cross-machine access', {
|
|
321
|
+
filePath: z
|
|
322
|
+
.string()
|
|
323
|
+
.describe('Path to the file to store (relative or absolute)'),
|
|
324
|
+
project: z
|
|
325
|
+
.string()
|
|
326
|
+
.optional()
|
|
327
|
+
.describe('Project name (auto-detected from git if omitted)'),
|
|
328
|
+
}, async ({ filePath, project }) => {
|
|
329
|
+
const api = createApiClient({ timeout: 30000 });
|
|
330
|
+
if (!api) {
|
|
331
|
+
return {
|
|
332
|
+
content: [
|
|
333
|
+
{
|
|
334
|
+
type: 'text',
|
|
335
|
+
text: 'Not authenticated. Run: npx @squidcode/forever-plugin login',
|
|
336
|
+
},
|
|
337
|
+
],
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
const resolvedProject = resolveProject(project);
|
|
341
|
+
if (!resolvedProject) {
|
|
342
|
+
return {
|
|
343
|
+
content: [
|
|
344
|
+
{
|
|
345
|
+
type: 'text',
|
|
346
|
+
text: 'Could not detect project. Please specify a project name.',
|
|
347
|
+
},
|
|
348
|
+
],
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
const absPath = resolveFilePath(filePath);
|
|
352
|
+
if (!existsSync(absPath)) {
|
|
353
|
+
return {
|
|
354
|
+
content: [
|
|
355
|
+
{ type: 'text', text: `File not found: ${absPath}` },
|
|
356
|
+
],
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
const { content, hash, size } = readAndEncodeFile(absPath);
|
|
361
|
+
const res = await api.post('/files/store', {
|
|
362
|
+
project: resolvedProject,
|
|
363
|
+
filePath,
|
|
364
|
+
content,
|
|
365
|
+
contentHash: hash,
|
|
366
|
+
machineId,
|
|
367
|
+
sessionId,
|
|
368
|
+
});
|
|
369
|
+
const dedup = res.data.deduplicated ? ' (unchanged, skipped)' : '';
|
|
370
|
+
return {
|
|
371
|
+
content: [
|
|
372
|
+
{
|
|
373
|
+
type: 'text',
|
|
374
|
+
text: `Stored "${filePath}" (${size} bytes)${dedup}`,
|
|
375
|
+
},
|
|
376
|
+
],
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
catch (err) {
|
|
380
|
+
const msg = err.response?.data?.message || err.message;
|
|
381
|
+
return {
|
|
382
|
+
content: [
|
|
383
|
+
{ type: 'text', text: `Failed to store file: ${msg}` },
|
|
384
|
+
],
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
server.tool('memory_restore_file', 'Restore a file from Forever to the local disk', {
|
|
389
|
+
filePath: z.string().describe('Path of the file to restore'),
|
|
390
|
+
project: z
|
|
391
|
+
.string()
|
|
392
|
+
.optional()
|
|
393
|
+
.describe('Project name (auto-detected from git if omitted)'),
|
|
394
|
+
}, async ({ filePath, project }) => {
|
|
395
|
+
const api = createApiClient({ timeout: 30000 });
|
|
396
|
+
if (!api) {
|
|
397
|
+
return {
|
|
398
|
+
content: [
|
|
399
|
+
{
|
|
400
|
+
type: 'text',
|
|
401
|
+
text: 'Not authenticated. Run: npx @squidcode/forever-plugin login',
|
|
402
|
+
},
|
|
403
|
+
],
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
const resolvedProject = resolveProject(project);
|
|
407
|
+
if (!resolvedProject) {
|
|
408
|
+
return {
|
|
409
|
+
content: [
|
|
410
|
+
{
|
|
411
|
+
type: 'text',
|
|
412
|
+
text: 'Could not detect project. Please specify a project name.',
|
|
413
|
+
},
|
|
414
|
+
],
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
try {
|
|
418
|
+
const res = await api.get('/files/latest', {
|
|
419
|
+
params: { project: resolvedProject, filePath },
|
|
420
|
+
});
|
|
421
|
+
if (!res.data) {
|
|
422
|
+
return {
|
|
423
|
+
content: [
|
|
424
|
+
{
|
|
425
|
+
type: 'text',
|
|
426
|
+
text: `No stored version found for "${filePath}"`,
|
|
427
|
+
},
|
|
428
|
+
],
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
const absPath = resolveFilePath(filePath);
|
|
432
|
+
writeDecodedFile(absPath, res.data.content);
|
|
433
|
+
return {
|
|
434
|
+
content: [
|
|
435
|
+
{
|
|
436
|
+
type: 'text',
|
|
437
|
+
text: `Restored "${filePath}" (hash: ${res.data.contentHash})`,
|
|
438
|
+
},
|
|
439
|
+
],
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
catch (err) {
|
|
443
|
+
const msg = err.response?.data?.message || err.message;
|
|
444
|
+
return {
|
|
445
|
+
content: [
|
|
446
|
+
{ type: 'text', text: `Failed to restore file: ${msg}` },
|
|
447
|
+
],
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
server.tool('memory_share_file', 'Mark a file for auto-sync across machines (also stores it immediately)', {
|
|
452
|
+
filePath: z.string().describe('Path to the file to share'),
|
|
453
|
+
project: z
|
|
454
|
+
.string()
|
|
455
|
+
.optional()
|
|
456
|
+
.describe('Project name (auto-detected from git if omitted)'),
|
|
457
|
+
}, async ({ filePath, project }) => {
|
|
458
|
+
const api = createApiClient({ timeout: 30000 });
|
|
459
|
+
if (!api) {
|
|
460
|
+
return {
|
|
461
|
+
content: [
|
|
462
|
+
{
|
|
463
|
+
type: 'text',
|
|
464
|
+
text: 'Not authenticated. Run: npx @squidcode/forever-plugin login',
|
|
465
|
+
},
|
|
466
|
+
],
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
const resolvedProject = resolveProject(project);
|
|
470
|
+
if (!resolvedProject) {
|
|
471
|
+
return {
|
|
472
|
+
content: [
|
|
473
|
+
{
|
|
474
|
+
type: 'text',
|
|
475
|
+
text: 'Could not detect project. Please specify a project name.',
|
|
476
|
+
},
|
|
477
|
+
],
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
const absPath = resolveFilePath(filePath);
|
|
481
|
+
if (!existsSync(absPath)) {
|
|
482
|
+
return {
|
|
483
|
+
content: [
|
|
484
|
+
{ type: 'text', text: `File not found: ${absPath}` },
|
|
485
|
+
],
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
try {
|
|
489
|
+
// Store the file first
|
|
490
|
+
const { content, hash, size } = readAndEncodeFile(absPath);
|
|
491
|
+
await api.post('/files/store', {
|
|
492
|
+
project: resolvedProject,
|
|
493
|
+
filePath,
|
|
494
|
+
content,
|
|
495
|
+
contentHash: hash,
|
|
496
|
+
machineId,
|
|
497
|
+
sessionId,
|
|
498
|
+
});
|
|
499
|
+
// Mark as shared
|
|
500
|
+
await api.post('/files/share', {
|
|
501
|
+
project: resolvedProject,
|
|
502
|
+
filePath,
|
|
503
|
+
});
|
|
504
|
+
return {
|
|
505
|
+
content: [
|
|
506
|
+
{
|
|
507
|
+
type: 'text',
|
|
508
|
+
text: `Shared "${filePath}" (${size} bytes) — will auto-sync across machines`,
|
|
509
|
+
},
|
|
510
|
+
],
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
catch (err) {
|
|
514
|
+
const msg = err.response?.data?.message || err.message;
|
|
515
|
+
return {
|
|
516
|
+
content: [
|
|
517
|
+
{ type: 'text', text: `Failed to share file: ${msg}` },
|
|
518
|
+
],
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
server.tool('memory_unshare_file', 'Stop auto-syncing a file across machines', {
|
|
523
|
+
filePath: z.string().describe('Path of the file to stop sharing'),
|
|
524
|
+
project: z
|
|
525
|
+
.string()
|
|
526
|
+
.optional()
|
|
527
|
+
.describe('Project name (auto-detected from git if omitted)'),
|
|
528
|
+
}, async ({ filePath, project }) => {
|
|
529
|
+
const api = createApiClient();
|
|
530
|
+
if (!api) {
|
|
531
|
+
return {
|
|
532
|
+
content: [
|
|
533
|
+
{
|
|
534
|
+
type: 'text',
|
|
535
|
+
text: 'Not authenticated. Run: npx @squidcode/forever-plugin login',
|
|
536
|
+
},
|
|
537
|
+
],
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
const resolvedProject = resolveProject(project);
|
|
541
|
+
if (!resolvedProject) {
|
|
542
|
+
return {
|
|
543
|
+
content: [
|
|
544
|
+
{
|
|
545
|
+
type: 'text',
|
|
546
|
+
text: 'Could not detect project. Please specify a project name.',
|
|
547
|
+
},
|
|
548
|
+
],
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
try {
|
|
552
|
+
await api.post('/files/unshare', {
|
|
553
|
+
project: resolvedProject,
|
|
554
|
+
filePath,
|
|
555
|
+
});
|
|
556
|
+
return {
|
|
557
|
+
content: [
|
|
558
|
+
{ type: 'text', text: `Stopped sharing "${filePath}"` },
|
|
559
|
+
],
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
catch (err) {
|
|
563
|
+
const msg = err.response?.data?.message || err.message;
|
|
564
|
+
return {
|
|
565
|
+
content: [
|
|
566
|
+
{ type: 'text', text: `Failed to unshare file: ${msg}` },
|
|
567
|
+
],
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
server.tool('memory_sync_files', 'Sync all shared files for a project — downloads newer versions, uploads local changes', {
|
|
572
|
+
project: z
|
|
573
|
+
.string()
|
|
574
|
+
.optional()
|
|
575
|
+
.describe('Project name (auto-detected from git if omitted)'),
|
|
576
|
+
}, async ({ project }) => {
|
|
577
|
+
const api = createApiClient({ timeout: 30000 });
|
|
578
|
+
if (!api) {
|
|
579
|
+
return {
|
|
580
|
+
content: [
|
|
581
|
+
{
|
|
582
|
+
type: 'text',
|
|
583
|
+
text: 'Not authenticated. Run: npx @squidcode/forever-plugin login',
|
|
584
|
+
},
|
|
585
|
+
],
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
const resolvedProject = resolveProject(project);
|
|
589
|
+
if (!resolvedProject) {
|
|
590
|
+
return {
|
|
591
|
+
content: [
|
|
592
|
+
{
|
|
593
|
+
type: 'text',
|
|
594
|
+
text: 'Could not detect project. Please specify a project name.',
|
|
595
|
+
},
|
|
596
|
+
],
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
try {
|
|
600
|
+
// Get list of shared files
|
|
601
|
+
const sharedRes = await api.get('/files/shared', {
|
|
602
|
+
params: { project: resolvedProject },
|
|
603
|
+
});
|
|
604
|
+
const sharedFiles = sharedRes.data;
|
|
605
|
+
if (!sharedFiles.length) {
|
|
606
|
+
return {
|
|
607
|
+
content: [
|
|
608
|
+
{
|
|
609
|
+
type: 'text',
|
|
610
|
+
text: `No shared files for "${resolvedProject}"`,
|
|
611
|
+
},
|
|
612
|
+
],
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
// Build local hash map
|
|
616
|
+
const localFiles = [];
|
|
617
|
+
for (const sf of sharedFiles) {
|
|
618
|
+
const absPath = resolveFilePath(sf.filePath);
|
|
619
|
+
if (existsSync(absPath)) {
|
|
620
|
+
const buffer = readFileSync(absPath);
|
|
621
|
+
localFiles.push({
|
|
622
|
+
filePath: sf.filePath,
|
|
623
|
+
contentHash: computeMd5(buffer),
|
|
624
|
+
exists: true,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
else {
|
|
628
|
+
localFiles.push({
|
|
629
|
+
filePath: sf.filePath,
|
|
630
|
+
contentHash: '',
|
|
631
|
+
exists: false,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
// Check sync status
|
|
636
|
+
const syncRes = await api.post('/files/sync', {
|
|
637
|
+
project: resolvedProject,
|
|
638
|
+
files: localFiles.map((f) => ({
|
|
639
|
+
filePath: f.filePath,
|
|
640
|
+
contentHash: f.contentHash,
|
|
641
|
+
})),
|
|
642
|
+
});
|
|
643
|
+
const results = [];
|
|
644
|
+
let downloaded = 0;
|
|
645
|
+
let uploaded = 0;
|
|
646
|
+
let upToDate = 0;
|
|
647
|
+
for (const file of syncRes.data.files) {
|
|
648
|
+
const local = localFiles.find((f) => f.filePath === file.filePath);
|
|
649
|
+
if (file.status === 'download_needed' ||
|
|
650
|
+
(file.status === 'upload_needed' && !local?.exists)) {
|
|
651
|
+
// Download from server
|
|
652
|
+
const latestRes = await api.get('/files/latest', {
|
|
653
|
+
params: { project: resolvedProject, filePath: file.filePath },
|
|
654
|
+
});
|
|
655
|
+
if (latestRes.data) {
|
|
656
|
+
const absPath = resolveFilePath(file.filePath);
|
|
657
|
+
writeDecodedFile(absPath, latestRes.data.content);
|
|
658
|
+
results.push(`↓ ${file.filePath}`);
|
|
659
|
+
downloaded++;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
else if (file.status === 'upload_needed' && local?.exists) {
|
|
663
|
+
// Upload to server
|
|
664
|
+
const { content, hash } = readAndEncodeFile(resolveFilePath(file.filePath));
|
|
665
|
+
await api.post('/files/store', {
|
|
666
|
+
project: resolvedProject,
|
|
667
|
+
filePath: file.filePath,
|
|
668
|
+
content,
|
|
669
|
+
contentHash: hash,
|
|
670
|
+
machineId,
|
|
671
|
+
sessionId,
|
|
672
|
+
});
|
|
673
|
+
results.push(`↑ ${file.filePath}`);
|
|
674
|
+
uploaded++;
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
upToDate++;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
const summary = [`Synced ${sharedFiles.length} shared file(s):`];
|
|
681
|
+
if (downloaded)
|
|
682
|
+
summary.push(` ${downloaded} downloaded`);
|
|
683
|
+
if (uploaded)
|
|
684
|
+
summary.push(` ${uploaded} uploaded`);
|
|
685
|
+
if (upToDate)
|
|
686
|
+
summary.push(` ${upToDate} up to date`);
|
|
687
|
+
if (results.length)
|
|
688
|
+
summary.push('', ...results);
|
|
689
|
+
return {
|
|
690
|
+
content: [{ type: 'text', text: summary.join('\n') }],
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
catch (err) {
|
|
694
|
+
const msg = err.response?.data?.message || err.message;
|
|
695
|
+
return {
|
|
696
|
+
content: [{ type: 'text', text: `Sync failed: ${msg}` }],
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
});
|
|
313
700
|
// Login subcommand
|
|
314
701
|
if (process.argv[2] === 'login') {
|
|
315
|
-
const
|
|
316
|
-
const rl = readline.createInterface({
|
|
317
|
-
input: process.stdin,
|
|
318
|
-
output: process.stdout,
|
|
319
|
-
});
|
|
320
|
-
const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
|
|
702
|
+
const SERVER_URL = 'https://forever.squidcode.com';
|
|
321
703
|
console.log('Forever Plugin Login\n');
|
|
322
|
-
const DEFAULT_SERVER = 'https://forever.squidcode.com';
|
|
323
|
-
const serverUrlInput = await ask(`Server URL [${DEFAULT_SERVER}]: `);
|
|
324
|
-
const serverUrl = serverUrlInput.trim() || DEFAULT_SERVER;
|
|
325
|
-
const email = await ask('Email: ');
|
|
326
|
-
const password = await ask('Password: ');
|
|
327
704
|
try {
|
|
328
705
|
const { default: axios } = await import('axios');
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
console.log(
|
|
706
|
+
// Request a device code
|
|
707
|
+
const codeRes = await axios.post(`${SERVER_URL}/api/auth/device/code`);
|
|
708
|
+
const { device_code, user_code, expires_in } = codeRes.data;
|
|
709
|
+
const authUrl = `${SERVER_URL}/auth/device?code=${user_code}`;
|
|
710
|
+
console.log('Your verification code:\n');
|
|
711
|
+
console.log(` ${user_code}\n`);
|
|
712
|
+
console.log(`Open this URL to authorize:\n ${authUrl}\n`);
|
|
713
|
+
// Try to open browser automatically
|
|
714
|
+
try {
|
|
715
|
+
const { execFileSync } = await import('child_process');
|
|
716
|
+
const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
717
|
+
execFileSync(cmd, [authUrl], { stdio: 'ignore' });
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
// Browser open failed — user can open manually
|
|
721
|
+
}
|
|
722
|
+
console.log('Waiting for authorization...');
|
|
723
|
+
const deadline = Date.now() + expires_in * 1000;
|
|
724
|
+
while (Date.now() < deadline) {
|
|
725
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
726
|
+
try {
|
|
727
|
+
const tokenRes = await axios.post(`${SERVER_URL}/api/auth/device/token`, { device_code });
|
|
728
|
+
const { saveCredentials } = await import('./client.js');
|
|
729
|
+
saveCredentials({
|
|
730
|
+
serverUrl: SERVER_URL,
|
|
731
|
+
token: tokenRes.data.access_token,
|
|
732
|
+
});
|
|
733
|
+
console.log('\nAuthenticated! Credentials saved to ~/.forever/credentials.json');
|
|
734
|
+
process.exit(0);
|
|
735
|
+
}
|
|
736
|
+
catch (err) {
|
|
737
|
+
const msg = err.response?.data?.message;
|
|
738
|
+
if (msg === 'authorization_pending') {
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
if (msg === 'expired_token') {
|
|
742
|
+
console.error('\nCode expired. Please run login again.');
|
|
743
|
+
process.exit(1);
|
|
744
|
+
}
|
|
745
|
+
throw err;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
console.error('\nCode expired. Please run login again.');
|
|
749
|
+
process.exit(1);
|
|
336
750
|
}
|
|
337
751
|
catch (err) {
|
|
338
752
|
console.error('\nLogin failed:', err.response?.data?.message || err.message);
|
|
339
753
|
process.exit(1);
|
|
340
754
|
}
|
|
341
|
-
rl.close();
|
|
342
|
-
process.exit(0);
|
|
343
755
|
}
|
|
344
756
|
// Start MCP server
|
|
345
757
|
const transport = new StdioServerTransport();
|