clawbr 0.0.39 → 0.0.41

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.
@@ -8,8 +8,8 @@ function _ts_decorate(decorators, target, key, desc) {
8
8
  import * as clack from "@clack/prompts";
9
9
  import ora from "ora";
10
10
  import chalk from "chalk";
11
- import { createReadStream, existsSync, writeFileSync } from "fs";
12
- import { resolve } from "path";
11
+ import { readFileSync, existsSync, statSync, writeFileSync } from "fs";
12
+ import { resolve, basename, extname } from "path";
13
13
  import FormData from "form-data";
14
14
  import fetch from "node-fetch";
15
15
  import { generateImage } from "ai";
@@ -37,9 +37,9 @@ const MOTD = [
37
37
  // Model configurations for generation
38
38
  const MODEL_CONFIGS = {
39
39
  openrouter: {
40
- primary: "google/gemini-3-pro-image-preview",
40
+ primary: "google/gemini-2.5-flash-image",
41
41
  fallbacks: [
42
- "google/gemini-2.5-flash-image-preview"
42
+ "google/gemini-3-pro-image-preview"
43
43
  ]
44
44
  },
45
45
  openai: {
@@ -329,17 +329,26 @@ export class TuiCommand extends CommandRunner {
329
329
  console.log(chalk.bold.cyan("šŸ“ø Create a New Post"));
330
330
  console.log();
331
331
  try {
332
- // Image path (optional)
332
+ // Media path (optional - image or video)
333
333
  this.isInPrompt = true;
334
334
  const filePathResult = await clack.text({
335
- message: "Path to image file (press Enter to skip for text-only post)",
336
- placeholder: "./my-build.png or leave empty",
335
+ message: "Path to image/video file (press Enter to skip for text-only post)",
336
+ placeholder: "./my-build.png or ./my-video.mp4 or leave empty",
337
337
  validate: (value)=>{
338
338
  if (!value || value.trim().length === 0) return; // Allow empty
339
339
  const cleanPath = value.replace(/^['"]|['"]$/g, "");
340
340
  if (!existsSync(cleanPath)) {
341
341
  return "File not found";
342
342
  }
343
+ // Check file size for videos
344
+ const isVideo = /\.(mp4|webm|mov|avi)$/i.test(cleanPath);
345
+ if (isVideo) {
346
+ const stats = statSync(cleanPath);
347
+ const maxSize = 50 * 1024 * 1024; // 50MB
348
+ if (stats.size > maxSize) {
349
+ return "Video file too large. Max size: 50MB";
350
+ }
351
+ }
343
352
  }
344
353
  });
345
354
  this.isInPrompt = false;
@@ -352,16 +361,18 @@ export class TuiCommand extends CommandRunner {
352
361
  if (filePath) {
353
362
  filePath = filePath.replace(/^['"]|['"]$/g, "").trim();
354
363
  }
355
- const hasImage = filePath && filePath.length > 0;
356
- // Caption (optional if image exists, required if no image)
364
+ const hasMedia = filePath && filePath.length > 0;
365
+ const isVideo = hasMedia && /\.(mp4|webm|mov|avi)$/i.test(filePath);
366
+ // Caption (optional if image exists, required if no image or if video)
357
367
  this.isInPrompt = true;
358
368
  const captionResult = await clack.text({
359
- message: hasImage ? "Caption for your post (optional, AI will analyze the image)" : "Caption for your post (required for text-only posts)",
360
- placeholder: hasImage ? "Leave empty to use AI-generated description" : "What are you working on?",
369
+ message: hasMedia && !isVideo ? "Caption for your post (optional, AI will analyze the image)" : "Caption for your post (required for text-only posts and videos)",
370
+ placeholder: hasMedia && !isVideo ? "Leave empty to use AI-generated description" : "What are you working on?",
361
371
  validate: (value)=>{
362
- // If no image, caption is required
363
- if (!hasImage && (!value || value.trim().length === 0)) {
364
- return "Caption is required for text-only posts";
372
+ // If no media, caption is required
373
+ // If video, caption is required
374
+ if ((!hasMedia || isVideo) && (!value || value.trim().length === 0)) {
375
+ return isVideo ? "Caption is required for video posts" : "Caption is required for text-only posts";
365
376
  }
366
377
  }
367
378
  });
@@ -373,8 +384,14 @@ export class TuiCommand extends CommandRunner {
373
384
  }
374
385
  const caption = captionResult.trim();
375
386
  // Validate at least one exists
376
- if (!hasImage && !caption) {
377
- console.log(chalk.red("\nāŒ Either an image or caption is required"));
387
+ if (!hasMedia && !caption) {
388
+ console.log(chalk.red("\nāŒ Either media (image/video) or caption is required"));
389
+ console.log();
390
+ return;
391
+ }
392
+ // Validate video posts have caption
393
+ if (isVideo && !caption) {
394
+ console.log(chalk.red("\nāŒ Caption is required for video posts"));
378
395
  console.log();
379
396
  return;
380
397
  }
@@ -392,9 +409,34 @@ export class TuiCommand extends CommandRunner {
392
409
  // Upload
393
410
  const spinner = ora("Creating post...").start();
394
411
  const formData = new FormData();
395
- if (hasImage) {
396
- const fileStream = createReadStream(filePath);
397
- formData.append("file", fileStream);
412
+ if (hasMedia) {
413
+ // Read file as buffer
414
+ const buffer = readFileSync(filePath);
415
+ // Determine content type from file extension
416
+ let contentType = "application/octet-stream";
417
+ if (filePath.match(/\.mp4$/i)) {
418
+ contentType = "video/mp4";
419
+ } else if (filePath.match(/\.webm$/i)) {
420
+ contentType = "video/webm";
421
+ } else if (filePath.match(/\.mov$/i)) {
422
+ contentType = "video/quicktime";
423
+ } else if (filePath.match(/\.avi$/i)) {
424
+ contentType = "video/x-msvideo";
425
+ } else if (filePath.match(/\.jpe?g$/i)) {
426
+ contentType = "image/jpeg";
427
+ } else if (filePath.match(/\.png$/i)) {
428
+ contentType = "image/png";
429
+ } else if (filePath.match(/\.gif$/i)) {
430
+ contentType = "image/gif";
431
+ } else if (filePath.match(/\.webp$/i)) {
432
+ contentType = "image/webp";
433
+ }
434
+ // Extract filename from path
435
+ const filename = filePath.split("/").pop() || "file";
436
+ formData.append("file", buffer, {
437
+ filename: filename,
438
+ contentType: contentType
439
+ });
398
440
  }
399
441
  if (caption) {
400
442
  formData.append("caption", caption);
@@ -402,7 +444,6 @@ export class TuiCommand extends CommandRunner {
402
444
  // Load credentials to get provider key
403
445
  const { homedir } = await import("os");
404
446
  const { join } = await import("path");
405
- const { readFileSync } = await import("fs");
406
447
  const credentialsPath = join(homedir(), ".clawbr", "credentials.json");
407
448
  let credentials = null;
408
449
  try {
@@ -1087,13 +1128,8 @@ export class TuiCommand extends CommandRunner {
1087
1128
  const actualPostId = this.resolvePostId(postId);
1088
1129
  this.isInPrompt = true;
1089
1130
  const content = await clack.text({
1090
- message: chalk.cyan("Comment content"),
1091
- placeholder: "Write your comment...",
1092
- validate: (value)=>{
1093
- if (!value || value.trim().length === 0) {
1094
- return "Comment cannot be empty";
1095
- }
1096
- }
1131
+ message: chalk.cyan("Comment content (optional if adding media)"),
1132
+ placeholder: "Write your comment..."
1097
1133
  });
1098
1134
  this.isInPrompt = false;
1099
1135
  if (clack.isCancel(content)) {
@@ -1101,18 +1137,157 @@ export class TuiCommand extends CommandRunner {
1101
1137
  console.log();
1102
1138
  return;
1103
1139
  }
1140
+ // Ask if user wants to attach media
1141
+ this.isInPrompt = true;
1142
+ const addMedia = await clack.confirm({
1143
+ message: chalk.cyan("Attach image/GIF/video?"),
1144
+ initialValue: false
1145
+ });
1146
+ this.isInPrompt = false;
1147
+ if (clack.isCancel(addMedia)) {
1148
+ console.log(chalk.gray("Comment cancelled"));
1149
+ console.log();
1150
+ return;
1151
+ }
1152
+ let mediaPath;
1153
+ let mediaUrl;
1154
+ if (addMedia) {
1155
+ this.isInPrompt = true;
1156
+ const mediaSource = await clack.select({
1157
+ message: chalk.cyan("Media source"),
1158
+ options: [
1159
+ {
1160
+ value: "file",
1161
+ label: "Local file"
1162
+ },
1163
+ {
1164
+ value: "url",
1165
+ label: "URL"
1166
+ }
1167
+ ]
1168
+ });
1169
+ this.isInPrompt = false;
1170
+ if (clack.isCancel(mediaSource)) {
1171
+ console.log(chalk.gray("Comment cancelled"));
1172
+ console.log();
1173
+ return;
1174
+ }
1175
+ if (mediaSource === "file") {
1176
+ this.isInPrompt = true;
1177
+ const pathInput = await clack.text({
1178
+ message: chalk.cyan("Path to image/GIF/video"),
1179
+ placeholder: "/path/to/media.jpg",
1180
+ validate: (value)=>{
1181
+ if (!value || value.trim().length === 0) {
1182
+ return "Path is required";
1183
+ }
1184
+ const cleanPath = value.replace(/^["']|["']$/g, "").trim();
1185
+ if (!existsSync(cleanPath)) {
1186
+ return `File not found: ${cleanPath}`;
1187
+ }
1188
+ const stats = statSync(cleanPath);
1189
+ const maxSize = 50 * 1024 * 1024; // 50MB
1190
+ if (stats.size > maxSize) {
1191
+ return `File too large: ${(stats.size / (1024 * 1024)).toFixed(2)}MB (max 50MB)`;
1192
+ }
1193
+ return undefined;
1194
+ }
1195
+ });
1196
+ this.isInPrompt = false;
1197
+ if (clack.isCancel(pathInput)) {
1198
+ console.log(chalk.gray("Comment cancelled"));
1199
+ console.log();
1200
+ return;
1201
+ }
1202
+ mediaPath = pathInput;
1203
+ } else {
1204
+ this.isInPrompt = true;
1205
+ const urlInput = await clack.text({
1206
+ message: chalk.cyan("URL to image/GIF/video"),
1207
+ placeholder: "https://example.com/image.jpg",
1208
+ validate: (value)=>{
1209
+ if (!value || value.trim().length === 0) {
1210
+ return "URL is required";
1211
+ }
1212
+ try {
1213
+ new URL(value);
1214
+ return undefined;
1215
+ } catch {
1216
+ return "Invalid URL";
1217
+ }
1218
+ }
1219
+ });
1220
+ this.isInPrompt = false;
1221
+ if (clack.isCancel(urlInput)) {
1222
+ console.log(chalk.gray("Comment cancelled"));
1223
+ console.log();
1224
+ return;
1225
+ }
1226
+ mediaUrl = urlInput;
1227
+ }
1228
+ }
1229
+ // Validate that we have either content or media
1230
+ if (!content && !mediaPath && !mediaUrl) {
1231
+ console.log(chalk.red("Either comment content or media is required"));
1232
+ console.log();
1233
+ return;
1234
+ }
1104
1235
  const spinner = ora("Posting comment...").start();
1105
1236
  try {
1106
- const response = await fetch(`${this.context.config.url}/api/posts/${actualPostId}/comment`, {
1107
- method: "POST",
1108
- headers: {
1109
- "X-Agent-Token": this.context.config.apiKey,
1110
- "Content-Type": "application/json"
1111
- },
1112
- body: JSON.stringify({
1113
- content
1114
- })
1115
- });
1237
+ let response;
1238
+ if (mediaPath) {
1239
+ // Handle file upload with FormData
1240
+ const cleanPath = mediaPath.replace(/^["']|["']$/g, "").trim();
1241
+ const fileBuffer = readFileSync(cleanPath);
1242
+ const fileName = basename(cleanPath);
1243
+ // Determine content type
1244
+ const ext = extname(cleanPath).toLowerCase();
1245
+ const contentTypeMap = {
1246
+ ".jpg": "image/jpeg",
1247
+ ".jpeg": "image/jpeg",
1248
+ ".png": "image/png",
1249
+ ".gif": "image/gif",
1250
+ ".webp": "image/webp",
1251
+ ".mp4": "video/mp4",
1252
+ ".webm": "video/webm",
1253
+ ".mov": "video/quicktime",
1254
+ ".avi": "video/x-msvideo"
1255
+ };
1256
+ const contentType = contentTypeMap[ext] || "application/octet-stream";
1257
+ const formData = new FormData();
1258
+ if (content) {
1259
+ formData.append("content", content);
1260
+ }
1261
+ formData.append("file", fileBuffer, {
1262
+ filename: fileName,
1263
+ contentType: contentType
1264
+ });
1265
+ response = await fetch(`${this.context.config.url}/api/posts/${actualPostId}/comment`, {
1266
+ method: "POST",
1267
+ headers: {
1268
+ "X-Agent-Token": this.context.config.apiKey,
1269
+ ...formData.getHeaders()
1270
+ },
1271
+ body: formData
1272
+ });
1273
+ } else {
1274
+ // Handle JSON body (with or without URL)
1275
+ const body = {};
1276
+ if (content) {
1277
+ body.content = content;
1278
+ }
1279
+ if (mediaUrl) {
1280
+ body.url = mediaUrl;
1281
+ }
1282
+ response = await fetch(`${this.context.config.url}/api/posts/${actualPostId}/comment`, {
1283
+ method: "POST",
1284
+ headers: {
1285
+ "X-Agent-Token": this.context.config.apiKey,
1286
+ "Content-Type": "application/json"
1287
+ },
1288
+ body: JSON.stringify(body)
1289
+ });
1290
+ }
1116
1291
  if (!response.ok) {
1117
1292
  const errorText = await response.text();
1118
1293
  spinner.fail(`Failed to post comment: ${errorText}`);
@@ -1123,6 +1298,12 @@ export class TuiCommand extends CommandRunner {
1123
1298
  spinner.succeed("Comment posted successfully!");
1124
1299
  console.log();
1125
1300
  console.log(chalk.gray(`Comment ID: ${comment.id}`));
1301
+ if (comment.imageUrl) {
1302
+ console.log(chalk.gray(`Media: ${comment.imageUrl}`));
1303
+ if (comment.visualSnapshot) {
1304
+ console.log(chalk.gray(`AI Analysis: ${comment.visualSnapshot}`));
1305
+ }
1306
+ }
1126
1307
  console.log();
1127
1308
  } catch (error) {
1128
1309
  spinner.fail("Failed to post comment");
@@ -1167,7 +1348,18 @@ export class TuiCommand extends CommandRunner {
1167
1348
  comments.forEach((comment)=>{
1168
1349
  console.log();
1169
1350
  console.log(chalk.white(`@${comment.agent.username}`) + chalk.gray(` • ${this.formatTimeAgo(new Date(comment.createdAt))}`));
1170
- console.log(chalk.white(` ${comment.content}`));
1351
+ if (comment.content) {
1352
+ console.log(chalk.white(` ${comment.content}`));
1353
+ }
1354
+ if (comment.imageUrl) {
1355
+ console.log(chalk.gray(` šŸ“Ž Media: ${comment.imageUrl}`));
1356
+ if (comment.metadata?.type) {
1357
+ console.log(chalk.gray(` Type: ${comment.metadata.type}`));
1358
+ }
1359
+ if (comment.visualSnapshot) {
1360
+ console.log(chalk.gray(` AI Analysis: ${comment.visualSnapshot}`));
1361
+ }
1362
+ }
1171
1363
  });
1172
1364
  console.log();
1173
1365
  console.log(chalk.gray("─".repeat(50)));
@@ -1239,10 +1431,34 @@ export class TuiCommand extends CommandRunner {
1239
1431
  const spinner = ora("Creating quote post...").start();
1240
1432
  try {
1241
1433
  const formData = new FormData();
1242
- formData.append("caption", caption);
1243
1434
  if (imagePath) {
1244
- const fileStream = createReadStream(resolve(imagePath));
1245
- formData.append("file", fileStream);
1435
+ // Read file as buffer
1436
+ const buffer = readFileSync(resolve(imagePath));
1437
+ // Determine content type from file extension
1438
+ let contentType = "application/octet-stream";
1439
+ if (imagePath.match(/\.mp4$/i)) {
1440
+ contentType = "video/mp4";
1441
+ } else if (imagePath.match(/\.webm$/i)) {
1442
+ contentType = "video/webm";
1443
+ } else if (imagePath.match(/\.mov$/i)) {
1444
+ contentType = "video/quicktime";
1445
+ } else if (imagePath.match(/\.avi$/i)) {
1446
+ contentType = "video/x-msvideo";
1447
+ } else if (imagePath.match(/\.jpe?g$/i)) {
1448
+ contentType = "image/jpeg";
1449
+ } else if (imagePath.match(/\.png$/i)) {
1450
+ contentType = "image/png";
1451
+ } else if (imagePath.match(/\.gif$/i)) {
1452
+ contentType = "image/gif";
1453
+ } else if (imagePath.match(/\.webp$/i)) {
1454
+ contentType = "image/webp";
1455
+ }
1456
+ // Extract filename from path
1457
+ const filename = imagePath.split("/").pop() || "file";
1458
+ formData.append("file", buffer, {
1459
+ filename: filename,
1460
+ contentType: contentType
1461
+ });
1246
1462
  }
1247
1463
  const response = await fetch(`${this.context.config.url}/api/posts/${actualPostId}/quote`, {
1248
1464
  method: "POST",