@0xmaxma/claude-gateway 1.0.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 +791 -0
- package/config.template.json +87 -0
- package/dist/agent/context-isolation.d.ts +28 -0
- package/dist/agent/context-isolation.d.ts.map +1 -0
- package/dist/agent/context-isolation.js +80 -0
- package/dist/agent/context-isolation.js.map +1 -0
- package/dist/agent/runner.d.ts +211 -0
- package/dist/agent/runner.d.ts.map +1 -0
- package/dist/agent/runner.js +1802 -0
- package/dist/agent/runner.js.map +1 -0
- package/dist/agent/workspace-loader.d.ts +31 -0
- package/dist/agent/workspace-loader.d.ts.map +1 -0
- package/dist/agent/workspace-loader.js +207 -0
- package/dist/agent/workspace-loader.js.map +1 -0
- package/dist/api/auth.d.ts +25 -0
- package/dist/api/auth.d.ts.map +1 -0
- package/dist/api/auth.js +77 -0
- package/dist/api/auth.js.map +1 -0
- package/dist/api/cron-router.d.ts +22 -0
- package/dist/api/cron-router.d.ts.map +1 -0
- package/dist/api/cron-router.js +197 -0
- package/dist/api/cron-router.js.map +1 -0
- package/dist/api/gateway-router.d.ts +52 -0
- package/dist/api/gateway-router.d.ts.map +1 -0
- package/dist/api/gateway-router.js +215 -0
- package/dist/api/gateway-router.js.map +1 -0
- package/dist/api/router.d.ts +5 -0
- package/dist/api/router.d.ts.map +1 -0
- package/dist/api/router.js +1098 -0
- package/dist/api/router.js.map +1 -0
- package/dist/api/skills-router.d.ts +5 -0
- package/dist/api/skills-router.d.ts.map +1 -0
- package/dist/api/skills-router.js +424 -0
- package/dist/api/skills-router.js.map +1 -0
- package/dist/api/workspace-router.d.ts +4 -0
- package/dist/api/workspace-router.d.ts.map +1 -0
- package/dist/api/workspace-router.js +149 -0
- package/dist/api/workspace-router.js.map +1 -0
- package/dist/config/loader.d.ts +16 -0
- package/dist/config/loader.d.ts.map +1 -0
- package/dist/config/loader.js +235 -0
- package/dist/config/loader.js.map +1 -0
- package/dist/config/migrator.d.ts +70 -0
- package/dist/config/migrator.d.ts.map +1 -0
- package/dist/config/migrator.js +343 -0
- package/dist/config/migrator.js.map +1 -0
- package/dist/config/watcher.d.ts +30 -0
- package/dist/config/watcher.d.ts.map +1 -0
- package/dist/config/watcher.js +170 -0
- package/dist/config/watcher.js.map +1 -0
- package/dist/cron/manager.d.ts +48 -0
- package/dist/cron/manager.d.ts.map +1 -0
- package/dist/cron/manager.js +580 -0
- package/dist/cron/manager.js.map +1 -0
- package/dist/cron/scheduler.d.ts +50 -0
- package/dist/cron/scheduler.d.ts.map +1 -0
- package/dist/cron/scheduler.js +231 -0
- package/dist/cron/scheduler.js.map +1 -0
- package/dist/discord/receiver.d.ts +17 -0
- package/dist/discord/receiver.d.ts.map +1 -0
- package/dist/discord/receiver.js +108 -0
- package/dist/discord/receiver.js.map +1 -0
- package/dist/heartbeat/history.d.ts +24 -0
- package/dist/heartbeat/history.d.ts.map +1 -0
- package/dist/heartbeat/history.js +50 -0
- package/dist/heartbeat/history.js.map +1 -0
- package/dist/heartbeat/parser.d.ts +26 -0
- package/dist/heartbeat/parser.d.ts.map +1 -0
- package/dist/heartbeat/parser.js +283 -0
- package/dist/heartbeat/parser.js.map +1 -0
- package/dist/history/cleanup.d.ts +21 -0
- package/dist/history/cleanup.d.ts.map +1 -0
- package/dist/history/cleanup.js +171 -0
- package/dist/history/cleanup.js.map +1 -0
- package/dist/history/db.d.ts +27 -0
- package/dist/history/db.d.ts.map +1 -0
- package/dist/history/db.js +323 -0
- package/dist/history/db.js.map +1 -0
- package/dist/history/media-store.d.ts +27 -0
- package/dist/history/media-store.d.ts.map +1 -0
- package/dist/history/media-store.js +158 -0
- package/dist/history/media-store.js.map +1 -0
- package/dist/history/types.d.ts +62 -0
- package/dist/history/types.d.ts.map +1 -0
- package/dist/history/types.js +3 -0
- package/dist/history/types.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +483 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +3 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +81 -0
- package/dist/logger.js.map +1 -0
- package/dist/memory/manager.d.ts +45 -0
- package/dist/memory/manager.d.ts.map +1 -0
- package/dist/memory/manager.js +178 -0
- package/dist/memory/manager.js.map +1 -0
- package/dist/session/compactor.d.ts +22 -0
- package/dist/session/compactor.d.ts.map +1 -0
- package/dist/session/compactor.js +151 -0
- package/dist/session/compactor.js.map +1 -0
- package/dist/session/process.d.ts +76 -0
- package/dist/session/process.d.ts.map +1 -0
- package/dist/session/process.js +729 -0
- package/dist/session/process.js.map +1 -0
- package/dist/session/store.d.ts +102 -0
- package/dist/session/store.d.ts.map +1 -0
- package/dist/session/store.js +533 -0
- package/dist/session/store.js.map +1 -0
- package/dist/skills/index.d.ts +9 -0
- package/dist/skills/index.d.ts.map +1 -0
- package/dist/skills/index.js +15 -0
- package/dist/skills/index.js.map +1 -0
- package/dist/skills/invoker.d.ts +16 -0
- package/dist/skills/invoker.d.ts.map +1 -0
- package/dist/skills/invoker.js +36 -0
- package/dist/skills/invoker.js.map +1 -0
- package/dist/skills/loader.d.ts +23 -0
- package/dist/skills/loader.d.ts.map +1 -0
- package/dist/skills/loader.js +183 -0
- package/dist/skills/loader.js.map +1 -0
- package/dist/skills/parser.d.ts +55 -0
- package/dist/skills/parser.d.ts.map +1 -0
- package/dist/skills/parser.js +178 -0
- package/dist/skills/parser.js.map +1 -0
- package/dist/skills/sync.d.ts +10 -0
- package/dist/skills/sync.d.ts.map +1 -0
- package/dist/skills/sync.js +130 -0
- package/dist/skills/sync.js.map +1 -0
- package/dist/skills/watcher.d.ts +16 -0
- package/dist/skills/watcher.d.ts.map +1 -0
- package/dist/skills/watcher.js +25 -0
- package/dist/skills/watcher.js.map +1 -0
- package/dist/telegram/markdown.d.ts +25 -0
- package/dist/telegram/markdown.d.ts.map +1 -0
- package/dist/telegram/markdown.js +237 -0
- package/dist/telegram/markdown.js.map +1 -0
- package/dist/telegram/receiver.d.ts +17 -0
- package/dist/telegram/receiver.d.ts.map +1 -0
- package/dist/telegram/receiver.js +104 -0
- package/dist/telegram/receiver.js.map +1 -0
- package/dist/types.d.ts +224 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/ui/web-ui.d.ts +6 -0
- package/dist/ui/web-ui.d.ts.map +1 -0
- package/dist/ui/web-ui.js +227 -0
- package/dist/ui/web-ui.js.map +1 -0
- package/dist/watch/factory.d.ts +26 -0
- package/dist/watch/factory.d.ts.map +1 -0
- package/dist/watch/factory.js +43 -0
- package/dist/watch/factory.js.map +1 -0
- package/dist/webhook/manager.d.ts +34 -0
- package/dist/webhook/manager.d.ts.map +1 -0
- package/dist/webhook/manager.js +120 -0
- package/dist/webhook/manager.js.map +1 -0
- package/mcp/bun.lock +264 -0
- package/mcp/channel-manager.ts +115 -0
- package/mcp/package.json +15 -0
- package/mcp/router.ts +69 -0
- package/mcp/server.ts +129 -0
- package/mcp/tools/agent/handlers.ts +578 -0
- package/mcp/tools/agent/module.ts +163 -0
- package/mcp/tools/browser/module.ts +321 -0
- package/mcp/tools/browser/skills/open-browser/SKILL.md +89 -0
- package/mcp/tools/cron/client.ts +65 -0
- package/mcp/tools/cron/module.ts +158 -0
- package/mcp/tools/cron/skills/cron/SKILL.md +46 -0
- package/mcp/tools/discord/access.ts +197 -0
- package/mcp/tools/discord/client.ts +31 -0
- package/mcp/tools/discord/commands.ts +48 -0
- package/mcp/tools/discord/inbound.ts +53 -0
- package/mcp/tools/discord/module.ts +442 -0
- package/mcp/tools/discord/outbound.ts +90 -0
- package/mcp/tools/discord/receiver-server.ts +58 -0
- package/mcp/tools/discord/skills/access/SKILL.md +86 -0
- package/mcp/tools/discord/skills/configure/SKILL.md +53 -0
- package/mcp/tools/discord/threading.ts +37 -0
- package/mcp/tools/discord/types.ts +106 -0
- package/mcp/tools/skills/handlers.ts +196 -0
- package/mcp/tools/skills/module.ts +163 -0
- package/mcp/tools/telegram/module.ts +454 -0
- package/mcp/tools/telegram/pure.ts +224 -0
- package/mcp/tools/telegram/receiver-server.ts +1656 -0
- package/mcp/tools/telegram/skills/access/SKILL.md +169 -0
- package/mcp/tools/telegram/skills/configure/SKILL.md +118 -0
- package/mcp/tools/telegram/typing.ts +327 -0
- package/mcp/types.ts +128 -0
- package/package.json +62 -0
- package/resource/claude_gateway.svg +112 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: discord-configure
|
|
3
|
+
description: Configure the Discord channel — save bot token, set auto-thread, embed options.
|
|
4
|
+
user-invocable: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# /gateway:discord-configure — Configure Discord
|
|
8
|
+
|
|
9
|
+
Use this skill to set up the Discord channel for an agent.
|
|
10
|
+
|
|
11
|
+
## Required setup
|
|
12
|
+
|
|
13
|
+
### 1. Save the bot token
|
|
14
|
+
```
|
|
15
|
+
/gateway:discord-configure token <BOT_TOKEN>
|
|
16
|
+
```
|
|
17
|
+
Writes `DISCORD_BOT_TOKEN=<token>` to `$DISCORD_STATE_DIR/.env`.
|
|
18
|
+
|
|
19
|
+
### 2. Set required env vars in agent config
|
|
20
|
+
Add to the agent's `.env` file:
|
|
21
|
+
```
|
|
22
|
+
DISCORD_BOT_TOKEN=<your-bot-token>
|
|
23
|
+
DISCORD_STATE_DIR=~/.claude-gateway/agents/<id>/.discord-state
|
|
24
|
+
DISCORD_DM_POLICY=disabled # open | allowlist | disabled
|
|
25
|
+
DISCORD_GUILD_ALLOWLIST= # comma-separated guild IDs (empty = all)
|
|
26
|
+
DISCORD_CHANNEL_ALLOWLIST= # comma-separated channel IDs (empty = all)
|
|
27
|
+
DISCORD_AUTO_THREAD=false # create thread per conversation
|
|
28
|
+
DISCORD_USE_EMBEDS=false # use embeds for long responses
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Discord Developer Portal checklist
|
|
32
|
+
|
|
33
|
+
1. Go to https://discord.com/developers/applications
|
|
34
|
+
2. Select your application → Bot settings
|
|
35
|
+
3. Enable **MESSAGE CONTENT INTENT** (privileged intent)
|
|
36
|
+
4. Required bot permissions:
|
|
37
|
+
- Send Messages
|
|
38
|
+
- Read Message History
|
|
39
|
+
- Create Public Threads
|
|
40
|
+
- Send Messages in Threads
|
|
41
|
+
- Attach Files
|
|
42
|
+
- Embed Links
|
|
43
|
+
- Add Reactions
|
|
44
|
+
- Use Application Commands
|
|
45
|
+
|
|
46
|
+
## Implementation
|
|
47
|
+
|
|
48
|
+
Parse the subcommand and act accordingly:
|
|
49
|
+
- `token <value>` — write `.env` file to DISCORD_STATE_DIR
|
|
50
|
+
- `show` — display current env vars (mask the token)
|
|
51
|
+
- `check` — verify the bot can connect (call discord_reply to a test channel if provided)
|
|
52
|
+
|
|
53
|
+
Confirm each action and display what was changed.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discord thread management — openclaw pattern from threading.ts.
|
|
3
|
+
* Uses local DiscordMessage interface; no top-level discord.js import.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { DiscordMessage } from './types';
|
|
7
|
+
|
|
8
|
+
export async function maybeCreateThread(
|
|
9
|
+
message: DiscordMessage,
|
|
10
|
+
autoThread: boolean,
|
|
11
|
+
autoArchiveDuration: number,
|
|
12
|
+
): Promise<{ id: string } | null> {
|
|
13
|
+
if (!autoThread) return null;
|
|
14
|
+
if (message.channel.isThread()) return null;
|
|
15
|
+
if (!message.guild) return null;
|
|
16
|
+
|
|
17
|
+
const name = sanitizeThreadName(message.content);
|
|
18
|
+
return message.startThread({ name, autoArchiveDuration });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function sanitizeThreadName(text: string): string {
|
|
22
|
+
return text
|
|
23
|
+
.replace(/<@!?\d+>/g, '')
|
|
24
|
+
.replace(/\s+/g, ' ')
|
|
25
|
+
.trim()
|
|
26
|
+
.slice(0, 100) || 'conversation';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildSessionKey(
|
|
30
|
+
agentId: string,
|
|
31
|
+
channelId: string,
|
|
32
|
+
userId: string,
|
|
33
|
+
threadId?: string | null,
|
|
34
|
+
): string {
|
|
35
|
+
const chatId = threadId ?? channelId;
|
|
36
|
+
return `${agentId}:discord:${userId}:${chatId}`;
|
|
37
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discord-specific types. No discord.js imports — pure TypeScript.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface DiscordPending {
|
|
6
|
+
senderId: string;
|
|
7
|
+
channelId: string;
|
|
8
|
+
createdAt: number;
|
|
9
|
+
expiresAt: number;
|
|
10
|
+
replies: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface DiscordAccess {
|
|
14
|
+
dmPolicy: 'pairing' | 'allowlist' | 'disabled';
|
|
15
|
+
allowFrom: string[];
|
|
16
|
+
guildAllowlist: string[];
|
|
17
|
+
channelAllowlist: string[];
|
|
18
|
+
roleAllowlist: string[];
|
|
19
|
+
pending: Record<string, DiscordPending>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type DiscordGateResult =
|
|
23
|
+
| { action: 'deliver' }
|
|
24
|
+
| { action: 'drop' }
|
|
25
|
+
| { action: 'pair'; code: string; isResend: boolean };
|
|
26
|
+
|
|
27
|
+
export type DiscordConfig = {
|
|
28
|
+
botToken: string;
|
|
29
|
+
guildAllowlist?: string[];
|
|
30
|
+
channelAllowlist?: string[];
|
|
31
|
+
dmPolicy: 'open' | 'allowlist' | 'disabled';
|
|
32
|
+
dmAllowlist?: string[];
|
|
33
|
+
autoThread: boolean;
|
|
34
|
+
autoThreadArchiveMinutes: 60 | 1440 | 4320 | 10080;
|
|
35
|
+
maxMessageLength: number;
|
|
36
|
+
useEmbeds: boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type DiscordMessageContext = {
|
|
40
|
+
guildId: string | null;
|
|
41
|
+
channelId: string;
|
|
42
|
+
threadId: string | null;
|
|
43
|
+
userId: string;
|
|
44
|
+
username: string;
|
|
45
|
+
messageId: string;
|
|
46
|
+
isDM: boolean;
|
|
47
|
+
isThread: boolean;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type DiscordAccessConfig = {
|
|
51
|
+
dmPolicy: 'open' | 'allowlist' | 'disabled';
|
|
52
|
+
dmAllowlist: string[];
|
|
53
|
+
guildAllowlist: string[];
|
|
54
|
+
channelAllowlist: string[];
|
|
55
|
+
roleAllowlist: string[];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Minimal interface for a Discord text-based channel send operation. */
|
|
59
|
+
export interface SendableChannel {
|
|
60
|
+
send(options: SendOptions): Promise<SentMessage>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type SendOptions = {
|
|
64
|
+
content?: string;
|
|
65
|
+
embeds?: EmbedData[];
|
|
66
|
+
files?: FileAttachment[];
|
|
67
|
+
reply?: { messageReference: string };
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type SentMessage = { id: string };
|
|
71
|
+
|
|
72
|
+
export type EmbedData = { description: string };
|
|
73
|
+
|
|
74
|
+
export type FileAttachment = { attachment: string; name?: string };
|
|
75
|
+
|
|
76
|
+
/** Minimal interface for a Discord message (for inbound handler). */
|
|
77
|
+
export interface DiscordMessage {
|
|
78
|
+
id: string;
|
|
79
|
+
content: string;
|
|
80
|
+
author: { id: string; username: string; bot: boolean };
|
|
81
|
+
system: boolean;
|
|
82
|
+
guild: { id: string } | null;
|
|
83
|
+
guildId: string | null;
|
|
84
|
+
channelId: string;
|
|
85
|
+
channel: {
|
|
86
|
+
isThread(): boolean;
|
|
87
|
+
parentId?: string | null;
|
|
88
|
+
};
|
|
89
|
+
createdTimestamp: number;
|
|
90
|
+
attachments: { first(): { url: string } | undefined };
|
|
91
|
+
client: { user: { id: string } | null };
|
|
92
|
+
startThread(options: { name: string; autoArchiveDuration: number }): Promise<{ id: string }>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type SlashCommandDef = {
|
|
96
|
+
name: string;
|
|
97
|
+
description: string;
|
|
98
|
+
options?: SlashCommandOption[];
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export type SlashCommandOption = {
|
|
102
|
+
name: string;
|
|
103
|
+
description: string;
|
|
104
|
+
required: boolean;
|
|
105
|
+
type: 'STRING';
|
|
106
|
+
};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill management handlers: create, delete, install.
|
|
3
|
+
* Pure functions operating on the filesystem — no MCP dependency.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
|
|
9
|
+
// Skill name validation: lowercase alphanumeric + hyphens, 1-64 chars
|
|
10
|
+
const VALID_NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
11
|
+
|
|
12
|
+
// Reserved names that conflict with system commands
|
|
13
|
+
const RESERVED_NAMES = new Set([
|
|
14
|
+
'help', 'sessions', 'session', 'new', 'clear', 'compact',
|
|
15
|
+
'rename', 'model', 'restart', 'access', 'configure',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const MAX_SKILL_SIZE = 100 * 1024; // 100KB
|
|
19
|
+
|
|
20
|
+
function validateName(name: string): string | null {
|
|
21
|
+
if (!VALID_NAME_RE.test(name)) {
|
|
22
|
+
return `Invalid skill name "${name}". Must be lowercase alphanumeric with hyphens, 1-64 chars.`;
|
|
23
|
+
}
|
|
24
|
+
if (RESERVED_NAMES.has(name)) {
|
|
25
|
+
return `Skill name "${name}" is reserved. Choose a different name.`;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getSkillDir(scope: 'workspace' | 'shared', name: string, workspaceDir: string, sharedSkillsDir: string): string {
|
|
31
|
+
if (scope === 'workspace') {
|
|
32
|
+
return path.join(workspaceDir, 'skills', name);
|
|
33
|
+
}
|
|
34
|
+
return path.join(sharedSkillsDir, name);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CreateSkillParams {
|
|
38
|
+
name: string;
|
|
39
|
+
description: string;
|
|
40
|
+
content: string;
|
|
41
|
+
scope: 'workspace' | 'shared';
|
|
42
|
+
workspaceDir: string;
|
|
43
|
+
sharedSkillsDir: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Create a new skill by writing a SKILL.md file with proper frontmatter.
|
|
48
|
+
*/
|
|
49
|
+
export async function createSkill(params: CreateSkillParams): Promise<string> {
|
|
50
|
+
const { name, description, content, scope, workspaceDir, sharedSkillsDir } = params;
|
|
51
|
+
|
|
52
|
+
const nameError = validateName(name);
|
|
53
|
+
if (nameError) throw new Error(nameError);
|
|
54
|
+
|
|
55
|
+
const skillDir = getSkillDir(scope, name, workspaceDir, sharedSkillsDir);
|
|
56
|
+
const skillFile = path.join(skillDir, 'SKILL.md');
|
|
57
|
+
|
|
58
|
+
if (fs.existsSync(skillFile)) {
|
|
59
|
+
throw new Error(`Skill "${name}" already exists at ${skillDir}. Use skill_delete first or choose a different name.`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Build SKILL.md with frontmatter
|
|
63
|
+
const skillMd = [
|
|
64
|
+
'---',
|
|
65
|
+
`name: ${name}`,
|
|
66
|
+
`description: "${description.replace(/"/g, '\\"')}"`,
|
|
67
|
+
'---',
|
|
68
|
+
'',
|
|
69
|
+
content,
|
|
70
|
+
].join('\n');
|
|
71
|
+
|
|
72
|
+
// Atomic write: create dir, write to .tmp, rename
|
|
73
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
74
|
+
const tmpFile = skillFile + '.tmp';
|
|
75
|
+
fs.writeFileSync(tmpFile, skillMd, 'utf-8');
|
|
76
|
+
fs.renameSync(tmpFile, skillFile);
|
|
77
|
+
|
|
78
|
+
return `Skill "${name}" created at ${skillFile} (scope: ${scope})`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface DeleteSkillParams {
|
|
82
|
+
name: string;
|
|
83
|
+
scope: 'workspace' | 'shared';
|
|
84
|
+
workspaceDir: string;
|
|
85
|
+
sharedSkillsDir: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Delete a skill by removing its directory.
|
|
90
|
+
*/
|
|
91
|
+
export async function deleteSkill(params: DeleteSkillParams): Promise<string> {
|
|
92
|
+
const { name, scope, workspaceDir, sharedSkillsDir } = params;
|
|
93
|
+
|
|
94
|
+
const skillDir = getSkillDir(scope, name, workspaceDir, sharedSkillsDir);
|
|
95
|
+
const skillFile = path.join(skillDir, 'SKILL.md');
|
|
96
|
+
|
|
97
|
+
if (!fs.existsSync(skillFile)) {
|
|
98
|
+
throw new Error(`Skill "${name}" not found in ${scope} (looked at ${skillDir})`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
fs.rmSync(skillDir, { recursive: true, force: true });
|
|
102
|
+
|
|
103
|
+
return `Skill "${name}" deleted from ${scope}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface InstallSkillParams {
|
|
107
|
+
url: string;
|
|
108
|
+
scope: 'workspace' | 'shared';
|
|
109
|
+
name?: string;
|
|
110
|
+
force?: boolean;
|
|
111
|
+
workspaceDir: string;
|
|
112
|
+
sharedSkillsDir: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Convert a github.com URL to raw.githubusercontent.com.
|
|
117
|
+
* e.g. https://github.com/user/repo/tree/main/skills/foo/SKILL.md
|
|
118
|
+
* -> https://raw.githubusercontent.com/user/repo/main/skills/foo/SKILL.md
|
|
119
|
+
*/
|
|
120
|
+
export function toRawGitHubUrl(url: string): string {
|
|
121
|
+
// Already raw
|
|
122
|
+
if (url.includes('raw.githubusercontent.com')) return url;
|
|
123
|
+
|
|
124
|
+
// GitHub blob/tree URL pattern
|
|
125
|
+
const match = url.match(
|
|
126
|
+
/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/(tree|blob)\/([^/]+)\/(.+)$/
|
|
127
|
+
);
|
|
128
|
+
if (match) {
|
|
129
|
+
const [, owner, repo, , branch, filePath] = match;
|
|
130
|
+
// If the URL points to a directory, append /SKILL.md
|
|
131
|
+
const finalPath = filePath.endsWith('SKILL.md') ? filePath : `${filePath}/SKILL.md`;
|
|
132
|
+
return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${finalPath}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return url;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Install a skill from a URL by fetching SKILL.md and writing it locally.
|
|
140
|
+
*/
|
|
141
|
+
export async function installSkill(params: InstallSkillParams): Promise<string> {
|
|
142
|
+
const { url, scope, force, workspaceDir, sharedSkillsDir } = params;
|
|
143
|
+
|
|
144
|
+
// Security: only allow HTTPS
|
|
145
|
+
if (!url.startsWith('https://')) {
|
|
146
|
+
throw new Error('Only HTTPS URLs are supported for security.');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const rawUrl = toRawGitHubUrl(url);
|
|
150
|
+
|
|
151
|
+
// Fetch content
|
|
152
|
+
const response = await fetch(rawUrl);
|
|
153
|
+
if (!response.ok) {
|
|
154
|
+
throw new Error(`Failed to fetch ${rawUrl}: ${response.status} ${response.statusText}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const content = await response.text();
|
|
158
|
+
|
|
159
|
+
// Size check
|
|
160
|
+
if (content.length > MAX_SKILL_SIZE) {
|
|
161
|
+
throw new Error(`SKILL.md exceeds ${MAX_SKILL_SIZE / 1024}KB limit (${(content.length / 1024).toFixed(1)}KB)`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Parse frontmatter to extract name
|
|
165
|
+
const { extractFrontmatter } = await import('../../../src/skills/parser');
|
|
166
|
+
const extracted = extractFrontmatter(content);
|
|
167
|
+
if (!extracted) {
|
|
168
|
+
throw new Error('Invalid SKILL.md: no valid YAML frontmatter found.');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const fm = extracted.frontmatter;
|
|
172
|
+
const skillName = params.name || (typeof fm.name === 'string' ? fm.name : '');
|
|
173
|
+
if (!skillName) {
|
|
174
|
+
throw new Error('Invalid SKILL.md: missing "name" in frontmatter. Use the name parameter to override.');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const nameError = validateName(skillName);
|
|
178
|
+
if (nameError) throw new Error(nameError);
|
|
179
|
+
|
|
180
|
+
const skillDir = getSkillDir(scope, skillName, workspaceDir, sharedSkillsDir);
|
|
181
|
+
const skillFile = path.join(skillDir, 'SKILL.md');
|
|
182
|
+
|
|
183
|
+
if (fs.existsSync(skillFile) && !force) {
|
|
184
|
+
throw new Error(`Skill "${skillName}" already exists. Use force: true to overwrite.`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Write skill file (atomic)
|
|
188
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
189
|
+
const tmpFile = skillFile + '.tmp';
|
|
190
|
+
fs.writeFileSync(tmpFile, content, 'utf-8');
|
|
191
|
+
fs.renameSync(tmpFile, skillFile);
|
|
192
|
+
|
|
193
|
+
const description = typeof fm.description === 'string' ? fm.description : '(no description)';
|
|
194
|
+
|
|
195
|
+
return `Skill "${skillName}" installed from ${rawUrl} (scope: ${scope})\nDescription: ${description}`;
|
|
196
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills tool module — implements ToolModule interface.
|
|
3
|
+
* Provides skill management tools: skill_create, skill_delete, skill_install.
|
|
4
|
+
* Tool-only module with "all-configured" visibility.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from 'fs';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
import type {
|
|
10
|
+
ToolModule,
|
|
11
|
+
McpToolDefinition,
|
|
12
|
+
McpToolResult,
|
|
13
|
+
ToolVisibility,
|
|
14
|
+
} from '../../types';
|
|
15
|
+
import {
|
|
16
|
+
createSkill,
|
|
17
|
+
deleteSkill,
|
|
18
|
+
installSkill,
|
|
19
|
+
type CreateSkillParams,
|
|
20
|
+
type DeleteSkillParams,
|
|
21
|
+
type InstallSkillParams,
|
|
22
|
+
} from './handlers';
|
|
23
|
+
|
|
24
|
+
export class SkillsModule implements ToolModule {
|
|
25
|
+
id = 'skills';
|
|
26
|
+
toolVisibility: ToolVisibility = 'all-configured';
|
|
27
|
+
|
|
28
|
+
isEnabled(): boolean {
|
|
29
|
+
// Skills tools are available when workspace dir is known
|
|
30
|
+
return Boolean(process.env.GATEWAY_WORKSPACE_DIR);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
getTools(): McpToolDefinition[] {
|
|
34
|
+
return [
|
|
35
|
+
{
|
|
36
|
+
name: 'skill_create',
|
|
37
|
+
description: 'Create a new skill from content. The skill will be available immediately via hot-reload.',
|
|
38
|
+
inputSchema: {
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {
|
|
41
|
+
name: {
|
|
42
|
+
type: 'string',
|
|
43
|
+
description: 'Skill slug name (lowercase, hyphens allowed, e.g. "my-helper")',
|
|
44
|
+
},
|
|
45
|
+
description: {
|
|
46
|
+
type: 'string',
|
|
47
|
+
description: 'One-line description of what the skill does',
|
|
48
|
+
},
|
|
49
|
+
content: {
|
|
50
|
+
type: 'string',
|
|
51
|
+
description: 'Full SKILL.md body content (instructions for the agent)',
|
|
52
|
+
},
|
|
53
|
+
scope: {
|
|
54
|
+
type: 'string',
|
|
55
|
+
enum: ['workspace', 'shared'],
|
|
56
|
+
description: 'Where to save: "workspace" (this agent only) or "shared" (all agents). Default: workspace',
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
required: ['name', 'description', 'content'],
|
|
60
|
+
additionalProperties: false,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'skill_delete',
|
|
65
|
+
description: 'Delete a skill by name.',
|
|
66
|
+
inputSchema: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
properties: {
|
|
69
|
+
name: {
|
|
70
|
+
type: 'string',
|
|
71
|
+
description: 'Skill name to delete',
|
|
72
|
+
},
|
|
73
|
+
scope: {
|
|
74
|
+
type: 'string',
|
|
75
|
+
enum: ['workspace', 'shared'],
|
|
76
|
+
description: 'Where to look: "workspace" or "shared". Default: workspace',
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
required: ['name'],
|
|
80
|
+
additionalProperties: false,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: 'skill_install',
|
|
85
|
+
description: 'Install a skill from a GitHub URL or raw URL pointing to a SKILL.md file.',
|
|
86
|
+
inputSchema: {
|
|
87
|
+
type: 'object',
|
|
88
|
+
properties: {
|
|
89
|
+
url: {
|
|
90
|
+
type: 'string',
|
|
91
|
+
description: 'URL to SKILL.md (GitHub URLs auto-converted to raw)',
|
|
92
|
+
},
|
|
93
|
+
scope: {
|
|
94
|
+
type: 'string',
|
|
95
|
+
enum: ['workspace', 'shared'],
|
|
96
|
+
description: 'Where to install: "workspace" or "shared". Default: workspace',
|
|
97
|
+
},
|
|
98
|
+
name: {
|
|
99
|
+
type: 'string',
|
|
100
|
+
description: 'Override skill name (default: parsed from frontmatter)',
|
|
101
|
+
},
|
|
102
|
+
force: {
|
|
103
|
+
type: 'boolean',
|
|
104
|
+
description: 'Overwrite if skill already exists. Default: false',
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
required: ['url'],
|
|
108
|
+
additionalProperties: false,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async handleTool(name: string, args: Record<string, unknown>): Promise<McpToolResult> {
|
|
115
|
+
const workspaceDir = process.env.GATEWAY_WORKSPACE_DIR!;
|
|
116
|
+
const sharedSkillsDir = process.env.GATEWAY_SHARED_SKILLS_DIR ||
|
|
117
|
+
path.join(process.env.HOME || '/tmp', '.claude-gateway', 'shared-skills');
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
switch (name) {
|
|
121
|
+
case 'skill_create': {
|
|
122
|
+
const params: CreateSkillParams = {
|
|
123
|
+
name: args.name as string,
|
|
124
|
+
description: args.description as string,
|
|
125
|
+
content: args.content as string,
|
|
126
|
+
scope: (args.scope as 'workspace' | 'shared') || 'workspace',
|
|
127
|
+
workspaceDir,
|
|
128
|
+
sharedSkillsDir,
|
|
129
|
+
};
|
|
130
|
+
const result = await createSkill(params);
|
|
131
|
+
return { content: [{ type: 'text', text: result }] };
|
|
132
|
+
}
|
|
133
|
+
case 'skill_delete': {
|
|
134
|
+
const params: DeleteSkillParams = {
|
|
135
|
+
name: args.name as string,
|
|
136
|
+
scope: (args.scope as 'workspace' | 'shared') || 'workspace',
|
|
137
|
+
workspaceDir,
|
|
138
|
+
sharedSkillsDir,
|
|
139
|
+
};
|
|
140
|
+
const result = await deleteSkill(params);
|
|
141
|
+
return { content: [{ type: 'text', text: result }] };
|
|
142
|
+
}
|
|
143
|
+
case 'skill_install': {
|
|
144
|
+
const params: InstallSkillParams = {
|
|
145
|
+
url: args.url as string,
|
|
146
|
+
scope: (args.scope as 'workspace' | 'shared') || 'workspace',
|
|
147
|
+
name: args.name as string | undefined,
|
|
148
|
+
force: args.force as boolean | undefined,
|
|
149
|
+
workspaceDir,
|
|
150
|
+
sharedSkillsDir,
|
|
151
|
+
};
|
|
152
|
+
const result = await installSkill(params);
|
|
153
|
+
return { content: [{ type: 'text', text: result }] };
|
|
154
|
+
}
|
|
155
|
+
default:
|
|
156
|
+
return { content: [{ type: 'text', text: `unknown tool: ${name}` }], isError: true };
|
|
157
|
+
}
|
|
158
|
+
} catch (err) {
|
|
159
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
160
|
+
return { content: [{ type: 'text', text: `${name} failed: ${msg}` }], isError: true };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|