moeba-claude-channel 0.0.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.
Files changed (2) hide show
  1. package/dist/moeba-channel.js +342 -0
  2. package/package.json +36 -0
@@ -0,0 +1,342 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Moeba Channel for Claude Code
4
+ *
5
+ * Two-way bridge: Moeba app users ↔ Claude Code session.
6
+ * Authenticates via OAuth on startup, connects via SSE, replies via HTTP.
7
+ *
8
+ * Usage:
9
+ * npx @moeba/claude-channel
10
+ *
11
+ * On first run, opens browser for Google/Apple sign-in before connecting.
12
+ * Credentials are cached per project at ~/.moeba/channel-<project>.json
13
+ */
14
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
15
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
16
+ import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
17
+ import { createServer } from 'http';
18
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
19
+ import { homedir } from 'os';
20
+ import { join } from 'path';
21
+ import { execSync, exec } from 'child_process';
22
+ // ---------------------------------------------------------------------------
23
+ // Config
24
+ // ---------------------------------------------------------------------------
25
+ const MOEBA_API_URL = (process.env.MOEBA_API_URL ||
26
+ 'https://moeba-api-999642860678.africa-south1.run.app').replace(/\/$/, '');
27
+ const MOEBA_AUTH_URL = process.env.MOEBA_AUTH_URL || 'https://admin.moeba.co.za';
28
+ // Detect project name from git repo or directory name
29
+ function detectProjectName() {
30
+ try {
31
+ const repoName = execSync('git rev-parse --show-toplevel', {
32
+ encoding: 'utf-8',
33
+ stdio: ['pipe', 'pipe', 'pipe'],
34
+ }).trim();
35
+ return repoName.split('/').pop() || 'default';
36
+ }
37
+ catch {
38
+ return process.cwd().split('/').pop() || 'default';
39
+ }
40
+ }
41
+ const PROJECT_NAME = process.env.MOEBA_PROJECT || detectProjectName();
42
+ const PROJECT_SLUG = PROJECT_NAME.toLowerCase().replace(/[^a-z0-9]+/g, '-');
43
+ const CREDENTIALS_PATH = join(homedir(), '.moeba', `channel-${PROJECT_SLUG}.json`);
44
+ // ---------------------------------------------------------------------------
45
+ // Credential storage
46
+ // ---------------------------------------------------------------------------
47
+ function loadCredentials() {
48
+ try {
49
+ if (!existsSync(CREDENTIALS_PATH))
50
+ return null;
51
+ return JSON.parse(readFileSync(CREDENTIALS_PATH, 'utf-8'));
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ function saveCredentials(c) {
58
+ const dir = join(homedir(), '.moeba');
59
+ if (!existsSync(dir))
60
+ mkdirSync(dir, { recursive: true });
61
+ writeFileSync(CREDENTIALS_PATH, JSON.stringify(c, null, 2));
62
+ }
63
+ // ---------------------------------------------------------------------------
64
+ // OAuth flow — opens browser, receives Firebase token via localhost callback
65
+ // ---------------------------------------------------------------------------
66
+ function authenticate() {
67
+ return new Promise((resolve, reject) => {
68
+ const callbackPort = 9876;
69
+ const httpServer = createServer(async (req, res) => {
70
+ const url = new URL(req.url, `http://localhost:${callbackPort}`);
71
+ if (url.pathname === '/callback') {
72
+ const firebaseIdToken = url.searchParams.get('token');
73
+ if (!firebaseIdToken) {
74
+ res.writeHead(400);
75
+ res.end('Missing token parameter');
76
+ return;
77
+ }
78
+ try {
79
+ const response = await fetch(`${MOEBA_API_URL}/channel/auth`, {
80
+ method: 'POST',
81
+ headers: { 'Content-Type': 'application/json' },
82
+ body: JSON.stringify({ firebaseIdToken, projectName: PROJECT_NAME }),
83
+ });
84
+ if (!response.ok) {
85
+ const err = await response.text();
86
+ res.writeHead(500);
87
+ res.end(`Auth failed: ${err}`);
88
+ reject(new Error(`Channel auth failed: ${err}`));
89
+ return;
90
+ }
91
+ const data = (await response.json());
92
+ const newCreds = {
93
+ token: data.token,
94
+ email: data.email,
95
+ businessId: data.businessId,
96
+ agentId: data.agentId,
97
+ connectionId: data.connectionId,
98
+ agentApiKey: data.agentApiKey,
99
+ projectName: PROJECT_NAME,
100
+ };
101
+ saveCredentials(newCreds);
102
+ res.writeHead(200, { 'Content-Type': 'text/html' });
103
+ res.end(`
104
+ <html><body style="font-family:system-ui;text-align:center;padding:60px">
105
+ <h2>Connected to Moeba!</h2>
106
+ <p>You can close this tab and return to Claude Code.</p>
107
+ </body></html>
108
+ `);
109
+ httpServer.close();
110
+ resolve(newCreds);
111
+ }
112
+ catch (err) {
113
+ res.writeHead(500);
114
+ res.end(`Error: ${err.message}`);
115
+ reject(err);
116
+ }
117
+ return;
118
+ }
119
+ res.writeHead(404);
120
+ res.end('Not found');
121
+ });
122
+ httpServer.listen(callbackPort, '127.0.0.1', () => {
123
+ const authUrl = `${MOEBA_AUTH_URL}/channel-login?redirect=http://localhost:${callbackPort}/callback`;
124
+ console.error(`\nšŸ”‘ Sign in to Moeba: ${authUrl}\n`);
125
+ const cmd = process.platform === 'darwin'
126
+ ? 'open'
127
+ : process.platform === 'win32'
128
+ ? 'start'
129
+ : 'xdg-open';
130
+ exec(`${cmd} "${authUrl}"`);
131
+ });
132
+ setTimeout(() => {
133
+ httpServer.close();
134
+ reject(new Error('Authentication timed out after 2 minutes'));
135
+ }, 120_000);
136
+ });
137
+ }
138
+ // ---------------------------------------------------------------------------
139
+ // SSE client — connects to Moeba and receives messages
140
+ // ---------------------------------------------------------------------------
141
+ function connectSSE(c, mcp) {
142
+ const url = `${MOEBA_API_URL}/api/channel/events?connectionId=${c.connectionId}`;
143
+ let reconnectDelay = 1000;
144
+ async function connect() {
145
+ try {
146
+ console.error('Connecting to Moeba SSE...');
147
+ const response = await fetch(url, {
148
+ headers: { Authorization: `Bearer ${c.token}` },
149
+ });
150
+ if (!response.ok) {
151
+ console.error(`SSE connection failed: ${response.status}`);
152
+ scheduleReconnect();
153
+ return;
154
+ }
155
+ console.error('Connected to Moeba — listening for messages');
156
+ reconnectDelay = 1000;
157
+ const reader = response.body.getReader();
158
+ const decoder = new TextDecoder();
159
+ let buffer = '';
160
+ while (true) {
161
+ const { done, value } = await reader.read();
162
+ if (done)
163
+ break;
164
+ buffer += decoder.decode(value, { stream: true });
165
+ const lines = buffer.split('\n');
166
+ buffer = lines.pop() || '';
167
+ let eventType = '';
168
+ let eventData = '';
169
+ for (const line of lines) {
170
+ if (line.startsWith('event: ')) {
171
+ eventType = line.slice(7);
172
+ }
173
+ else if (line.startsWith('data: ')) {
174
+ eventData = line.slice(6);
175
+ }
176
+ else if (line === '' && eventType && eventData) {
177
+ if (eventType === 'message') {
178
+ try {
179
+ const event = JSON.parse(eventData);
180
+ const content = event.message?.text || '';
181
+ const meta = {
182
+ sender_email: event.senderEmail || '',
183
+ sender_name: event.senderName || '',
184
+ connection_id: event.connectionId || c.connectionId,
185
+ conversation_id: event.conversationId || '',
186
+ };
187
+ if (event.type === 'action') {
188
+ meta.type = 'action';
189
+ }
190
+ mcp
191
+ .notification({
192
+ method: 'notifications/claude/channel',
193
+ params: { content, meta },
194
+ })
195
+ .catch((err) => console.error('Notification failed:', err.message));
196
+ }
197
+ catch { }
198
+ }
199
+ else if (eventType === 'connected') {
200
+ console.error('SSE stream established');
201
+ }
202
+ eventType = '';
203
+ eventData = '';
204
+ }
205
+ }
206
+ }
207
+ console.error('SSE stream ended');
208
+ scheduleReconnect();
209
+ }
210
+ catch (err) {
211
+ console.error(`SSE error: ${err.message}`);
212
+ scheduleReconnect();
213
+ }
214
+ }
215
+ function scheduleReconnect() {
216
+ console.error(`Reconnecting in ${reconnectDelay / 1000}s...`);
217
+ setTimeout(connect, reconnectDelay);
218
+ reconnectDelay = Math.min(reconnectDelay * 2, 30_000);
219
+ }
220
+ connect();
221
+ }
222
+ // ---------------------------------------------------------------------------
223
+ // Main — authenticate first, then connect MCP
224
+ // ---------------------------------------------------------------------------
225
+ async function main() {
226
+ // 1. Authenticate (cached or browser OAuth) — before MCP connects
227
+ let creds = loadCredentials();
228
+ if (!creds) {
229
+ console.error('No Moeba credentials found — opening browser to sign in...');
230
+ creds = await authenticate();
231
+ }
232
+ console.error(`Authenticated as ${creds.email} (project: ${PROJECT_NAME})`);
233
+ // 2. Create MCP channel server
234
+ const mcp = new Server({ name: 'moeba', version: '0.0.1' }, {
235
+ capabilities: {
236
+ experimental: { 'claude/channel': {} },
237
+ tools: {},
238
+ },
239
+ instructions: `Messages from Moeba users arrive as <channel source="moeba" ...>.
240
+ Each message has attributes: sender_email, sender_name, connection_id, conversation_id.
241
+
242
+ When you receive a message:
243
+ 1. Read and understand the user's request
244
+ 2. Take whatever actions are needed (read files, run commands, etc.)
245
+ 3. Send progress updates via moeba_progress while working on longer tasks
246
+ 4. Reply using moeba_reply with the connection_id from the message tag
247
+
248
+ Keep replies concise and helpful. The user is chatting from a mobile app.`,
249
+ });
250
+ // 3. Register tools
251
+ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
252
+ tools: [
253
+ {
254
+ name: 'moeba_reply',
255
+ description: 'Send a reply message back to the Moeba user',
256
+ inputSchema: {
257
+ type: 'object',
258
+ properties: {
259
+ connection_id: {
260
+ type: 'string',
261
+ description: 'The connection_id from the inbound <channel> tag',
262
+ },
263
+ text: {
264
+ type: 'string',
265
+ description: 'The message to send back',
266
+ },
267
+ },
268
+ required: ['connection_id', 'text'],
269
+ },
270
+ },
271
+ {
272
+ name: 'moeba_progress',
273
+ description: 'Show a typing/progress indicator while working on a task',
274
+ inputSchema: {
275
+ type: 'object',
276
+ properties: {
277
+ connection_id: {
278
+ type: 'string',
279
+ description: 'The connection_id from the inbound <channel> tag',
280
+ },
281
+ text: {
282
+ type: 'string',
283
+ description: 'Progress text (e.g. "Reading files...")',
284
+ },
285
+ },
286
+ required: ['connection_id', 'text'],
287
+ },
288
+ },
289
+ ],
290
+ }));
291
+ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
292
+ const { name, arguments: args } = req.params;
293
+ if (name === 'moeba_reply' || name === 'moeba_progress') {
294
+ const { connection_id, text } = args;
295
+ const body = {
296
+ connectionId: connection_id,
297
+ message: { text },
298
+ };
299
+ if (name === 'moeba_progress') {
300
+ body.type = 'progress';
301
+ }
302
+ const response = await fetch(`${MOEBA_API_URL}/api/agent/send`, {
303
+ method: 'POST',
304
+ headers: {
305
+ 'Content-Type': 'application/json',
306
+ 'X-Moeba-Agent-Key': creds.agentApiKey,
307
+ },
308
+ body: JSON.stringify(body),
309
+ });
310
+ if (!response.ok) {
311
+ const errText = await response.text();
312
+ return {
313
+ content: [
314
+ {
315
+ type: 'text',
316
+ text: `Failed: ${response.status} ${errText}`,
317
+ },
318
+ ],
319
+ isError: true,
320
+ };
321
+ }
322
+ return {
323
+ content: [
324
+ {
325
+ type: 'text',
326
+ text: name === 'moeba_progress' ? 'Progress updated' : 'Sent',
327
+ },
328
+ ],
329
+ };
330
+ }
331
+ throw new Error(`Unknown tool: ${name}`);
332
+ });
333
+ // 4. Connect MCP to Claude Code
334
+ await mcp.connect(new StdioServerTransport());
335
+ // 5. Start SSE listener
336
+ connectSSE(creds, mcp);
337
+ console.error('Moeba channel ready — waiting for messages');
338
+ }
339
+ main().catch((err) => {
340
+ console.error('Fatal:', err.message);
341
+ process.exit(1);
342
+ });
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "moeba-claude-channel",
3
+ "version": "0.0.1",
4
+ "description": "Claude Code channel for Moeba — chat with Claude Code from the Moeba app",
5
+ "type": "module",
6
+ "main": "dist/moeba-channel.js",
7
+ "bin": {
8
+ "moeba-channel": "dist/moeba-channel.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "prepublishOnly": "npm run build"
16
+ },
17
+ "keywords": [
18
+ "claude-code",
19
+ "mcp",
20
+ "moeba",
21
+ "channel",
22
+ "ai-agent"
23
+ ],
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/moeba-co/moeba"
28
+ },
29
+ "dependencies": {
30
+ "@modelcontextprotocol/sdk": "^1.12.1"
31
+ },
32
+ "devDependencies": {
33
+ "typescript": "^5.9.0",
34
+ "@types/node": "^20.0.0"
35
+ }
36
+ }