clishop 0.1.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.
@@ -0,0 +1,1319 @@
1
+ import { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import ora from "ora";
4
+ import inquirer from "inquirer";
5
+ import { getApiClient, handleApiError } from "../api.js";
6
+ import { getActiveAgent } from "../config.js";
7
+
8
+ export interface Product {
9
+ id: string;
10
+ name: string;
11
+ description: string;
12
+ priceInCents: number;
13
+ currency: string;
14
+ category: string;
15
+ categoryId?: string;
16
+ vendor: string;
17
+ storeId: string;
18
+ storeName: string;
19
+ storeVerified: boolean;
20
+ storeRating: number | null;
21
+ rating: number;
22
+ reviewCount: number;
23
+ inStock: boolean;
24
+ imageUrl?: string;
25
+ sku?: string;
26
+ brand?: string;
27
+ model?: string;
28
+ gtin?: string;
29
+ variant?: string;
30
+ shippingPriceInCents?: number;
31
+ freeShipping: boolean;
32
+ shippingDays?: number;
33
+ stockQuantity?: number;
34
+ backorder: boolean;
35
+ freeReturns: boolean;
36
+ returnWindowDays?: number;
37
+ checkoutMode: string;
38
+ }
39
+
40
+ export interface SearchResult {
41
+ products: Product[];
42
+ total: number;
43
+ page: number;
44
+ pageSize: number;
45
+ }
46
+
47
+ function formatPrice(cents: number, currency: string): string {
48
+ return new Intl.NumberFormat("en-US", {
49
+ style: "currency",
50
+ currency,
51
+ }).format(cents / 100);
52
+ }
53
+
54
+ // ── Currency conversion ──────────────────────────────────────────────
55
+ // Uses the free open.er-api.com (no key needed, updates daily)
56
+
57
+ let rateCache: { base: string; rates: Record<string, number>; fetchedAt: number } | null = null;
58
+
59
+ async function fetchRates(baseCurrency: string): Promise<Record<string, number>> {
60
+ // Cache for 1 hour
61
+ if (rateCache && rateCache.base === baseCurrency && Date.now() - rateCache.fetchedAt < 3600000) {
62
+ return rateCache.rates;
63
+ }
64
+ try {
65
+ const res = await fetch(`https://open.er-api.com/v6/latest/${baseCurrency}`);
66
+ const data = await res.json() as { rates?: Record<string, number> };
67
+ if (data.rates) {
68
+ rateCache = { base: baseCurrency, rates: data.rates, fetchedAt: Date.now() };
69
+ return data.rates;
70
+ }
71
+ } catch {
72
+ // Silently fail — conversion is optional
73
+ }
74
+ return {};
75
+ }
76
+
77
+ function convertPrice(cents: number, fromCurrency: string, toCurrency: string, rates: Record<string, number>): number | null {
78
+ if (fromCurrency === toCurrency) return null; // same currency, no conversion needed
79
+ const rate = rates[toCurrency];
80
+ if (!rate) return null;
81
+ return Math.round(cents * rate);
82
+ }
83
+
84
+ function formatConverted(cents: number, fromCurrency: string, toCurrency: string, rates: Record<string, number>): string {
85
+ const converted = convertPrice(cents, fromCurrency, toCurrency, rates);
86
+ if (converted == null) return "";
87
+ return chalk.dim(` (~${formatPrice(converted, toCurrency)})`);
88
+ }
89
+
90
+ function renderStars(rating: number): string {
91
+ const full = Math.floor(rating);
92
+ const half = rating - full >= 0.5 ? 1 : 0;
93
+ const empty = 5 - full - half;
94
+ return "★".repeat(full) + (half ? "½" : "") + "☆".repeat(empty);
95
+ }
96
+
97
+ function deliveryLabel(days: number): string {
98
+ if (days <= 0) return "Same-day";
99
+ if (days === 1) return "Next-day";
100
+ if (days === 2) return "2-day";
101
+ return `${days}-day`;
102
+ }
103
+
104
+ function estimatedArrival(days: number): string {
105
+ const d = new Date();
106
+ d.setDate(d.getDate() + days);
107
+ return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
108
+ }
109
+
110
+ function scoreOutOf10(rating: number, maxScale = 5): string {
111
+ return ((rating / maxScale) * 10).toFixed(1);
112
+ }
113
+
114
+ // ── Free-form info renderer ───────────────────────────────────────────
115
+ // Recursively renders arbitrary key-value data from stores in a readable way.
116
+ // Handles nested objects, arrays, strings, numbers, booleans, etc.
117
+
118
+ function renderFreeFormInfo(data: any, indent: number = 0): void {
119
+ const pad = " ".repeat(indent);
120
+
121
+ if (data == null) return;
122
+
123
+ if (typeof data === "string") {
124
+ // Wrap long strings
125
+ if (data.length > 100) {
126
+ const words = data.split(/\s+/);
127
+ let line = "";
128
+ for (const word of words) {
129
+ if (line.length + word.length + 1 > 90) {
130
+ console.log(`${pad}${chalk.dim(line)}`);
131
+ line = word;
132
+ } else {
133
+ line = line ? `${line} ${word}` : word;
134
+ }
135
+ }
136
+ if (line) console.log(`${pad}${chalk.dim(line)}`);
137
+ } else {
138
+ console.log(`${pad}${data}`);
139
+ }
140
+ return;
141
+ }
142
+
143
+ if (typeof data === "number" || typeof data === "boolean") {
144
+ console.log(`${pad}${data}`);
145
+ return;
146
+ }
147
+
148
+ if (Array.isArray(data)) {
149
+ for (const item of data) {
150
+ if (typeof item === "string") {
151
+ console.log(`${pad}${chalk.dim("•")} ${item}`);
152
+ } else if (typeof item === "object" && item !== null) {
153
+ renderFreeFormInfo(item, indent + 2);
154
+ console.log(); // spacing between array items
155
+ } else {
156
+ console.log(`${pad}${chalk.dim("•")} ${String(item)}`);
157
+ }
158
+ }
159
+ return;
160
+ }
161
+
162
+ if (typeof data === "object") {
163
+ for (const [key, value] of Object.entries(data)) {
164
+ // Skip internal/meta fields
165
+ if (key === "product_id" || key === "error" || key === "available") continue;
166
+
167
+ const label = key
168
+ .replace(/_/g, " ")
169
+ .replace(/\b\w/g, (c) => c.toUpperCase());
170
+
171
+ if (value == null) continue;
172
+
173
+ if (typeof value === "string") {
174
+ if (value.length > 80) {
175
+ console.log(`${pad}${chalk.bold(label + ":")}`);
176
+ renderFreeFormInfo(value, indent + 4);
177
+ } else {
178
+ console.log(`${pad}${chalk.bold(label + ":")} ${value}`);
179
+ }
180
+ } else if (typeof value === "number") {
181
+ console.log(`${pad}${chalk.bold(label + ":")} ${value}`);
182
+ } else if (typeof value === "boolean") {
183
+ console.log(`${pad}${chalk.bold(label + ":")} ${value ? chalk.green("Yes") : chalk.red("No")}`);
184
+ } else if (Array.isArray(value)) {
185
+ console.log(`${pad}${chalk.bold(label + ":")}`);
186
+ renderFreeFormInfo(value, indent + 4);
187
+ } else if (typeof value === "object") {
188
+ console.log(`${pad}${chalk.bold(label + ":")}`);
189
+ renderFreeFormInfo(value, indent + 4);
190
+ }
191
+ }
192
+ }
193
+ }
194
+
195
+ // ── Render a single product info result (reusable by both `info` command and interactive mode) ──
196
+
197
+ function renderProductInfo(
198
+ result: any,
199
+ index: number,
200
+ totalResults: number,
201
+ formatPriceFn: (cents: number, currency: string) => string,
202
+ ): void {
203
+ const num = index + 1;
204
+
205
+ // Header
206
+ const storeBadge = result.storeName
207
+ ? chalk.dim(` from ${result.storeName}`)
208
+ : "";
209
+
210
+ console.log(
211
+ ` ${chalk.dim(`[${num}]`)} ${chalk.bold.cyan(result.info?.title || result.info?.product_id || result.productId)}${storeBadge}`
212
+ );
213
+ console.log(` ${chalk.dim(`ID: ${result.productId}`)}`);
214
+
215
+ if (result.error) {
216
+ console.log(` ${chalk.red(`Error: ${result.error}`)}`);
217
+ console.log();
218
+ return;
219
+ }
220
+
221
+ // Product URL (if available)
222
+ if (result.info?.product_url) {
223
+ console.log(` ${chalk.blue.underline(result.info.product_url)}`);
224
+ }
225
+
226
+ console.log();
227
+
228
+ // Render all the free-form info from the store
229
+ const info = result.info || {};
230
+
231
+ // Price display (if available)
232
+ if (info.price) {
233
+ const priceStr = info.price.amount && info.price.currency
234
+ ? formatPriceFn(Math.round(parseFloat(info.price.amount) * 100), info.price.currency)
235
+ : `${info.price.amount || "N/A"}`;
236
+ let priceLine = ` ${chalk.bold("Price:")} ${chalk.bold.white(priceStr)}`;
237
+
238
+ if (info.list_price?.amount) {
239
+ const listStr = formatPriceFn(
240
+ Math.round(parseFloat(info.list_price.amount) * 100),
241
+ info.list_price.currency || info.price.currency
242
+ );
243
+ priceLine += chalk.dim.strikethrough(` ${listStr}`);
244
+ }
245
+ console.log(priceLine);
246
+ }
247
+
248
+ // Pricing object (from darkstore format)
249
+ if (info.pricing && !info.price) {
250
+ const priceStr = info.pricing.amount && info.pricing.currency
251
+ ? formatPriceFn(Math.round(parseFloat(info.pricing.amount) * 100), info.pricing.currency)
252
+ : `${info.pricing.amount || "N/A"}`;
253
+ let priceLine = ` ${chalk.bold("Price:")} ${chalk.bold.white(priceStr)}`;
254
+ if (info.pricing.compare_at) {
255
+ const listStr = formatPriceFn(
256
+ Math.round(parseFloat(info.pricing.compare_at) * 100),
257
+ info.pricing.currency
258
+ );
259
+ priceLine += chalk.dim.strikethrough(` ${listStr}`);
260
+ }
261
+ console.log(priceLine);
262
+ }
263
+
264
+ // Rating
265
+ if (info.rating) {
266
+ const ratingScore = typeof info.rating === "object"
267
+ ? `${info.rating.score}/${info.rating.max}`
268
+ : String(info.rating);
269
+ let ratingLine = ` ${chalk.bold("Rating:")} ${chalk.yellow(ratingScore)}`;
270
+ if (info.review_count) {
271
+ ratingLine += chalk.dim(` (${info.review_count.toLocaleString()} reviews)`);
272
+ }
273
+ console.log(ratingLine);
274
+ }
275
+
276
+ // Brand
277
+ if (info.brand) {
278
+ console.log(` ${chalk.bold("Brand:")} ${info.brand}`);
279
+ }
280
+
281
+ // Marketplace
282
+ if (info.marketplace) {
283
+ console.log(` ${chalk.bold("Marketplace:")} ${info.marketplace.name || info.marketplace.domain || ""}`);
284
+ }
285
+
286
+ // Availability
287
+ if (info.availability) {
288
+ if (typeof info.availability === "string") {
289
+ const isInStock = info.availability.toLowerCase().includes("in stock");
290
+ console.log(` ${chalk.bold("Availability:")} ${isInStock ? chalk.green(info.availability) : chalk.yellow(info.availability)}`);
291
+ } else if (typeof info.availability === "object") {
292
+ const status = info.availability.in_stock
293
+ ? chalk.green("In Stock")
294
+ : chalk.red("Out of Stock");
295
+ let availLine = ` ${chalk.bold("Availability:")} ${status}`;
296
+ if (info.availability.quantity != null) {
297
+ availLine += chalk.dim(` (${info.availability.quantity} available)`);
298
+ }
299
+ console.log(availLine);
300
+ }
301
+ }
302
+
303
+ // Prime
304
+ if (info.prime) {
305
+ console.log(` ${chalk.bold("Prime:")} ${chalk.blue("✓ Prime eligible")}`);
306
+ }
307
+
308
+ // Shipping
309
+ if (info.shipping && typeof info.shipping === "object") {
310
+ const parts: string[] = [];
311
+ if (info.shipping.free) parts.push(chalk.green("Free Shipping"));
312
+ if (info.shipping.estimated_days) parts.push(`${info.shipping.estimated_days}-day delivery`);
313
+ if (info.shipping.price?.amount) parts.push(`${info.shipping.price.amount} ${info.shipping.price.currency || ""}`);
314
+ if (info.shipping.weight_kg) parts.push(`${info.shipping.weight_kg}kg`);
315
+ if (parts.length > 0) {
316
+ console.log(` ${chalk.bold("Shipping:")} ${parts.join(" · ")}`);
317
+ }
318
+ }
319
+
320
+ // Delivery info
321
+ if (info.delivery_info) {
322
+ console.log(` ${chalk.bold("Delivery:")} ${info.delivery_info}`);
323
+ }
324
+
325
+ // Returns
326
+ if (info.returns && typeof info.returns === "object") {
327
+ const parts: string[] = [];
328
+ if (info.returns.free) parts.push(chalk.green("Free Returns"));
329
+ if (info.returns.window_days) parts.push(`${info.returns.window_days}-day window`);
330
+ if (info.returns.note) parts.push(info.returns.note);
331
+ if (parts.length > 0) {
332
+ console.log(` ${chalk.bold("Returns:")} ${parts.join(" · ")}`);
333
+ }
334
+ }
335
+
336
+ // Checkout
337
+ if (info.checkout && typeof info.checkout === "object") {
338
+ const parts: string[] = [];
339
+ if (info.checkout.mode) parts.push(info.checkout.mode);
340
+ if (info.checkout.note) parts.push(info.checkout.note);
341
+ if (parts.length > 0) {
342
+ console.log(` ${chalk.bold("Checkout:")} ${parts.join(" — ")}`);
343
+ }
344
+ }
345
+
346
+ // Seller
347
+ if (info.sold_by) {
348
+ console.log(` ${chalk.bold("Sold by:")} ${info.sold_by}`);
349
+ }
350
+
351
+ // Categories
352
+ if (info.categories && Array.isArray(info.categories)) {
353
+ console.log(` ${chalk.bold("Category:")} ${info.categories.join(" > ")}`);
354
+ }
355
+
356
+ console.log();
357
+
358
+ // Features / bullet points
359
+ if (info.features && Array.isArray(info.features) && info.features.length > 0) {
360
+ console.log(` ${chalk.bold("Key Features:")}`);
361
+ for (const feature of info.features) {
362
+ if (feature.length > 80) {
363
+ const words = feature.split(/\s+/);
364
+ let line = "";
365
+ let first = true;
366
+ for (const word of words) {
367
+ if (line.length + word.length + 1 > 76) {
368
+ if (first) {
369
+ console.log(` ${chalk.dim("•")} ${line}`);
370
+ first = false;
371
+ } else {
372
+ console.log(` ${line}`);
373
+ }
374
+ line = word;
375
+ } else {
376
+ line = line ? `${line} ${word}` : word;
377
+ }
378
+ }
379
+ if (line) {
380
+ if (first) {
381
+ console.log(` ${chalk.dim("•")} ${line}`);
382
+ } else {
383
+ console.log(` ${line}`);
384
+ }
385
+ }
386
+ } else {
387
+ console.log(` ${chalk.dim("•")} ${feature}`);
388
+ }
389
+ }
390
+ console.log();
391
+ }
392
+
393
+ // Description
394
+ if (info.description) {
395
+ console.log(` ${chalk.bold("Description:")}`);
396
+ const words = info.description.split(/\s+/);
397
+ let line = "";
398
+ for (const word of words) {
399
+ if (line.length + word.length + 1 > 76) {
400
+ console.log(` ${chalk.dim(line)}`);
401
+ line = word;
402
+ } else {
403
+ line = line ? `${line} ${word}` : word;
404
+ }
405
+ }
406
+ if (line) console.log(` ${chalk.dim(line)}`);
407
+ console.log();
408
+ }
409
+
410
+ // Specifications table
411
+ if (info.specifications && typeof info.specifications === "object") {
412
+ const specs = info.specifications;
413
+ const keys = Object.keys(specs);
414
+ if (keys.length > 0) {
415
+ console.log(` ${chalk.bold("Specifications:")}`);
416
+ const maxKeyLen = Math.min(30, Math.max(...keys.map((k: string) => k.length)));
417
+ for (const [key, value] of Object.entries(specs)) {
418
+ const paddedKey = key.padEnd(maxKeyLen);
419
+ console.log(` ${chalk.dim(paddedKey)} ${value}`);
420
+ }
421
+ console.log();
422
+ }
423
+ }
424
+
425
+ // Images
426
+ if (info.images && Array.isArray(info.images) && info.images.length > 0) {
427
+ console.log(` ${chalk.bold("Images:")} ${chalk.dim(`${info.images.length} available`)}`);
428
+ for (let j = 0; j < Math.min(3, info.images.length); j++) {
429
+ console.log(` ${chalk.dim(`[${j + 1}]`)} ${chalk.blue.underline(info.images[j])}`);
430
+ }
431
+ if (info.images.length > 3) {
432
+ console.log(` ${chalk.dim(`... and ${info.images.length - 3} more`)}`);
433
+ }
434
+ console.log();
435
+ }
436
+
437
+ // About section (A+ content)
438
+ if (info.about && Array.isArray(info.about) && info.about.length > 0) {
439
+ console.log(` ${chalk.bold("About This Item:")}`);
440
+ for (const section of info.about) {
441
+ const words = section.split(/\s+/);
442
+ let line = "";
443
+ for (const word of words) {
444
+ if (line.length + word.length + 1 > 76) {
445
+ console.log(` ${chalk.dim(line)}`);
446
+ line = word;
447
+ } else {
448
+ line = line ? `${line} ${word}` : word;
449
+ }
450
+ }
451
+ if (line) console.log(` ${chalk.dim(line)}`);
452
+ console.log();
453
+ }
454
+ }
455
+
456
+ // SEO info
457
+ if (info.seo && typeof info.seo === "object" && Object.keys(info.seo).length > 0) {
458
+ if (info.seo.title || info.seo.description) {
459
+ console.log(` ${chalk.bold("SEO:")}`);
460
+ if (info.seo.title) console.log(` Title: ${info.seo.title}`);
461
+ if (info.seo.description) console.log(` Description: ${info.seo.description}`);
462
+ console.log();
463
+ }
464
+ }
465
+
466
+ // Any remaining fields not handled above (free-form catch-all)
467
+ const handledKeys = new Set([
468
+ "product_id", "title", "price", "list_price", "pricing", "rating",
469
+ "review_count", "brand", "marketplace", "availability", "prime",
470
+ "shipping", "delivery_info", "returns", "checkout", "sold_by",
471
+ "categories", "features", "description", "specifications",
472
+ "images", "about", "seo", "product_url", "asin", "error",
473
+ "available", "note", "updated_at", "sku", "model", "gtin",
474
+ "variant", "tags",
475
+ ]);
476
+
477
+ const extraKeys = Object.keys(info).filter((k: string) => !handledKeys.has(k));
478
+ if (extraKeys.length > 0) {
479
+ console.log(` ${chalk.bold("Additional Information:")}`);
480
+ for (const key of extraKeys) {
481
+ const value = info[key];
482
+ if (value == null) continue;
483
+ const label = key.replace(/_/g, " ").replace(/\b\w/g, (c: string) => c.toUpperCase());
484
+
485
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
486
+ console.log(` ${chalk.dim(label + ":")} ${value}`);
487
+ } else if (Array.isArray(value)) {
488
+ console.log(` ${chalk.dim(label + ":")}`);
489
+ renderFreeFormInfo(value, 10);
490
+ } else if (typeof value === "object") {
491
+ console.log(` ${chalk.dim(label + ":")}`);
492
+ renderFreeFormInfo(value, 10);
493
+ }
494
+ }
495
+ console.log();
496
+ }
497
+
498
+ // Tags
499
+ if (info.tags && Array.isArray(info.tags) && info.tags.length > 0) {
500
+ console.log(` ${chalk.bold("Tags:")} ${info.tags.map((t: string) => chalk.dim(`#${t}`)).join(" ")}`);
501
+ console.log();
502
+ }
503
+
504
+ // Note from store
505
+ if (info.note) {
506
+ console.log(` ${chalk.dim(`ℹ ${info.note}`)}`);
507
+ console.log();
508
+ }
509
+
510
+ // Separator between products
511
+ if (index < totalResults - 1) {
512
+ console.log(chalk.dim(" " + "─".repeat(60)));
513
+ console.log();
514
+ }
515
+ }
516
+
517
+ export function registerSearchCommands(program: Command): void {
518
+ // ── SEARCH ─────────────────────────────────────────────────────────
519
+ program
520
+ .command("search <query>")
521
+ .description("Search for products")
522
+
523
+ // Product match
524
+ .option("-c, --category <category>", "Filter by category")
525
+ .option("--brand <brand>", "Filter by brand")
526
+ .option("--model <model>", "Filter by model name/number")
527
+ .option("--sku <sku>", "Filter by SKU")
528
+ .option("--gtin <gtin>", "Filter by GTIN (UPC/EAN/ISBN)")
529
+ .option("--variant <variant>", "Filter by variant (size/color/storage/etc.)")
530
+
531
+ // Cost
532
+ .option("--min-price <price>", "Minimum price (cents)", parseFloat)
533
+ .option("--max-price <price>", "Maximum price (cents)", parseFloat)
534
+ .option("--max-shipping <price>", "Maximum shipping cost (cents)", parseInt)
535
+ .option("--max-total <price>", "Maximum landed total: item + shipping (cents)", parseInt)
536
+ .option("--free-shipping", "Only show items with free shipping")
537
+
538
+ // Delivery location
539
+ .option("--ship-to <address>", "Saved address label or ID (resolves country/city/postal automatically)")
540
+ .option("--country <code>", "Delivery country (ISO 3166-1 alpha-2, e.g. US, BE, NL)")
541
+ .option("--city <city>", "Delivery city")
542
+ .option("--postal-code <code>", "Delivery postal/zip code")
543
+ .option("--region <region>", "Delivery state/province/region")
544
+ .option("--lat <latitude>", "Delivery latitude (for local/proximity search)", parseFloat)
545
+ .option("--lng <longitude>", "Delivery longitude (for local/proximity search)", parseFloat)
546
+ .option("--deliver-by <date>", "Need delivery by date (YYYY-MM-DD)")
547
+ .option("--max-delivery-days <days>", "Maximum delivery/transit days", parseInt)
548
+
549
+ // Availability
550
+ .option("--in-stock", "Only show in-stock items")
551
+ .option("--exclude-backorder", "Exclude backordered items")
552
+ .option("--min-qty <qty>", "Minimum quantity available", parseInt)
553
+
554
+ // Returns
555
+ .option("--free-returns", "Only show items with free returns")
556
+ .option("--min-return-window-days <days>", "Minimum return window in days", parseInt)
557
+
558
+ // Trust / eligibility
559
+ .option("--store <store>", "Limit to a store (ID, slug, or name)")
560
+ .option("--vendor <vendor>", "Filter by vendor name (alias for --store)")
561
+ .option("--trusted-only", "Only show products from verified stores")
562
+ .option("--min-store-rating <rating>", "Minimum store rating (0-5)", parseFloat)
563
+ .option("--checkout-mode <mode>", "Checkout mode: instant, handoff")
564
+
565
+ // Rating / sorting / pagination
566
+ .option("--min-rating <rating>", "Minimum product rating (1-5)", parseFloat)
567
+ .option("-s, --sort <field>", "Sort by: price, total-cost, rating, relevance, newest, delivery", "relevance")
568
+ .option("--order <dir>", "Sort order: asc, desc", "desc")
569
+ .option("-p, --page <page>", "Page number", parseInt, 1)
570
+ .option("-n, --per-page <count>", "Results per page", parseInt, 10)
571
+
572
+ // Delivery shortcuts
573
+ .option("--express", "Only show items with 2-day or faster delivery")
574
+
575
+ // Extended search
576
+ .option("-e, --extended-search", "Enable extended search: query darkstores when no local results found")
577
+ .option("--no-extended-search", "Disable automatic extended search when no local results found")
578
+ .option("--extended-timeout <seconds>", "Extended search timeout in seconds (default: 30, max: 60)", parseInt)
579
+
580
+ // Output
581
+ .option("--json", "Output raw JSON")
582
+ .option("--compact", "Compact one-line-per-result output")
583
+ .option("--detailed", "Show full product details inline")
584
+
585
+ // Interactive
586
+ .option("-i, --interactive", "After results, interactively select products to get more info from their store")
587
+
588
+ .action(async (query: string, opts) => {
589
+ try {
590
+ const spinner = ora(`Searching for "${query}"...`).start();
591
+ const api = getApiClient();
592
+
593
+ // Compute --deliver-by → maxDeliveryDays if not explicitly set
594
+ let maxDeliveryDays = opts.maxDeliveryDays;
595
+ if (!maxDeliveryDays && opts.deliverBy) {
596
+ const target = new Date(opts.deliverBy);
597
+ const now = new Date();
598
+ const diff = Math.ceil((target.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
599
+ if (diff > 0) maxDeliveryDays = diff;
600
+ }
601
+
602
+ // Resolve --ship-to: if provided, fetch the saved address to extract location fields
603
+ let shipToCountry = opts.country || undefined;
604
+ let shipToCity = opts.city || undefined;
605
+ let shipToPostalCode = opts.postalCode || undefined;
606
+ let shipToRegion = opts.region || undefined;
607
+ let shipToLat = opts.lat || undefined;
608
+ let shipToLng = opts.lng || undefined;
609
+
610
+ if (opts.shipTo) {
611
+ try {
612
+ spinner.text = `Resolving address "${opts.shipTo}"...`;
613
+ const addrRes = await api.get("/addresses");
614
+ const addresses = addrRes.data.addresses || [];
615
+ // Match by label (case-insensitive) or by ID
616
+ const match = addresses.find((a: any) =>
617
+ a.id === opts.shipTo ||
618
+ (a.label && a.label.toLowerCase() === opts.shipTo.toLowerCase())
619
+ );
620
+ if (match) {
621
+ if (!shipToCountry) shipToCountry = match.country;
622
+ if (!shipToCity) shipToCity = match.city;
623
+ if (!shipToPostalCode) shipToPostalCode = match.postalCode;
624
+ if (!shipToRegion) shipToRegion = match.region;
625
+ } else {
626
+ spinner.warn(`Address "${opts.shipTo}" not found — ignoring --ship-to`);
627
+ spinner.start(`Searching for "${query}"...`);
628
+ }
629
+ } catch {
630
+ // Address lookup failed — continue without it
631
+ }
632
+ spinner.text = `Searching for "${query}"...`;
633
+ }
634
+
635
+ // Auto-resolve default address when no location flags are set
636
+ // Uses the active agent's default address so searches target the right region
637
+ if (!shipToCountry && !shipToCity && !shipToPostalCode && !opts.shipTo) {
638
+ const agent = getActiveAgent();
639
+ if (agent.defaultAddressId) {
640
+ try {
641
+ spinner.text = `Resolving default address...`;
642
+ const addrRes = await api.get("/addresses");
643
+ const addresses = addrRes.data.addresses || [];
644
+ const defaultAddr = addresses.find((a: any) => a.id === agent.defaultAddressId);
645
+ if (defaultAddr) {
646
+ shipToCountry = defaultAddr.country;
647
+ shipToCity = defaultAddr.city;
648
+ shipToPostalCode = defaultAddr.postalCode;
649
+ shipToRegion = defaultAddr.region || undefined;
650
+ spinner.text = `Searching for "${query}" (delivering to: ${[shipToCity, shipToCountry].filter(Boolean).join(", ")})...`;
651
+ }
652
+ } catch {
653
+ // Default address lookup failed — continue without it
654
+ }
655
+ }
656
+ }
657
+
658
+ // Extended search: clamp timeout to 5-60s, default 30s
659
+ // Extended search is enabled by default (auto-triggers when no results found)
660
+ // User can force it with -e, or disable it with --no-extended-search
661
+ const forceExtended = opts.extendedSearch === true;
662
+ const disableExtended = opts.extendedSearch === false;
663
+ const extendedTimeout = opts.extendedTimeout
664
+ ? Math.min(60, Math.max(5, opts.extendedTimeout))
665
+ : 30;
666
+
667
+ // --express shortcut → max 2-day delivery
668
+ if (opts.express && !maxDeliveryDays) {
669
+ maxDeliveryDays = 2;
670
+ }
671
+
672
+ // Build common search params (reused for both regular & extended calls)
673
+ const searchParams: Record<string, any> = {
674
+ q: query,
675
+ // Product match
676
+ category: opts.category,
677
+ brand: opts.brand,
678
+ model: opts.model,
679
+ sku: opts.sku,
680
+ gtin: opts.gtin,
681
+ variant: opts.variant,
682
+ // Cost
683
+ minPrice: opts.minPrice,
684
+ maxPrice: opts.maxPrice,
685
+ maxShipping: opts.maxShipping,
686
+ maxTotal: opts.maxTotal,
687
+ freeShipping: opts.freeShipping || undefined,
688
+ // Delivery location
689
+ shipTo: opts.shipTo || undefined,
690
+ country: shipToCountry,
691
+ city: shipToCity,
692
+ postalCode: shipToPostalCode,
693
+ region: shipToRegion,
694
+ lat: shipToLat,
695
+ lng: shipToLng,
696
+ maxDeliveryDays: maxDeliveryDays,
697
+ // Availability
698
+ inStock: opts.inStock || undefined,
699
+ excludeBackorder: opts.excludeBackorder || undefined,
700
+ minQty: opts.minQty,
701
+ // Returns
702
+ freeReturns: opts.freeReturns || undefined,
703
+ minReturnDays: opts.minReturnWindowDays,
704
+ // Trust
705
+ store: opts.store,
706
+ vendor: opts.vendor,
707
+ trustedOnly: opts.trustedOnly || undefined,
708
+ minStoreRating: opts.minStoreRating,
709
+ checkoutMode: opts.checkoutMode,
710
+ // Rating / sorting / pagination
711
+ minRating: opts.minRating,
712
+ sort: opts.sort === "total-cost" ? "price" : opts.sort, // backend doesn't know total-cost yet
713
+ order: opts.order,
714
+ page: opts.page,
715
+ pageSize: opts.perPage,
716
+ };
717
+
718
+ // If user forced extended search (-e), include it in the first call
719
+ if (forceExtended) {
720
+ searchParams.extendedSearch = true;
721
+ searchParams.extendedTimeout = extendedTimeout;
722
+ }
723
+
724
+ const httpTimeout = forceExtended ? (extendedTimeout + 5) * 1000 : 15000;
725
+
726
+ if (forceExtended) {
727
+ const locationParts = [shipToCountry, shipToCity, shipToPostalCode].filter(Boolean);
728
+ const locationLabel = locationParts.length > 0 ? locationParts.join(", ") : "global";
729
+ spinner.text = `Searching for "${query}" (extended search: ${extendedTimeout}s timeout, deliver to: ${locationLabel})...`;
730
+ }
731
+
732
+ let res = await api.get("/products/search", {
733
+ params: searchParams,
734
+ timeout: httpTimeout,
735
+ });
736
+
737
+ // ── Auto-trigger extended search when no results found ──
738
+ // If the regular search found nothing and extended search wasn't disabled,
739
+ // automatically run the extended search to query all registered stores.
740
+ const regularResult: SearchResult = res.data;
741
+ const regularExtended = (res.data as any).extended;
742
+
743
+ if (
744
+ regularResult.products.length === 0 &&
745
+ (!regularExtended || regularExtended.total === 0) &&
746
+ !forceExtended &&
747
+ !disableExtended
748
+ ) {
749
+ spinner.text = `No local results for "${query}". Starting extended search across all stores (${extendedTimeout}s timeout)...`;
750
+
751
+ try {
752
+ res = await api.get("/products/search", {
753
+ params: {
754
+ ...searchParams,
755
+ extendedSearch: true,
756
+ extendedTimeout,
757
+ },
758
+ timeout: (extendedTimeout + 5) * 1000,
759
+ });
760
+ } catch (extErr) {
761
+ // If extended search fails (e.g. timeout), continue with empty results
762
+ spinner.warn(`Extended search failed — showing regular results only.`);
763
+ }
764
+ }
765
+
766
+ spinner.stop();
767
+
768
+ const result: SearchResult = res.data;
769
+
770
+ if (opts.json) {
771
+ console.log(JSON.stringify(result, null, 2));
772
+ return;
773
+ }
774
+
775
+ // Check for extended search results
776
+ const extended = (res.data as any).extended;
777
+ const suggestAdvertise = (res.data as any).suggestAdvertise;
778
+ const didExtendedSearch = forceExtended || (res.data as any).extended != null;
779
+
780
+ if (result.products.length === 0 && (!extended || extended.total === 0)) {
781
+ if (didExtendedSearch) {
782
+ console.log(chalk.yellow(`\nNo results found for "${query}" (searched local catalog + all vendor stores).`));
783
+ } else {
784
+ console.log(chalk.yellow(`\nNo results found for "${query}".`));
785
+ }
786
+
787
+ if (!didExtendedSearch && disableExtended) {
788
+ console.log(
789
+ chalk.dim("\n 🔍 Tip: ") +
790
+ chalk.white("Extended search was disabled. Enable it to query vendor stores in real-time:") +
791
+ chalk.dim(`\n Run: `) +
792
+ chalk.cyan(`clishop search "${query}" --extended-search`) +
793
+ chalk.dim("\n")
794
+ );
795
+ }
796
+
797
+ console.log(
798
+ chalk.dim(" 💡 Tip: ") +
799
+ chalk.white("Can't find what you need? Advertise your request and let vendors come to you!") +
800
+ chalk.dim(`\n Run: `) +
801
+ chalk.cyan(`clishop advertise create`) +
802
+ chalk.dim(` or `) +
803
+ chalk.cyan(`clishop advertise quick "${query}"`) +
804
+ chalk.dim("\n")
805
+ );
806
+ return;
807
+ }
808
+
809
+ // ── Fetch exchange rates for currency conversion ──────────
810
+ // Determine user's preferred currency from their country
811
+ const COUNTRY_CURRENCY: Record<string, string> = {
812
+ US: "USD", CA: "CAD", GB: "GBP", AU: "AUD", NZ: "NZD",
813
+ EU: "EUR", DE: "EUR", FR: "EUR", IT: "EUR", ES: "EUR", NL: "EUR",
814
+ BE: "EUR", AT: "EUR", IE: "EUR", PT: "EUR", FI: "EUR", GR: "EUR",
815
+ LU: "EUR", SK: "EUR", SI: "EUR", EE: "EUR", LV: "EUR", LT: "EUR",
816
+ SE: "SEK", NO: "NOK", DK: "DKK", PL: "PLN", CZ: "CZK",
817
+ CH: "CHF", HU: "HUF", RO: "RON", BG: "BGN", HR: "EUR",
818
+ JP: "JPY", CN: "CNY", KR: "KRW", IN: "INR", SG: "SGD",
819
+ TH: "THB", AE: "AED", IL: "ILS", TR: "TRY", ZA: "ZAR",
820
+ BR: "BRL", MX: "MXN", AR: "ARS", CO: "COP", CL: "CLP",
821
+ };
822
+ const userCurrency = shipToCountry ? (COUNTRY_CURRENCY[shipToCountry.toUpperCase()] || "EUR") : "EUR";
823
+ let exchangeRates: Record<string, number> = {};
824
+ try {
825
+ exchangeRates = await fetchRates(userCurrency);
826
+ } catch {
827
+ // Non-critical — conversion just won't show
828
+ }
829
+
830
+ // ── Merge all products for display ──────────────────────────
831
+ // Combine local + extended into a unified list for rendering
832
+ type DisplayProduct = {
833
+ name: string; priceInCents: number; currency: string;
834
+ freeShipping: boolean; shippingPriceInCents?: number | null; shippingDays?: number | null;
835
+ vendor: string; storeVerified?: boolean; storeRating?: number | null;
836
+ brand?: string | null; category?: string | null;
837
+ rating?: number; reviewCount?: number;
838
+ variant?: string | null; variantLabel?: string | null;
839
+ description?: string | null; id?: string;
840
+ isExtended?: boolean;
841
+ };
842
+
843
+ const allProducts: DisplayProduct[] = [];
844
+
845
+ for (const p of result.products) {
846
+ allProducts.push({
847
+ ...p,
848
+ vendor: p.vendor || p.storeName || "Unknown",
849
+ isExtended: false,
850
+ });
851
+ }
852
+
853
+ if (extended?.products) {
854
+ for (const ep of extended.products) {
855
+ allProducts.push({
856
+ name: ep.name,
857
+ priceInCents: ep.priceInCents,
858
+ currency: ep.currency,
859
+ freeShipping: ep.freeShipping,
860
+ shippingPriceInCents: ep.shippingPriceInCents,
861
+ shippingDays: ep.shippingDays,
862
+ vendor: ep.storeName || "Unknown",
863
+ storeRating: ep.storeRating ?? null,
864
+ storeVerified: ep.storeVerified ?? false,
865
+ brand: ep.brand,
866
+ variant: ep.variant,
867
+ variantLabel: ep.variantLabel,
868
+ description: ep.description,
869
+ id: ep.id,
870
+ isExtended: true,
871
+ });
872
+ }
873
+ }
874
+
875
+ // Client-side sort for total-cost (backend doesn't support this directly)
876
+ if (opts.sort === "total-cost") {
877
+ allProducts.sort((a, b) => {
878
+ const aCost = a.priceInCents + (a.freeShipping ? 0 : (a.shippingPriceInCents ?? 0));
879
+ const bCost = b.priceInCents + (b.freeShipping ? 0 : (b.shippingPriceInCents ?? 0));
880
+ return opts.order === "desc" ? bCost - aCost : aCost - bCost;
881
+ });
882
+ }
883
+
884
+ if (allProducts.length === 0) {
885
+ // Already handled above
886
+ } else {
887
+ // Header
888
+ const totalCount = result.total + (extended?.total || 0);
889
+ if (result.products.length > 0 && extended?.total > 0) {
890
+ console.log(chalk.bold(`\nResults for "${query}" — ${result.total} local + ${extended.total} from stores\n`));
891
+ } else if (extended?.total > 0) {
892
+ console.log(chalk.bold(`\nExtended search for "${query}" — ${extended.total} result(s) from ${extended.storesResponded} store(s)\n`));
893
+ } else {
894
+ console.log(chalk.bold(`\nResults for "${query}" — ${result.total} found (page ${result.page})\n`));
895
+ }
896
+
897
+ // ── Price comparison summary ──
898
+ if (allProducts.length >= 2) {
899
+ const withTotal = allProducts.map((p) => ({
900
+ ...p,
901
+ totalCost: p.priceInCents + (p.freeShipping ? 0 : (p.shippingPriceInCents ?? 0)),
902
+ }));
903
+ const cheapest = withTotal.reduce((a, b) => a.totalCost < b.totalCost ? a : b);
904
+ const fastest = allProducts.filter((p) => p.shippingDays != null).sort((a, b) => (a.shippingDays ?? 99) - (b.shippingDays ?? 99))[0];
905
+ const bestRated = allProducts.filter((p) => (p.rating ?? 0) > 0).sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0))[0];
906
+
907
+ const parts: string[] = [];
908
+ parts.push(`${chalk.green("Best price:")} ${formatPrice(cheapest.totalCost, cheapest.currency)} at ${cheapest.vendor}`);
909
+ if (fastest?.shippingDays != null) {
910
+ parts.push(`${chalk.blue("Fastest:")} ${deliveryLabel(fastest.shippingDays)} at ${fastest.vendor}`);
911
+ }
912
+ if (bestRated?.rating) {
913
+ parts.push(`${chalk.yellow("Top rated:")} ${scoreOutOf10(bestRated.rating)}/10 at ${bestRated.vendor}`);
914
+ }
915
+ console.log(` ${chalk.dim("┌")} ${parts.join(chalk.dim(" │ "))}`);
916
+
917
+ // Price range
918
+ const prices = withTotal.map((p) => p.totalCost);
919
+ const minP = Math.min(...prices);
920
+ const maxP = Math.max(...prices);
921
+ const avgP = Math.round(prices.reduce((a, b) => a + b, 0) / prices.length);
922
+ const curr = allProducts[0].currency;
923
+ console.log(` ${chalk.dim("└")} ${chalk.dim(`Price range: ${formatPrice(minP, curr)} – ${formatPrice(maxP, curr)} · Average: ${formatPrice(avgP, curr)}`)}`);
924
+ console.log();
925
+ }
926
+
927
+ // ── Render each product ──
928
+ for (let i = 0; i < allProducts.length; i++) {
929
+ const p = allProducts[i];
930
+ const num = i + 1;
931
+ const itemPrice = p.priceInCents;
932
+ const shippingPrice = p.freeShipping ? 0 : (p.shippingPriceInCents ?? 0);
933
+ const totalCost = itemPrice + shippingPrice;
934
+
935
+ // Badges
936
+ const badges: string[] = [];
937
+ if (i === 0) badges.push(chalk.bgGreen.black(" BEST MATCH "));
938
+ // Best value = lowest total cost
939
+ const allCosts = allProducts.map((x) => x.priceInCents + (x.freeShipping ? 0 : (x.shippingPriceInCents ?? 0)));
940
+ if (totalCost === Math.min(...allCosts) && i !== 0) badges.push(chalk.bgYellow.black(" BEST VALUE "));
941
+
942
+ // Currency conversion hint (only when product currency differs from user's)
943
+ const converted = formatConverted(totalCost, p.currency, userCurrency, exchangeRates);
944
+
945
+ // ── Compact mode ──
946
+ if (opts.compact) {
947
+ const priceStr = formatPrice(totalCost, p.currency) + converted;
948
+ const store = p.vendor;
949
+ const delivery = p.shippingDays != null ? `Arrives ${estimatedArrival(p.shippingDays)}` : "";
950
+ console.log(
951
+ ` ${chalk.dim(`[${num}]`)} ${chalk.cyan(p.name.length > 60 ? p.name.slice(0, 57) + "..." : p.name)} ` +
952
+ `${chalk.bold.white(priceStr)} ${chalk.dim(store)}${delivery ? " " + chalk.dim(delivery) : ""}` +
953
+ (badges.length ? " " + badges.join(" ") : "")
954
+ );
955
+ continue;
956
+ }
957
+
958
+ // ── Normal / Detailed mode ──
959
+ // Number + Title + badges
960
+ console.log(` ${chalk.dim(`[${num}]`)} ${chalk.bold.cyan(p.name)}${badges.length ? " " + badges.join(" ") : ""}`);
961
+
962
+ // Price line with currency conversion
963
+ let priceLine = ` ${chalk.bold.white(formatPrice(itemPrice, p.currency))}`;
964
+ if (p.freeShipping) {
965
+ priceLine += chalk.green(" Free Shipping");
966
+ } else if (p.shippingPriceInCents != null && (p.shippingPriceInCents ?? 0) > 0) {
967
+ priceLine += chalk.dim(` + ${formatPrice(shippingPrice, p.currency)} shipping`);
968
+ priceLine += chalk.bold(` = ${formatPrice(totalCost, p.currency)}`);
969
+ }
970
+ // Currency conversion
971
+ priceLine += converted;
972
+ // Delivery date
973
+ if (p.shippingDays != null) {
974
+ priceLine += chalk.blue(` Arrives ${estimatedArrival(p.shippingDays)}`);
975
+ }
976
+ console.log(priceLine);
977
+
978
+ // Store & ratings
979
+ const meta: string[] = [];
980
+ const storeBadge = p.storeVerified ? chalk.green(" ✓") : "";
981
+ const storeScore = p.storeRating != null
982
+ ? chalk.dim(` ${p.storeRating.toFixed(1)}/10`)
983
+ : chalk.dim(" (no store rating)");
984
+ meta.push(`${p.vendor}${storeBadge}${storeScore}`);
985
+ if (p.brand) meta.push(p.brand);
986
+ if (p.rating && p.rating > 0) {
987
+ meta.push(chalk.yellow(`${scoreOutOf10(p.rating)}/10`) + (p.reviewCount ? chalk.dim(` (${p.reviewCount})`) : ""));
988
+ }
989
+ if (p.isExtended) meta.push(chalk.magenta("via extended search"));
990
+ console.log(` ${chalk.dim(meta.join(" · "))}`);
991
+ // Product ID
992
+ if (p.id) console.log(` ${chalk.dim(`ID: ${p.id}`)}`);;
993
+
994
+ // Detailed mode: extra info
995
+ if (opts.detailed) {
996
+ if (p.category) console.log(` ${chalk.dim(`Category: ${p.category}`)}`);
997
+ if (p.variant || p.variantLabel) console.log(` ${chalk.dim(`Variant: ${p.variant || p.variantLabel}`)}`);
998
+ if (p.description) {
999
+ console.log(` ${chalk.dim(p.description.length > 200 ? p.description.slice(0, 200) + "..." : p.description)}`);
1000
+ }
1001
+ } else {
1002
+ // Normal mode: short description
1003
+ if (p.description) {
1004
+ console.log(` ${chalk.dim(p.description.length > 80 ? p.description.slice(0, 80) + "..." : p.description)}`);
1005
+ }
1006
+ }
1007
+ console.log();
1008
+ }
1009
+ }
1010
+
1011
+ // ── Interactive selection mode ──────────────────────────────
1012
+ // When --interactive / -i is used and there are extended results,
1013
+ // prompt the user to select products and fetch detailed info.
1014
+ const hasExtendedProducts = extended?.products?.length > 0;
1015
+
1016
+ if (opts.interactive && allProducts.length > 0) {
1017
+ // Build selectable choices from all products that have an ID
1018
+ // (both local products and extended products can be selected,
1019
+ // but only extended products can be queried for store info)
1020
+ const selectableProducts = allProducts
1021
+ .map((p, idx) => ({ product: p, index: idx }))
1022
+ .filter((item) => item.product.id);
1023
+
1024
+ if (selectableProducts.length > 0) {
1025
+ console.log(chalk.dim(" ─".repeat(30)));
1026
+ console.log();
1027
+
1028
+ const choices = selectableProducts.map((item) => {
1029
+ const p = item.product;
1030
+ const priceStr = formatPrice(p.priceInCents, p.currency);
1031
+ const storeStr = p.vendor || "Unknown";
1032
+ const extLabel = p.isExtended ? chalk.magenta(" [store]") : chalk.dim(" [local]");
1033
+ return {
1034
+ name: `${chalk.cyan(p.name.length > 50 ? p.name.slice(0, 47) + "..." : p.name)} ${chalk.bold(priceStr)} ${chalk.dim(storeStr)}${extLabel}`,
1035
+ value: item.product.id,
1036
+ short: p.name.length > 40 ? p.name.slice(0, 37) + "..." : p.name,
1037
+ };
1038
+ });
1039
+
1040
+ const { selectedIds } = await inquirer.prompt([
1041
+ {
1042
+ type: "checkbox",
1043
+ name: "selectedIds",
1044
+ message: "Select product(s) to request more information from their store:",
1045
+ choices,
1046
+ pageSize: 15,
1047
+ },
1048
+ ]);
1049
+
1050
+ if (selectedIds && selectedIds.length > 0) {
1051
+ // Separate extended vs local product IDs
1052
+ const extendedIds = selectedIds.filter((id: string) =>
1053
+ allProducts.find((p) => p.id === id && p.isExtended)
1054
+ );
1055
+ const localIds = selectedIds.filter((id: string) =>
1056
+ allProducts.find((p) => p.id === id && !p.isExtended)
1057
+ );
1058
+
1059
+ if (extendedIds.length > 0) {
1060
+ const infoSpinner = ora(`Requesting detailed info for ${extendedIds.length} product(s) from their stores...`).start();
1061
+
1062
+ try {
1063
+ const infoRes = await api.post("/products/info", {
1064
+ productIds: extendedIds,
1065
+ }, {
1066
+ timeout: 30000,
1067
+ });
1068
+
1069
+ infoSpinner.stop();
1070
+
1071
+ const { results: infoResults } = infoRes.data;
1072
+
1073
+ if (infoResults && infoResults.length > 0) {
1074
+ console.log(chalk.bold(`\n Store Information — ${infoResults.length} result(s)\n`));
1075
+
1076
+ for (let i = 0; i < infoResults.length; i++) {
1077
+ const infoResult = infoResults[i];
1078
+ renderProductInfo(infoResult, i, infoResults.length, formatPrice);
1079
+ }
1080
+ } else {
1081
+ console.log(chalk.yellow("\n No additional information returned from the stores."));
1082
+ }
1083
+ } catch (infoErr) {
1084
+ infoSpinner.stop();
1085
+ console.error(chalk.red("\n Failed to fetch product info from stores."));
1086
+ }
1087
+ }
1088
+
1089
+ if (localIds.length > 0) {
1090
+ // For local products, fetch details from the backend directly
1091
+ for (const localId of localIds) {
1092
+ const detailSpinner = ora(`Fetching details for ${localId}...`).start();
1093
+ try {
1094
+ const detailRes = await api.get(`/products/${localId}`);
1095
+ detailSpinner.stop();
1096
+ const p = detailRes.data.product;
1097
+ if (p) {
1098
+ console.log();
1099
+ console.log(` ${chalk.bold.cyan(p.name)}`);
1100
+ console.log(` ${chalk.dim(`ID: ${p.id}`)}`);
1101
+ if (p.brand) console.log(` ${chalk.bold("Brand:")} ${p.brand}`);
1102
+ if (p.model) console.log(` ${chalk.bold("Model:")} ${p.model}`);
1103
+ if (p.sku) console.log(` ${chalk.bold("SKU:")} ${p.sku}`);
1104
+ console.log(` ${chalk.bold("Price:")} ${chalk.bold.white(formatPrice(p.priceInCents, p.currency))}`);
1105
+ const status = p.inStock ? chalk.green("In Stock") : chalk.red("Out of Stock");
1106
+ console.log(` ${chalk.bold("Availability:")} ${status}${p.stockQuantity != null ? chalk.dim(` (${p.stockQuantity} available)`) : ""}`);
1107
+ if (p.freeShipping) console.log(` ${chalk.bold("Shipping:")} ${chalk.green("Free")}`);
1108
+ else if (p.shippingPriceInCents != null) console.log(` ${chalk.bold("Shipping:")} ${formatPrice(p.shippingPriceInCents, p.currency)}`);
1109
+ if (p.shippingDays != null) console.log(` ${chalk.bold("Delivery:")} ${deliveryLabel(p.shippingDays)}`);
1110
+ if (p.freeReturns) console.log(` ${chalk.bold("Returns:")} ${chalk.green("Free Returns")}${p.returnWindowDays ? ` · ${p.returnWindowDays}-day window` : ""}`);
1111
+ if (p.description) {
1112
+ console.log(` ${chalk.bold("Description:")}`);
1113
+ const words = p.description.split(/\s+/);
1114
+ let line = "";
1115
+ for (const word of words) {
1116
+ if (line.length + word.length + 1 > 76) {
1117
+ console.log(` ${chalk.dim(line)}`);
1118
+ line = word;
1119
+ } else {
1120
+ line = line ? `${line} ${word}` : word;
1121
+ }
1122
+ }
1123
+ if (line) console.log(` ${chalk.dim(line)}`);
1124
+ }
1125
+ console.log();
1126
+ }
1127
+ } catch {
1128
+ detailSpinner.stop();
1129
+ }
1130
+ }
1131
+ }
1132
+
1133
+ console.log();
1134
+ }
1135
+ }
1136
+ } else if (hasExtendedProducts && !opts.interactive) {
1137
+ // Show "info" tip when there are extended results and interactive mode is off
1138
+ const sampleIds = extended.products.slice(0, 2).map((ep: any) => ep.id).join(" ");
1139
+ console.log(
1140
+ chalk.dim(" ℹ️ Tip: ") +
1141
+ chalk.white("Want more details? Request info from the store:") +
1142
+ chalk.dim(`\n Run: `) +
1143
+ chalk.cyan(`clishop info ${sampleIds}`) +
1144
+ chalk.dim(` or use `) +
1145
+ chalk.cyan(`--interactive`) +
1146
+ chalk.dim(` to select interactively`) +
1147
+ chalk.dim("\n")
1148
+ );
1149
+ }
1150
+
1151
+ const totalPages = Math.ceil(result.total / result.pageSize);
1152
+ if (totalPages > 1) {
1153
+ console.log(chalk.dim(` Page ${result.page} of ${totalPages}. Use --page to navigate.\n`));
1154
+ }
1155
+
1156
+ // Show advertise tip on the last page of results
1157
+ const showAdvertiseTip = result.page >= totalPages || suggestAdvertise;
1158
+ if (showAdvertiseTip) {
1159
+ // If no extended search was used and it was explicitly disabled, suggest it
1160
+ if (!didExtendedSearch && disableExtended && result.products.length > 0) {
1161
+ console.log(
1162
+ chalk.dim(" 🔍 Tip: ") +
1163
+ chalk.white("Want more results? Extended search was disabled. Enable it:") +
1164
+ chalk.dim(`\n Run: `) +
1165
+ chalk.cyan(`clishop search "${query}" --extended-search`) +
1166
+ chalk.dim("\n")
1167
+ );
1168
+ }
1169
+
1170
+ console.log(
1171
+ chalk.dim(" 💡 Tip: ") +
1172
+ chalk.white("Didn't find the right match? Advertise your request for vendors to bid on.") +
1173
+ chalk.dim(`\n Run: `) +
1174
+ chalk.cyan(`clishop advertise create`) +
1175
+ chalk.dim(` or `) +
1176
+ chalk.cyan(`clishop advertise quick "${query}"`) +
1177
+ chalk.dim("\n")
1178
+ );
1179
+ }
1180
+ } catch (error) {
1181
+ handleApiError(error);
1182
+ }
1183
+ });
1184
+
1185
+ // ── PRODUCT DETAIL ─────────────────────────────────────────────────
1186
+ program
1187
+ .command("product <id>")
1188
+ .description("View detailed product information")
1189
+ .option("--json", "Output raw JSON")
1190
+ .action(async (id: string, opts) => {
1191
+ try {
1192
+ const spinner = ora("Fetching product details...").start();
1193
+ const api = getApiClient();
1194
+ const res = await api.get(`/products/${id}`);
1195
+ spinner.stop();
1196
+
1197
+ const p: Product = res.data.product;
1198
+
1199
+ if (opts.json) {
1200
+ console.log(JSON.stringify(p, null, 2));
1201
+ return;
1202
+ }
1203
+
1204
+ const storeBadge = p.storeVerified ? chalk.green(" ✓ Verified") : "";
1205
+
1206
+ console.log();
1207
+ console.log(chalk.bold.cyan(` ${p.name}`));
1208
+ console.log(chalk.dim(` ID: ${p.id}`));
1209
+ if (p.brand) console.log(chalk.dim(` Brand: ${p.brand}`));
1210
+ if (p.model) console.log(chalk.dim(` Model: ${p.model}`));
1211
+ if (p.variant) console.log(chalk.dim(` Variant: ${p.variant}`));
1212
+ if (p.sku) console.log(chalk.dim(` SKU: ${p.sku}`));
1213
+ if (p.gtin) console.log(chalk.dim(` GTIN: ${p.gtin}`));
1214
+ console.log();
1215
+
1216
+ // Price & shipping
1217
+ console.log(` Price: ${chalk.bold(formatPrice(p.priceInCents, p.currency))}`);
1218
+ if (p.freeShipping) {
1219
+ console.log(` Shipping: ${chalk.green("Free")}`);
1220
+ } else if (p.shippingPriceInCents != null) {
1221
+ console.log(` Shipping: ${formatPrice(p.shippingPriceInCents, p.currency)}`);
1222
+ }
1223
+ if (p.shippingPriceInCents != null || p.freeShipping) {
1224
+ const total = p.priceInCents + (p.freeShipping ? 0 : (p.shippingPriceInCents ?? 0));
1225
+ console.log(` Total: ${chalk.bold(formatPrice(total, p.currency))}`);
1226
+ }
1227
+
1228
+ // Availability
1229
+ const status = p.inStock
1230
+ ? chalk.green("In Stock")
1231
+ : p.backorder
1232
+ ? chalk.yellow("Backorder")
1233
+ : chalk.red("Out of Stock");
1234
+ console.log(` Status: ${status}${p.stockQuantity != null ? chalk.dim(` (${p.stockQuantity} available)`) : ""}`);
1235
+
1236
+ // Delivery
1237
+ if (p.shippingDays != null) console.log(` Delivery: ${deliveryLabel(p.shippingDays)}`);
1238
+
1239
+ // Rating
1240
+ console.log(` Rating: ${chalk.yellow(renderStars(p.rating))} ${chalk.dim(`(${p.reviewCount} reviews)`)}`);
1241
+ console.log(` Category: ${p.category}`);
1242
+ console.log(` Store: ${p.vendor}${storeBadge}${p.storeRating != null ? chalk.dim(` (${p.storeRating.toFixed(1)} store rating)`) : ""}`);
1243
+
1244
+ // Returns
1245
+ const returnParts: string[] = [];
1246
+ if (p.freeReturns) returnParts.push(chalk.green("Free Returns"));
1247
+ if (p.returnWindowDays) returnParts.push(`${p.returnWindowDays}-day return window`);
1248
+ if (returnParts.length) console.log(` Returns: ${returnParts.join(" · ")}`);
1249
+
1250
+ // Checkout mode
1251
+ if (p.checkoutMode && p.checkoutMode !== "instant") {
1252
+ console.log(` Checkout: ${chalk.yellow(p.checkoutMode)}`);
1253
+ }
1254
+
1255
+ console.log();
1256
+ console.log(` ${p.description}`);
1257
+ console.log();
1258
+ } catch (error) {
1259
+ handleApiError(error);
1260
+ }
1261
+ });
1262
+
1263
+ // ── INFO — Request detailed product information from stores ────────
1264
+ // After a search returns results, users can request more info about
1265
+ // specific products. The backend proxies the request to the originating
1266
+ // store, which can return ANY information it wants (free-form).
1267
+ program
1268
+ .command("info <ids...>")
1269
+ .description("Request detailed information about search result products from their stores")
1270
+ .option("--json", "Output raw JSON")
1271
+ .action(async (ids: string[], opts) => {
1272
+ try {
1273
+ if (ids.length === 0) {
1274
+ console.error(chalk.red("\n✗ Please provide one or more product IDs."));
1275
+ console.log(chalk.dim(" Usage: clishop info <product-id> [product-id...]"));
1276
+ console.log(chalk.dim(" Example: clishop info ep_abc123 ep_def456\n"));
1277
+ process.exit(1);
1278
+ }
1279
+
1280
+ if (ids.length > 20) {
1281
+ console.error(chalk.red("\n✗ Maximum 20 products per request."));
1282
+ process.exit(1);
1283
+ }
1284
+
1285
+ const spinner = ora(`Requesting detailed info for ${ids.length} product(s)...`).start();
1286
+ const api = getApiClient();
1287
+
1288
+ const res = await api.post("/products/info", {
1289
+ productIds: ids,
1290
+ }, {
1291
+ timeout: 30000,
1292
+ });
1293
+
1294
+ spinner.stop();
1295
+
1296
+ const { results, total } = res.data;
1297
+
1298
+ if (opts.json) {
1299
+ console.log(JSON.stringify(res.data, null, 2));
1300
+ return;
1301
+ }
1302
+
1303
+ if (!results || results.length === 0) {
1304
+ console.log(chalk.yellow("\nNo information returned for the requested products."));
1305
+ console.log(chalk.dim(" Make sure you're using product IDs from extended search results (ep_...)."));
1306
+ console.log(chalk.dim(" Product IDs are shown after each search result.\n"));
1307
+ return;
1308
+ }
1309
+
1310
+ console.log(chalk.bold(`\nProduct Information — ${total} result(s)\n`));
1311
+
1312
+ for (let i = 0; i < results.length; i++) {
1313
+ renderProductInfo(results[i], i, results.length, formatPrice);
1314
+ }
1315
+ } catch (error) {
1316
+ handleApiError(error);
1317
+ }
1318
+ });
1319
+ }