create-walle 0.3.0 → 0.3.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-walle",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Set up Wall-E — your personal digital twin",
5
5
  "bin": {
6
6
  "create-walle": "bin/create-walle.js"
@@ -243,13 +243,16 @@
243
243
  document.getElementById('api-key').value = '';
244
244
  document.getElementById('api-key').placeholder = '••••••••••••••• (from ' + (d.source || 'environment') + ')';
245
245
  document.getElementById('api-dot').className = 'status-dot ok';
246
- okMsg.textContent = 'API key detected from ' + (d.source || 'environment') + '!';
246
+ okMsg.textContent = 'Detected: ' + (d.source || 'environment') + '!';
247
247
  okMsg.style.display = 'inline';
248
248
  const ownerVal = document.getElementById('owner-name').value.trim();
249
+ const saveBody = { owner_name: ownerVal };
250
+ if (d.gateway) saveBody.gateway = d.gateway;
251
+ else saveBody.api_key = d.key;
249
252
  await fetch('/api/setup/save', {
250
253
  method: 'POST',
251
254
  headers: { 'Content-Type': 'application/json' },
252
- body: JSON.stringify({ owner_name: ownerVal, api_key: d.key }),
255
+ body: JSON.stringify(saveBody),
253
256
  });
254
257
  } else {
255
258
  errMsg.textContent = d.hint || 'No API key found. Enter one manually.';
@@ -107,6 +107,26 @@ const server = http.createServer((req, res) => {
107
107
  }
108
108
  }
109
109
 
110
+ // Slack OAuth callback (browser redirect — no auth token, must be before auth check)
111
+ if (url.pathname === '/api/slack/callback' && req.method === 'GET') {
112
+ try {
113
+ const slackMcp = require('../wall-e/tools/slack-mcp');
114
+ const code = url.searchParams.get('code');
115
+ const state = url.searchParams.get('state');
116
+ slackMcp.handleOAuthCallback(code, state).then(result => {
117
+ res.writeHead(200, { 'Content-Type': 'text/html' });
118
+ res.end(result.html);
119
+ }).catch(err => {
120
+ res.writeHead(500, { 'Content-Type': 'text/html' });
121
+ res.end('<html><body>OAuth error: ' + err.message + '</body></html>');
122
+ });
123
+ } catch (e) {
124
+ res.writeHead(500, { 'Content-Type': 'text/html' });
125
+ res.end('<html><body>Slack module not available: ' + e.message + '</body></html>');
126
+ }
127
+ return;
128
+ }
129
+
110
130
  // API routes
111
131
  if (url.pathname.startsWith('/api/')) {
112
132
  if (!isLocalhost(req)) {
@@ -208,15 +228,26 @@ function handleApi(req, res, url) {
208
228
  if (url.pathname === '/api/setup/detect-key' && req.method === 'GET') {
209
229
  let key = '';
210
230
  let source = '';
231
+ let gateway = null; // For corporate/Portkey gateway setups
232
+
233
+ // 1. Check for corporate gateway setup (Portkey, cybertron, etc.)
234
+ if (process.env.ANTHROPIC_BASE_URL && process.env.ANTHROPIC_CUSTOM_HEADERS_B64) {
235
+ gateway = {
236
+ base_url: process.env.ANTHROPIC_BASE_URL,
237
+ auth_token: process.env.ANTHROPIC_AUTH_TOKEN || 'sk-ant-api03-unused',
238
+ custom_headers_b64: process.env.ANTHROPIC_CUSTOM_HEADERS_B64,
239
+ };
240
+ source = 'Claude Code gateway (' + process.env.ANTHROPIC_BASE_URL.replace(/https?:\/\//, '').split('/')[0] + ')';
241
+ }
211
242
 
212
- // 1. Check process.env
213
- if (process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.startsWith('sk-ant-')) {
243
+ // 2. Check for direct API key in process.env
244
+ if (!gateway && process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.startsWith('sk-ant-')) {
214
245
  key = process.env.ANTHROPIC_API_KEY;
215
246
  source = 'environment variable';
216
247
  }
217
248
 
218
- // 2. Try shell profile
219
- if (!key) {
249
+ // 3. Try shell profile for direct API key
250
+ if (!gateway && !key) {
220
251
  try {
221
252
  const { execFileSync } = require('child_process');
222
253
  const shell = process.env.SHELL || '/bin/zsh';
@@ -225,8 +256,8 @@ function handleApi(req, res, url) {
225
256
  } catch {}
226
257
  }
227
258
 
228
- // 3. Try Claude Code OAuth token from macOS Keychain
229
- if (!key && process.platform === 'darwin') {
259
+ // 4. Try Claude Code OAuth token from macOS Keychain
260
+ if (!gateway && !key && process.platform === 'darwin') {
230
261
  try {
231
262
  const { execFileSync } = require('child_process');
232
263
  const credJson = execFileSync('security', ['find-generic-password', '-s', 'Claude Code-credentials', '-w'], { encoding: 'utf8', timeout: 3000 }).trim();
@@ -239,7 +270,6 @@ function handleApi(req, res, url) {
239
270
  key = oauth.accessToken;
240
271
  source = 'Claude Code (OAuth)';
241
272
  } else {
242
- // Token expired — tell user to refresh
243
273
  res.writeHead(200, { 'Content-Type': 'application/json' });
244
274
  res.end(JSON.stringify({
245
275
  found: false,
@@ -254,10 +284,10 @@ function handleApi(req, res, url) {
254
284
  }
255
285
 
256
286
  res.writeHead(200, { 'Content-Type': 'application/json' });
257
- if (key) {
287
+ if (gateway) {
288
+ res.end(JSON.stringify({ found: true, gateway, source }));
289
+ } else if (key) {
258
290
  res.end(JSON.stringify({ found: true, key, source }));
259
- } else if (process.env.ANTHROPIC_BASE_URL) {
260
- res.end(JSON.stringify({ found: false, hint: 'Your environment uses an API gateway (' + process.env.ANTHROPIC_BASE_URL + '). You may not need a separate API key — try going to the dashboard.' }));
261
291
  } else {
262
292
  res.end(JSON.stringify({ found: false, hint: 'No API key found. Checked: environment variables, shell profile, Claude Code keychain. You can get a key at console.anthropic.com' }));
263
293
  }
@@ -281,6 +311,8 @@ function handleApi(req, res, url) {
281
311
  const apiKey = typeof data.api_key === 'string'
282
312
  ? data.api_key.replace(/[\r\n\s]/g, '').slice(0, 200)
283
313
  : '';
314
+ // Gateway config (corporate Portkey/cybertron setups)
315
+ const gw = data.gateway;
284
316
  if (apiKey && !/^sk-ant-/.test(apiKey)) {
285
317
  res.writeHead(400, { 'Content-Type': 'application/json' });
286
318
  res.end(JSON.stringify({ error: 'API key must start with sk-ant-' }));
@@ -288,22 +320,40 @@ function handleApi(req, res, url) {
288
320
  }
289
321
  const envPath = path.resolve(__dirname, '..', '.env');
290
322
  const lines = [];
291
- // Read existing .env or start fresh
323
+ // Read existing .env, strip lines we're about to replace
324
+ const stripPatterns = [/^#?\s*WALLE_OWNER_NAME=/, /^#?\s*ANTHROPIC_API_KEY=/, /^#?\s*ANTHROPIC_BASE_URL=/, /^#?\s*ANTHROPIC_AUTH_TOKEN=/, /^#?\s*ANTHROPIC_CUSTOM_HEADERS_B64=/];
292
325
  try {
293
326
  const existing = fs.readFileSync(envPath, 'utf8');
294
327
  for (const line of existing.split('\n')) {
295
- if (line.match(/^#?\s*ANTHROPIC_API_KEY=/) && apiKey) continue;
296
- if (line.match(/^#?\s*WALLE_OWNER_NAME=/) && ownerName) continue;
297
- lines.push(line);
328
+ const shouldStrip = (apiKey || gw) && stripPatterns.some(p => p.test(line));
329
+ if (!shouldStrip || (!apiKey && !gw)) lines.push(line);
330
+ else if (!ownerName || !line.match(/WALLE_OWNER_NAME/)) {
331
+ // Only strip if we have a replacement
332
+ if (stripPatterns.some(p => p.test(line))) continue;
333
+ lines.push(line);
334
+ }
335
+ }
336
+ // Also strip owner name line if we have a new one
337
+ if (ownerName) {
338
+ const idx = lines.findIndex(l => /^#?\s*WALLE_OWNER_NAME=/.test(l));
339
+ if (idx >= 0) lines.splice(idx, 1);
298
340
  }
299
341
  } catch { lines.push('# Wall-E configuration'); lines.push(''); }
300
- // Add values after the header comment
342
+ // Add values
301
343
  if (ownerName) {
302
344
  const insertIdx = lines.findIndex(l => !l.startsWith('#') && l.trim() !== '') || lines.length;
303
345
  lines.splice(insertIdx, 0, `WALLE_OWNER_NAME=${ownerName}`);
304
346
  process.env.WALLE_OWNER_NAME = ownerName;
305
347
  }
306
- if (apiKey) {
348
+ if (gw) {
349
+ // Gateway setup: save all three env vars
350
+ lines.push(`ANTHROPIC_BASE_URL=${gw.base_url}`);
351
+ lines.push(`ANTHROPIC_AUTH_TOKEN=${gw.auth_token}`);
352
+ lines.push(`ANTHROPIC_CUSTOM_HEADERS_B64=${gw.custom_headers_b64}`);
353
+ process.env.ANTHROPIC_BASE_URL = gw.base_url;
354
+ process.env.ANTHROPIC_AUTH_TOKEN = gw.auth_token;
355
+ process.env.ANTHROPIC_CUSTOM_HEADERS_B64 = gw.custom_headers_b64;
356
+ } else if (apiKey) {
307
357
  lines.push(`ANTHROPIC_API_KEY=${apiKey}`);
308
358
  process.env.ANTHROPIC_API_KEY = apiKey;
309
359
  }
@@ -213,13 +213,8 @@ function handleWalleApi(req, res, url) {
213
213
  jsonResponse(res, { ok: true, already: true });
214
214
  return true;
215
215
  }
216
- // Start OAuth — browser opens server-side, resolve on callback
217
- slackMcp.authenticate().then(token => {
218
- console.log('[wall-e] Slack OAuth completed');
219
- }).catch(err => {
220
- console.error('[wall-e] Slack OAuth failed:', err.message);
221
- });
222
- // Tell client the flow started (browser will open)
216
+ // Start OAuth — opens browser, callback handled by CTM server route
217
+ slackMcp.authenticate();
223
218
  jsonResponse(res, { ok: true, pending: true });
224
219
  } catch (e) {
225
220
  jsonResponse(res, { error: e.message }, 500);
@@ -6,9 +6,15 @@ const path = require('path');
6
6
 
7
7
  const SLACK_MCP_URL = 'https://mcp.slack.com/mcp';
8
8
  const CLIENT_ID = '1601185624273.8899143856786';
9
- const CALLBACK_PORT = 3118; // Must match the registered redirect_uri for this client ID
10
9
  const TOKEN_FILE = path.join(process.env.HOME, '.claude', 'wall-e-slack-token.json');
11
10
 
11
+ // OAuth callback is handled by CTM server at /api/slack/callback
12
+ // so it works on whatever port CTM is running on (no separate server needed).
13
+ function getCallbackUrl() {
14
+ const port = process.env.CTM_PORT || '3456';
15
+ return `http://localhost:${port}/api/slack/callback`;
16
+ }
17
+
12
18
  // ── Token persistence ──
13
19
 
14
20
  function loadToken() {
@@ -37,120 +43,109 @@ function generatePKCE() {
37
43
  return { verifier, challenge };
38
44
  }
39
45
 
46
+ // Pending OAuth state (set during authenticate, consumed by handleOAuthCallback)
47
+ let _pendingOAuth = null;
48
+
40
49
  /**
41
- * Start OAuth flow: opens browser, waits for callback.
42
- * Returns the access token.
50
+ * Start OAuth flow: opens browser, callback handled by CTM server.
51
+ * Returns immediately — the token is saved when CTM receives the callback.
43
52
  */
44
- async function authenticate() {
53
+ function authenticate() {
45
54
  // Check if we already have a valid token
46
55
  const existing = loadToken();
47
56
  if (existing && existing.access_token) {
48
- // TODO: check expiry if available
49
57
  return existing.access_token;
50
58
  }
51
59
 
52
60
  const { verifier, challenge } = generatePKCE();
53
61
  const state = crypto.randomBytes(16).toString('hex');
62
+ const redirectUri = getCallbackUrl();
54
63
 
55
- return new Promise((resolve, reject) => {
56
- const timeout = setTimeout(() => {
57
- server.close();
58
- reject(new Error('OAuth flow timed out (120s). Please try again.'));
59
- }, 120000);
60
-
61
- const server = http.createServer(async (req, res) => {
62
- const url = new URL(req.url, `http://localhost:${CALLBACK_PORT}`);
63
- if (url.pathname !== '/callback') {
64
- res.writeHead(404);
65
- res.end('Not found');
66
- return;
67
- }
68
-
69
- const code = url.searchParams.get('code');
70
- const returnedState = url.searchParams.get('state');
71
-
72
- if (returnedState !== state) {
73
- res.writeHead(400);
74
- res.end('State mismatch. Please try again.');
75
- clearTimeout(timeout);
76
- server.close();
77
- reject(new Error('OAuth state mismatch'));
78
- return;
79
- }
80
-
81
- if (!code) {
82
- res.writeHead(400);
83
- res.end('No authorization code received.');
84
- clearTimeout(timeout);
85
- server.close();
86
- reject(new Error('No authorization code'));
87
- return;
88
- }
89
-
90
- try {
91
- // Exchange code for token (MCP uses v2.user.access endpoint)
92
- const tokenResp = await fetch('https://slack.com/api/oauth.v2.user.access', {
93
- method: 'POST',
94
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
95
- body: new URLSearchParams({
96
- client_id: CLIENT_ID,
97
- code,
98
- code_verifier: verifier,
99
- redirect_uri: `http://localhost:${CALLBACK_PORT}/callback`,
100
- }),
101
- });
102
- const tokenData = await tokenResp.json();
103
-
104
- if (!tokenData.ok) {
105
- throw new Error(`Slack OAuth error: ${tokenData.error}`);
106
- }
107
-
108
- // Save token (v2.user.access returns tokens at top level, not nested under authed_user)
109
- const tokenInfo = {
110
- access_token: tokenData.access_token || tokenData.authed_user?.access_token,
111
- refresh_token: tokenData.refresh_token,
112
- team_id: tokenData.team?.id || tokenData.team_id,
113
- team_name: tokenData.team?.name,
114
- user_id: tokenData.user_id || tokenData.authed_user?.id,
115
- scope: tokenData.scope || tokenData.authed_user?.scope,
116
- obtained_at: new Date().toISOString(),
117
- };
118
- saveToken(tokenInfo);
119
-
120
- res.writeHead(200, { 'Content-Type': 'text/html' });
121
- res.end('<html><body style="font-family:system-ui;text-align:center;padding:40px"><h2>WALL-E connected to Slack!</h2><p>You can close this tab.</p></body></html>');
122
-
123
- clearTimeout(timeout);
124
- server.close();
125
- resolve(tokenInfo.access_token);
126
- } catch (err) {
127
- res.writeHead(500);
128
- res.end('Token exchange failed: ' + err.message);
129
- clearTimeout(timeout);
130
- server.close();
131
- reject(err);
132
- }
133
- });
64
+ _pendingOAuth = { verifier, state, redirectUri, createdAt: Date.now() };
134
65
 
135
- server.listen(CALLBACK_PORT, () => {
136
- // Scopes from Slack MCP's .well-known/oauth-authorization-server
137
- const scopes = 'search:read.public,search:read.private,search:read.mpim,search:read.im,search:read.files,search:read.users,chat:write,channels:history,groups:history,mpim:history,im:history,canvases:read,users:read,users:read.email';
138
- const authUrl = `https://slack.com/oauth/v2_user/authorize?client_id=${CLIENT_ID}&scope=${encodeURIComponent(scopes)}&redirect_uri=${encodeURIComponent(`http://localhost:${CALLBACK_PORT}/callback`)}&state=${state}&code_challenge=${challenge}&code_challenge_method=S256`;
139
- console.log('[slack-mcp] Opening browser for Slack OAuth...');
140
- console.log('[slack-mcp] Auth URL:', authUrl);
141
-
142
- // Open browser
143
- const { execFile } = require('child_process');
144
- execFile('open', [authUrl], (err) => {
145
- if (err) console.error('[slack-mcp] Failed to open browser:', err.message);
146
- });
147
- });
66
+ const scopes = 'search:read.public,search:read.private,search:read.mpim,search:read.im,search:read.files,search:read.users,chat:write,channels:history,groups:history,mpim:history,im:history,canvases:read,users:read,users:read.email';
67
+ const authUrl = `https://slack.com/oauth/v2_user/authorize?client_id=${CLIENT_ID}&scope=${encodeURIComponent(scopes)}&redirect_uri=${encodeURIComponent(redirectUri)}&state=${state}&code_challenge=${challenge}&code_challenge_method=S256`;
148
68
 
149
- server.on('error', (err) => {
150
- clearTimeout(timeout);
151
- reject(new Error(`OAuth server failed: ${err.message}`));
152
- });
69
+ console.log('[slack-mcp] Opening browser for Slack OAuth...');
70
+ console.log('[slack-mcp] Callback URL:', redirectUri);
71
+
72
+ const { execFile } = require('child_process');
73
+ execFile('open', [authUrl], (err) => {
74
+ if (err) console.error('[slack-mcp] Failed to open browser:', err.message);
153
75
  });
76
+
77
+ return null; // Token not yet available — will be set by callback
78
+ }
79
+
80
+ /**
81
+ * Handle the OAuth callback from Slack (called by CTM server route).
82
+ * Returns { ok, html } or { error, html }.
83
+ */
84
+ async function handleOAuthCallback(code, returnedState) {
85
+ if (!_pendingOAuth) {
86
+ return { error: 'No pending OAuth flow. Click "Connect" again.', html: errorPage('No pending OAuth flow. Go back and click Connect again.') };
87
+ }
88
+
89
+ // Expire after 10 minutes
90
+ if (Date.now() - _pendingOAuth.createdAt > 600000) {
91
+ _pendingOAuth = null;
92
+ return { error: 'OAuth flow expired.', html: errorPage('OAuth flow expired. Go back and click Connect again.') };
93
+ }
94
+
95
+ if (returnedState !== _pendingOAuth.state) {
96
+ _pendingOAuth = null;
97
+ return { error: 'State mismatch.', html: errorPage('OAuth state mismatch. Please try again.') };
98
+ }
99
+
100
+ if (!code) {
101
+ _pendingOAuth = null;
102
+ return { error: 'No code.', html: errorPage('No authorization code received from Slack.') };
103
+ }
104
+
105
+ try {
106
+ const tokenResp = await fetch('https://slack.com/api/oauth.v2.user.access', {
107
+ method: 'POST',
108
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
109
+ body: new URLSearchParams({
110
+ client_id: CLIENT_ID,
111
+ code,
112
+ code_verifier: _pendingOAuth.verifier,
113
+ redirect_uri: _pendingOAuth.redirectUri,
114
+ }),
115
+ });
116
+ const tokenData = await tokenResp.json();
117
+
118
+ _pendingOAuth = null;
119
+
120
+ if (!tokenData.ok) {
121
+ return { error: tokenData.error, html: errorPage('Slack OAuth error: ' + tokenData.error) };
122
+ }
123
+
124
+ const tokenInfo = {
125
+ access_token: tokenData.access_token || tokenData.authed_user?.access_token,
126
+ refresh_token: tokenData.refresh_token,
127
+ team_id: tokenData.team?.id || tokenData.team_id,
128
+ team_name: tokenData.team?.name,
129
+ user_id: tokenData.user_id || tokenData.authed_user?.id,
130
+ scope: tokenData.scope || tokenData.authed_user?.scope,
131
+ obtained_at: new Date().toISOString(),
132
+ };
133
+ saveToken(tokenInfo);
134
+
135
+ console.log('[slack-mcp] OAuth completed — team:', tokenInfo.team_name);
136
+ return { ok: true, html: successPage() };
137
+ } catch (err) {
138
+ _pendingOAuth = null;
139
+ return { error: err.message, html: errorPage('Token exchange failed: ' + err.message) };
140
+ }
141
+ }
142
+
143
+ function successPage() {
144
+ return '<html><body style="font-family:system-ui;text-align:center;padding:60px;background:#0d1117;color:#e6edf3"><h2 style="color:#3fb950">Wall-E connected to Slack!</h2><p style="color:#8b949e">You can close this tab and return to the setup page.</p></body></html>';
145
+ }
146
+
147
+ function errorPage(msg) {
148
+ return '<html><body style="font-family:system-ui;text-align:center;padding:60px;background:#0d1117;color:#e6edf3"><h2 style="color:#f85149">Slack Connection Failed</h2><p style="color:#8b949e">' + msg + '</p></body></html>';
154
149
  }
155
150
 
156
151
  // ── MCP HTTP client ──
@@ -287,4 +282,4 @@ if (require.main === module) {
287
282
  }
288
283
  }
289
284
 
290
- module.exports = { authenticate, callSlackMcp, listSlackTools, isAuthenticated, loadToken, clearToken };
285
+ module.exports = { authenticate, handleOAuthCallback, callSlackMcp, listSlackTools, isAuthenticated, loadToken, clearToken };