slashvibe-mcp 0.3.19 → 0.3.21

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.
@@ -0,0 +1,407 @@
1
+ /**
2
+ * Git Bundle Library
3
+ *
4
+ * Creates and validates git bundles for session sharing.
5
+ * Used by broadcast.js to capture code changes during a broadcast.
6
+ *
7
+ * Key functions:
8
+ * - captureSessionStart() - Returns current HEAD commit
9
+ * - createBundle(startCommit) - Creates bundle from startCommit to HEAD
10
+ * - validateBundle(buffer) - Validates bundle integrity
11
+ */
12
+
13
+ const { execSync, spawn } = require('child_process');
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const os = require('os');
17
+
18
+ // Max bundle size (10MB)
19
+ const MAX_BUNDLE_SIZE = 10 * 1024 * 1024;
20
+
21
+ // Files to exclude from bundles (security)
22
+ const EXCLUDED_PATTERNS = [
23
+ '.env',
24
+ '.env.*',
25
+ '*.pem',
26
+ '*.key',
27
+ '*.p12',
28
+ '*.pfx',
29
+ '.npmrc',
30
+ '.netrc',
31
+ 'credentials.json',
32
+ 'secrets.json',
33
+ '*_rsa',
34
+ '*_dsa',
35
+ '*_ed25519',
36
+ '*_ecdsa',
37
+ 'id_rsa*',
38
+ 'id_dsa*',
39
+ '*.keystore',
40
+ ];
41
+
42
+ /**
43
+ * Check if we're in a git repository
44
+ */
45
+ function isGitRepo() {
46
+ try {
47
+ execSync('git rev-parse --is-inside-work-tree', {
48
+ stdio: 'pipe',
49
+ encoding: 'utf8',
50
+ });
51
+ return true;
52
+ } catch (e) {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Get current git branch
59
+ */
60
+ function getCurrentBranch() {
61
+ try {
62
+ return execSync('git rev-parse --abbrev-ref HEAD', {
63
+ stdio: 'pipe',
64
+ encoding: 'utf8',
65
+ }).trim();
66
+ } catch (e) {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Get current HEAD commit hash
73
+ */
74
+ function getHeadCommit() {
75
+ try {
76
+ return execSync('git rev-parse HEAD', {
77
+ stdio: 'pipe',
78
+ encoding: 'utf8',
79
+ }).trim();
80
+ } catch (e) {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Get commit count between two commits
87
+ */
88
+ function getCommitCount(startCommit, endCommit = 'HEAD') {
89
+ try {
90
+ const output = execSync(`git rev-list --count ${startCommit}..${endCommit}`, {
91
+ stdio: 'pipe',
92
+ encoding: 'utf8',
93
+ });
94
+ return parseInt(output.trim(), 10);
95
+ } catch (e) {
96
+ return 0;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Get list of files changed between commits
102
+ */
103
+ function getChangedFiles(startCommit, endCommit = 'HEAD') {
104
+ try {
105
+ const output = execSync(`git diff --name-only ${startCommit}..${endCommit}`, {
106
+ stdio: 'pipe',
107
+ encoding: 'utf8',
108
+ });
109
+ return output.trim().split('\n').filter(Boolean);
110
+ } catch (e) {
111
+ return [];
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Check if any excluded files would be in the bundle
117
+ */
118
+ function hasExcludedFiles(changedFiles) {
119
+ const excluded = [];
120
+
121
+ for (const file of changedFiles) {
122
+ const basename = path.basename(file);
123
+
124
+ for (const pattern of EXCLUDED_PATTERNS) {
125
+ // Simple glob matching
126
+ if (pattern.startsWith('*')) {
127
+ const suffix = pattern.slice(1);
128
+ if (basename.endsWith(suffix)) {
129
+ excluded.push(file);
130
+ break;
131
+ }
132
+ } else if (pattern.endsWith('*')) {
133
+ const prefix = pattern.slice(0, -1);
134
+ if (basename.startsWith(prefix)) {
135
+ excluded.push(file);
136
+ break;
137
+ }
138
+ } else if (pattern.includes('*')) {
139
+ // Pattern like ".env.*"
140
+ const [prefix, suffix] = pattern.split('*');
141
+ if (basename.startsWith(prefix) && (suffix === '' || basename.endsWith(suffix))) {
142
+ excluded.push(file);
143
+ break;
144
+ }
145
+ } else if (basename === pattern || file === pattern) {
146
+ excluded.push(file);
147
+ break;
148
+ }
149
+ }
150
+ }
151
+
152
+ return excluded;
153
+ }
154
+
155
+ /**
156
+ * Capture git state at session start
157
+ * @returns {object} - { success, commit, branch, error? }
158
+ */
159
+ function captureSessionStart() {
160
+ if (!isGitRepo()) {
161
+ return { success: false, error: 'Not a git repository' };
162
+ }
163
+
164
+ const commit = getHeadCommit();
165
+ const branch = getCurrentBranch();
166
+
167
+ if (!commit) {
168
+ return { success: false, error: 'Could not get HEAD commit' };
169
+ }
170
+
171
+ return {
172
+ success: true,
173
+ commit,
174
+ branch,
175
+ capturedAt: new Date().toISOString(),
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Create a git bundle from startCommit to HEAD
181
+ * @param {string} startCommit - Starting commit hash
182
+ * @returns {object} - { success, buffer, metadata, error? }
183
+ */
184
+ function createBundle(startCommit) {
185
+ if (!isGitRepo()) {
186
+ return { success: false, error: 'Not a git repository' };
187
+ }
188
+
189
+ if (!startCommit) {
190
+ return { success: false, error: 'Start commit required' };
191
+ }
192
+
193
+ const endCommit = getHeadCommit();
194
+ const branch = getCurrentBranch();
195
+
196
+ if (!endCommit) {
197
+ return { success: false, error: 'Could not get HEAD commit' };
198
+ }
199
+
200
+ // Check if there are any new commits
201
+ const commitCount = getCommitCount(startCommit, endCommit);
202
+ if (commitCount === 0) {
203
+ return {
204
+ success: false,
205
+ error: 'No new commits since session start',
206
+ metadata: {
207
+ startCommit,
208
+ endCommit,
209
+ branch,
210
+ },
211
+ };
212
+ }
213
+
214
+ // Check for excluded files
215
+ const changedFiles = getChangedFiles(startCommit, endCommit);
216
+ const excludedFiles = hasExcludedFiles(changedFiles);
217
+
218
+ if (excludedFiles.length > 0) {
219
+ console.log(`[git-bundle] Warning: Excluding sensitive files: ${excludedFiles.join(', ')}`);
220
+ }
221
+
222
+ // Create temp file for bundle
223
+ const tmpDir = os.tmpdir();
224
+ const bundlePath = path.join(tmpDir, `vibe-bundle-${Date.now()}.bundle`);
225
+
226
+ try {
227
+ // Create the bundle
228
+ // Format: git bundle create <file> <range>
229
+ const bundleRange = `${startCommit}..HEAD`;
230
+
231
+ execSync(`git bundle create "${bundlePath}" ${bundleRange}`, {
232
+ stdio: 'pipe',
233
+ encoding: 'utf8',
234
+ });
235
+
236
+ // Read the bundle
237
+ const buffer = fs.readFileSync(bundlePath);
238
+
239
+ // Check size
240
+ if (buffer.length > MAX_BUNDLE_SIZE) {
241
+ fs.unlinkSync(bundlePath);
242
+ return {
243
+ success: false,
244
+ error: `Bundle too large (${Math.round(buffer.length / 1024 / 1024)}MB > ${MAX_BUNDLE_SIZE / 1024 / 1024}MB limit)`,
245
+ };
246
+ }
247
+
248
+ // Clean up temp file
249
+ fs.unlinkSync(bundlePath);
250
+
251
+ const metadata = {
252
+ initialCommit: startCommit,
253
+ finalCommit: endCommit,
254
+ branch,
255
+ commitCount,
256
+ changedFiles: changedFiles.filter((f) => !excludedFiles.includes(f)),
257
+ excludedFiles,
258
+ size: buffer.length,
259
+ createdAt: new Date().toISOString(),
260
+ };
261
+
262
+ console.log(`[git-bundle] Created bundle: ${commitCount} commits, ${changedFiles.length} files, ${buffer.length} bytes`);
263
+
264
+ return {
265
+ success: true,
266
+ buffer,
267
+ metadata,
268
+ };
269
+ } catch (error) {
270
+ // Clean up on error
271
+ try {
272
+ if (fs.existsSync(bundlePath)) {
273
+ fs.unlinkSync(bundlePath);
274
+ }
275
+ } catch (e) {
276
+ // Ignore cleanup errors
277
+ }
278
+
279
+ console.error('[git-bundle] Failed to create bundle:', error.message);
280
+ return { success: false, error: error.message };
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Validate a git bundle
286
+ * @param {Buffer} buffer - The bundle data
287
+ * @returns {object} - { success, info, error? }
288
+ */
289
+ function validateBundle(buffer) {
290
+ if (!buffer || buffer.length === 0) {
291
+ return { success: false, error: 'Empty buffer' };
292
+ }
293
+
294
+ // Check magic bytes (git bundles start with "# v2 git bundle" or "# v3 git bundle")
295
+ const header = buffer.slice(0, 20).toString('utf8');
296
+ if (!header.startsWith('# v2 git bundle') && !header.startsWith('# v3 git bundle')) {
297
+ return { success: false, error: 'Invalid bundle format' };
298
+ }
299
+
300
+ // Write to temp and verify
301
+ const tmpDir = os.tmpdir();
302
+ const bundlePath = path.join(tmpDir, `vibe-verify-${Date.now()}.bundle`);
303
+
304
+ try {
305
+ fs.writeFileSync(bundlePath, buffer);
306
+
307
+ // Verify the bundle
308
+ const output = execSync(`git bundle verify "${bundlePath}"`, {
309
+ stdio: 'pipe',
310
+ encoding: 'utf8',
311
+ });
312
+
313
+ fs.unlinkSync(bundlePath);
314
+
315
+ return {
316
+ success: true,
317
+ info: output.trim(),
318
+ size: buffer.length,
319
+ };
320
+ } catch (error) {
321
+ try {
322
+ if (fs.existsSync(bundlePath)) {
323
+ fs.unlinkSync(bundlePath);
324
+ }
325
+ } catch (e) {
326
+ // Ignore cleanup errors
327
+ }
328
+
329
+ return { success: false, error: error.message };
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Apply a bundle to create a new branch (for forking)
335
+ * @param {Buffer} buffer - The bundle data
336
+ * @param {string} branchName - Name for the new branch
337
+ * @returns {object} - { success, branch, error? }
338
+ */
339
+ function applyBundle(buffer, branchName = 'forked-session') {
340
+ if (!isGitRepo()) {
341
+ return { success: false, error: 'Not a git repository' };
342
+ }
343
+
344
+ const validation = validateBundle(buffer);
345
+ if (!validation.success) {
346
+ return { success: false, error: `Invalid bundle: ${validation.error}` };
347
+ }
348
+
349
+ const tmpDir = os.tmpdir();
350
+ const bundlePath = path.join(tmpDir, `vibe-apply-${Date.now()}.bundle`);
351
+
352
+ try {
353
+ fs.writeFileSync(bundlePath, buffer);
354
+
355
+ // Get the refs in the bundle
356
+ const listOutput = execSync(`git bundle list-heads "${bundlePath}"`, {
357
+ stdio: 'pipe',
358
+ encoding: 'utf8',
359
+ });
360
+
361
+ const refs = listOutput.trim().split('\n');
362
+ if (refs.length === 0) {
363
+ throw new Error('No refs in bundle');
364
+ }
365
+
366
+ // Get the first commit hash from the bundle
367
+ const firstRef = refs[0].split(/\s+/)[0];
368
+
369
+ // Unbundle (fetch from bundle)
370
+ execSync(`git fetch "${bundlePath}" ${firstRef}:${branchName}`, {
371
+ stdio: 'pipe',
372
+ });
373
+
374
+ fs.unlinkSync(bundlePath);
375
+
376
+ console.log(`[git-bundle] Applied bundle to branch: ${branchName}`);
377
+
378
+ return {
379
+ success: true,
380
+ branch: branchName,
381
+ refs: refs.map((r) => r.split(/\s+/)),
382
+ };
383
+ } catch (error) {
384
+ try {
385
+ if (fs.existsSync(bundlePath)) {
386
+ fs.unlinkSync(bundlePath);
387
+ }
388
+ } catch (e) {
389
+ // Ignore cleanup errors
390
+ }
391
+
392
+ console.error('[git-bundle] Failed to apply bundle:', error.message);
393
+ return { success: false, error: error.message };
394
+ }
395
+ }
396
+
397
+ module.exports = {
398
+ isGitRepo,
399
+ getCurrentBranch,
400
+ getHeadCommit,
401
+ captureSessionStart,
402
+ createBundle,
403
+ validateBundle,
404
+ applyBundle,
405
+ MAX_BUNDLE_SIZE,
406
+ EXCLUDED_PATTERNS,
407
+ };
package/tools/session.js CHANGED
@@ -8,11 +8,13 @@
8
8
  * - session save --room X → Save specific broadcast
9
9
  * - session list → List my sessions
10
10
  * - session browse → Browse all public sessions
11
- * - session fork <id> → Fork a session
11
+ * - session fork <id> → Fork a session (story only)
12
+ * - session fork <id> --full → Fork with git bundle (get actual code)
12
13
  * - session view <id> → View session details
13
14
  */
14
15
 
15
16
  const config = require('../config');
17
+ const gitApply = require('./lib/git-apply');
16
18
 
17
19
  const definition = {
18
20
  name: 'vibe_session',
@@ -45,6 +47,14 @@ const definition = {
45
47
  limit: {
46
48
  type: 'number',
47
49
  description: 'Number of sessions to show (default: 10)'
50
+ },
51
+ full: {
52
+ type: 'boolean',
53
+ description: 'For fork: download git bundle and apply locally (default: false)'
54
+ },
55
+ branch: {
56
+ type: 'string',
57
+ description: 'For fork --full: branch name for applied code (default: forked-session)'
48
58
  }
49
59
  }
50
60
  }
@@ -373,6 +383,9 @@ async function handler(args) {
373
383
  }
374
384
 
375
385
  const sessionId = args.id;
386
+ const fullFork = args.full === true;
387
+ const branchName = args.branch || 'forked-session';
388
+ const forkLevel = fullFork ? 'full' : 'story_only';
376
389
 
377
390
  try {
378
391
  const response = await fetch(`${apiUrl}/api/sessions/fork`, {
@@ -382,7 +395,7 @@ async function handler(args) {
382
395
  parentSessionId: sessionId,
383
396
  forkerHandle: myHandle,
384
397
  forkTo: 'session',
385
- forkLevel: 'story_only'
398
+ forkLevel
386
399
  })
387
400
  });
388
401
 
@@ -404,8 +417,42 @@ async function handler(args) {
404
417
  display += `**From:** ${data.parentSession?.title || sessionId}\n`;
405
418
  display += `**By:** @${data.parentSession?.author || 'unknown'}\n\n`;
406
419
  display += `**Fork ID:** ${data.forkId}\n\n`;
420
+
421
+ // Handle full fork with git bundle
422
+ if (fullFork && data.git?.bundleDownloadUrl) {
423
+ display += `📦 **Downloading git bundle...**\n`;
424
+
425
+ const applyResult = await gitApply.applyBundleFromUrl(
426
+ data.git.bundleDownloadUrl,
427
+ branchName
428
+ );
429
+
430
+ if (applyResult.success) {
431
+ display += `✅ **Code applied to branch:** \`${applyResult.branch}\`\n\n`;
432
+ display += `**Git info:**\n`;
433
+ display += `- Branch: ${data.git.branch || 'unknown'}\n`;
434
+ display += `- Commits: ${data.git.initialCommit?.slice(0, 7)}..${data.git.finalCommit?.slice(0, 7)}\n`;
435
+ display += `- Bundle size: ${Math.round(applyResult.size / 1024)}KB\n\n`;
436
+ display += `**Next steps:**\n`;
437
+ display += `\`\`\`\ngit checkout ${applyResult.branch}\n\`\`\`\n`;
438
+ } else {
439
+ display += `⚠️ **Bundle apply failed:** ${applyResult.error}\n\n`;
440
+ display += `_The session was forked, but the code bundle could not be applied._\n`;
441
+ display += `_You can still view the story at the URL below._\n\n`;
442
+ }
443
+ } else if (fullFork && data.git?.hasBundleAvailable && !data.git?.bundleDownloadUrl) {
444
+ display += `⚠️ **Git bundle not available** (R2 storage may not be configured)\n\n`;
445
+ } else if (fullFork && !data.git?.hasBundleAvailable) {
446
+ display += `ℹ️ **No git bundle** for this session (no commits during broadcast)\n\n`;
447
+ }
448
+
407
449
  display += `**View the story:**\n${data.storyUrl}\n\n`;
408
- display += `_Learn from this session and build your own version!_`;
450
+
451
+ if (!fullFork && data.git?.hasBundleAvailable) {
452
+ display += `_💡 Want the actual code? Use \`vibe session fork ${sessionId} --full\`_`;
453
+ } else if (!fullFork) {
454
+ display += `_Learn from this session and build your own version!_`;
455
+ }
409
456
 
410
457
  return { display };
411
458
 
@@ -0,0 +1,147 @@
1
+ /**
2
+ * vibe_streak — View and track your building streak
3
+ *
4
+ * Streaks drive retention through daily habit formation.
5
+ *
6
+ * Badge Tiers:
7
+ * - 7 days: Verified Builder
8
+ * - 14 days: Consistent Builder
9
+ * - 30 days: Dedicated Builder
10
+ * - 60 days: Relentless Builder
11
+ * - 100 days: Legendary Builder
12
+ *
13
+ * Streak Freezes:
14
+ * - Earn 1 freeze every 7 days (max 3)
15
+ * - Auto-used if you miss a day
16
+ */
17
+
18
+ const config = require('../config');
19
+
20
+ const API_URL = process.env.VIBE_API_URL || 'https://www.slashvibe.dev';
21
+
22
+ const definition = {
23
+ name: 'vibe_streak',
24
+ description: 'View your building streak, badges, and freezes',
25
+ inputSchema: {
26
+ type: 'object',
27
+ properties: {
28
+ user: {
29
+ type: 'string',
30
+ description: 'Username to check (default: yourself)'
31
+ }
32
+ }
33
+ }
34
+ };
35
+
36
+ function requireInit() {
37
+ if (!config.isInitialized()) {
38
+ throw new Error('Not initialized. Run `vibe init` first.');
39
+ }
40
+ }
41
+
42
+ async function handler(args) {
43
+ requireInit();
44
+
45
+ const myHandle = config.getHandle();
46
+ const targetUser = args.user ? args.user.toLowerCase().replace('@', '') : myHandle;
47
+ const isOwnStreak = targetUser === myHandle.toLowerCase();
48
+
49
+ try {
50
+ const endpoint = `${API_URL}/api/growth/streak?user=${targetUser}`;
51
+ const response = await fetch(endpoint);
52
+ const data = await response.json();
53
+
54
+ if (!data.success) {
55
+ return { display: `\u274c Failed to load streak: ${data.error}` };
56
+ }
57
+
58
+ const streak = data.streak;
59
+ let display = '';
60
+
61
+ // Header with current streak
62
+ if (isOwnStreak) {
63
+ display += `## Your Building Streak\n\n`;
64
+ } else {
65
+ display += `## @${targetUser}'s Building Streak\n\n`;
66
+ }
67
+
68
+ // Big streak number with fire emoji based on length
69
+ const fireEmoji = streak.current >= 30 ? '\ud83d\udd25\ud83d\udd25\ud83d\udd25' :
70
+ streak.current >= 14 ? '\ud83d\udd25\ud83d\udd25' :
71
+ streak.current >= 7 ? '\ud83d\udd25' : '\ud83d\udcaa';
72
+
73
+ display += `### ${fireEmoji} ${streak.current} Day Streak\n\n`;
74
+
75
+ // Status indicators
76
+ if (streak.atRisk) {
77
+ display += `\u26a0\ufe0f **AT RISK** - Ship something today to keep your streak!\n\n`;
78
+ } else if (streak.isActiveToday) {
79
+ display += `\u2705 **Active today** - streak is safe!\n\n`;
80
+ }
81
+
82
+ // Stats
83
+ display += `| Stat | Value |\n`;
84
+ display += `|------|-------|\n`;
85
+ display += `| Current Streak | ${streak.current} days |\n`;
86
+ display += `| Longest Streak | ${streak.longest} days |\n`;
87
+ display += `| Freezes Available | ${data.freezesAvailable}\u2744\ufe0f |\n`;
88
+ display += `| Freezes Used | ${streak.freezesUsed || 0} |\n\n`;
89
+
90
+ // Current tier
91
+ if (streak.tier?.current) {
92
+ display += `### Current Tier: ${streak.tier.current.emoji} ${streak.tier.current.label}\n\n`;
93
+ }
94
+
95
+ // Next badge progress
96
+ if (data.nextBadge) {
97
+ const progress = Math.round((streak.current / data.nextBadge.daysAway + streak.current) * 100);
98
+ display += `### Next Badge: ${data.nextBadge.emoji} ${data.nextBadge.label}\n`;
99
+ display += `**${data.nextBadge.daysAway} days** to unlock\n\n`;
100
+ } else if (streak.current >= 100) {
101
+ display += `### \ud83c\udf1f All badges unlocked! You're a legend.\n\n`;
102
+ }
103
+
104
+ // Earned badges
105
+ if (streak.badges && streak.badges.length > 0) {
106
+ display += `### Earned Badges\n`;
107
+ const badgeInfo = {
108
+ verified_builder: { emoji: '\u2705', label: 'Verified Builder (7d)' },
109
+ consistent_builder: { emoji: '\ud83d\udcaa', label: 'Consistent Builder (14d)' },
110
+ dedicated_builder: { emoji: '\ud83d\udd25', label: 'Dedicated Builder (30d)' },
111
+ relentless_builder: { emoji: '\u26a1', label: 'Relentless Builder (60d)' },
112
+ legendary_builder: { emoji: '\ud83d\udc51', label: 'Legendary Builder (100d)' }
113
+ };
114
+
115
+ for (const badge of streak.badges) {
116
+ const info = badgeInfo[badge] || { emoji: '\ud83c\udfc5', label: badge };
117
+ display += `- ${info.emoji} ${info.label}\n`;
118
+ }
119
+ display += '\n';
120
+ }
121
+
122
+ // 30-day calendar visualization
123
+ if (data.calendar) {
124
+ display += `### Last 30 Days\n`;
125
+ display += '`';
126
+ for (const day of data.calendar) {
127
+ display += day.active ? '\u25a0' : '\u25a1';
128
+ }
129
+ display += '`\n';
130
+ display += '_(\u25a0 = active day)_\n';
131
+ }
132
+
133
+ // Tip for maintaining streak
134
+ if (isOwnStreak && !streak.isActiveToday) {
135
+ display += `\n---\n**Tip:** Ship something or post to the board to record today's activity!`;
136
+ }
137
+
138
+ return { display };
139
+
140
+ } catch (e) {
141
+ return {
142
+ display: `## Building Streak\n\n\u274c **Failed to load**\n\nError: ${e.message}`
143
+ };
144
+ }
145
+ }
146
+
147
+ module.exports = { definition, handler };