glance-cli 0.13.0 → 0.14.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,854 @@
1
+ /**
2
+ * Command handlers for Glance CLI
3
+ * Exports individual command functions that can be used programmatically
4
+ */
5
+
6
+ import { writeFile } from "node:fs/promises";
7
+ import chalk from "chalk";
8
+ import clipboard from "clipboardy";
9
+ import {
10
+ extractCleanText,
11
+ extractLinks,
12
+ extractMetadata,
13
+ } from "../core/extractor";
14
+ import { fetchPage } from "../core/fetcher";
15
+ import { formatOutput } from "../core/formatter";
16
+ import {
17
+ detectLanguage,
18
+ shouldAutoDetectLanguage,
19
+ } from "../core/language-detector";
20
+ import { takeScreenshot } from "../core/screenshot";
21
+ import { getDefaultModel, showCostWarning } from "../core/service-detector";
22
+ import { detectProvider, summarize } from "../core/summarizer";
23
+ import { sanitizeAIResponse } from "../core/text-cleaner";
24
+ import { cleanTextForSpeech, createVoiceSynthesizer } from "../core/voice";
25
+ import { CONFIG, LANGUAGE_MAP } from "./config";
26
+ import { showServiceStatus } from "./display";
27
+ import { ErrorCodes, GlanceError } from "./errors";
28
+ import { logger } from "./logger";
29
+ import type { ServiceStatus } from "./types";
30
+ import {
31
+ createSpinner,
32
+ formatFileSize,
33
+ getFileExtension,
34
+ sanitizeOutputForTerminal,
35
+ withRetry,
36
+ } from "./utils";
37
+ import { validateAPIKeys } from "./validators";
38
+
39
+ export interface GlanceOptions {
40
+ url?: string;
41
+ model?: string;
42
+ language?: string;
43
+ tldr?: boolean;
44
+ keyPoints?: boolean;
45
+ eli5?: boolean;
46
+ full?: boolean;
47
+ customQuestion?: string;
48
+ stream?: boolean;
49
+ maxTokens?: number;
50
+ format?: string;
51
+ output?: string;
52
+ screenshot?: string;
53
+ fullRender?: boolean;
54
+ metadata?: boolean;
55
+ links?: boolean;
56
+ read?: boolean;
57
+ voice?: string;
58
+ audioOutput?: string;
59
+ listVoices?: boolean;
60
+ checkServices?: boolean;
61
+ freeOnly?: boolean;
62
+ preferQuality?: boolean;
63
+ debug?: boolean;
64
+ copy?: boolean;
65
+ }
66
+
67
+ /**
68
+ * Main glance command - fetch and summarize a webpage
69
+ */
70
+ export async function glance(
71
+ url: string,
72
+ options: GlanceOptions = {},
73
+ ): Promise<string> {
74
+ const startTime = Date.now();
75
+
76
+ // Set debug logging if requested
77
+ if (options.debug) {
78
+ logger.setLevel("debug");
79
+ }
80
+
81
+ // Language will be determined after fetching content
82
+ let language: string = options.language || "en";
83
+ let languageName =
84
+ LANGUAGE_MAP[language as keyof typeof LANGUAGE_MAP] || "English";
85
+
86
+ // Note: Caching temporarily disabled to eliminate corruption issues
87
+
88
+ // Fetch the webpage
89
+ const fetchSpinner = createSpinner("Fetching webpage...");
90
+ fetchSpinner.start();
91
+
92
+ let html: string;
93
+ try {
94
+ html = await withRetry(
95
+ () => fetchPage(url, { fullRender: options.fullRender }),
96
+ {
97
+ onRetry: (attempt, _error) => {
98
+ fetchSpinner.text = `Fetching webpage... (retry ${attempt})`;
99
+ },
100
+ },
101
+ );
102
+ fetchSpinner.succeed("Webpage fetched successfully");
103
+ } catch (error: unknown) {
104
+ fetchSpinner.fail("Failed to fetch webpage");
105
+ throw new GlanceError(
106
+ error instanceof Error ? error.message : String(error),
107
+ ErrorCodes.FETCH_FAILED,
108
+ "Could not fetch the webpage. Please check the URL and your internet connection.",
109
+ true,
110
+ "Try again or use --full-render for JavaScript-heavy sites",
111
+ );
112
+ }
113
+
114
+ // Extract content
115
+ const extractSpinner = createSpinner("Extracting content...");
116
+ extractSpinner.start();
117
+
118
+ const cleanText = extractCleanText(html);
119
+
120
+ // Auto-detect language if not specified by user
121
+ if (!options.language && shouldAutoDetectLanguage()) {
122
+ const detectionResult = detectLanguage(
123
+ url,
124
+ html,
125
+ cleanText,
126
+ options.language,
127
+ );
128
+ language = detectionResult.detected;
129
+ languageName =
130
+ LANGUAGE_MAP[language as keyof typeof LANGUAGE_MAP] || "English";
131
+
132
+ // Show detection info to user if confidence is not high
133
+ if (
134
+ detectionResult.confidence !== "high" &&
135
+ detectionResult.source !== "default"
136
+ ) {
137
+ logger.info(`Auto-detected language: ${languageName}`);
138
+ }
139
+
140
+ // Log detection result if in debug mode
141
+ if (options.debug) {
142
+ logger.debug(
143
+ `Language detected: ${language} (${detectionResult.confidence} confidence from ${detectionResult.source})`,
144
+ );
145
+ logger.debug(`Detection signals: ${detectionResult.signals.join(", ")}`);
146
+ }
147
+ }
148
+
149
+ if (cleanText.length > CONFIG.MAX_CONTENT_SIZE) {
150
+ extractSpinner.fail("Content too large");
151
+ throw new GlanceError(
152
+ `Content size (${formatFileSize(cleanText.length)}) exceeds maximum allowed`,
153
+ ErrorCodes.CONTENT_TOO_LARGE,
154
+ `The webpage content is too large to process (>${formatFileSize(CONFIG.MAX_CONTENT_SIZE)})`,
155
+ false,
156
+ );
157
+ }
158
+
159
+ extractSpinner.succeed(
160
+ `Content extracted (${formatFileSize(cleanText.length)})`,
161
+ );
162
+
163
+ // Handle metadata extraction
164
+ if (options.metadata) {
165
+ const metadata = extractMetadata(html);
166
+ console.log(chalk.bold("\nšŸ“Š Page Metadata:"));
167
+ console.log(JSON.stringify(metadata, null, 2));
168
+ }
169
+
170
+ // Handle links extraction
171
+ if (options.links) {
172
+ const links = extractLinks(html);
173
+ console.log(chalk.bold(`\nšŸ”— Found ${links.length} links:`));
174
+ links.forEach((link) => {
175
+ const display = link.text ? `${link.text} (${link.href})` : link.href;
176
+ console.log(chalk.cyan(` • ${display}`));
177
+ });
178
+ }
179
+
180
+ // Handle screenshot
181
+ if (options.screenshot) {
182
+ await handleScreenshot(url, options.screenshot);
183
+ }
184
+
185
+ // Handle full content mode (no summarization)
186
+ if (options.full) {
187
+ const fullContent = await handleFullContent(cleanText, {
188
+ ...options,
189
+ language,
190
+ });
191
+
192
+ // Note: Caching disabled
193
+
194
+ return fullContent;
195
+ }
196
+
197
+ // Summarize content with detected or specified language
198
+ const { rawSummary, formattedSummary } = await summarizeContentWithRaw(
199
+ cleanText,
200
+ url,
201
+ { ...options, language },
202
+ );
203
+
204
+ // Note: Caching disabled
205
+
206
+ // Display summary immediately if voice synthesis is requested
207
+ if (options.read || options.audioOutput) {
208
+ // Show the full formatted summary first
209
+ console.log(formattedSummary);
210
+ console.log(""); // Add spacing
211
+
212
+ // Then clean the raw text for speech and read it aloud
213
+ const cleanedSummary = cleanTextForSpeech(rawSummary);
214
+ await handleVoiceSynthesis(cleanedSummary, { ...options, language });
215
+ return formattedSummary;
216
+ }
217
+
218
+ const summary = formattedSummary;
219
+
220
+ // Save to file if output specified
221
+ if (options.output) {
222
+ await saveToFile(summary, options.output);
223
+ }
224
+
225
+ // Copy to clipboard if requested
226
+ if (options.copy) {
227
+ // Copy formatted output for JSON/markdown, raw summary for terminal
228
+ const contentToCopy =
229
+ options.format === "json" || options.format === "markdown"
230
+ ? summary
231
+ : rawSummary || summary;
232
+ await copyToClipboard(contentToCopy);
233
+ }
234
+
235
+ const duration = Date.now() - startTime;
236
+ logger.debug(`Total execution time: ${duration}ms`);
237
+
238
+ return summary;
239
+ }
240
+
241
+ /**
242
+ * Determine output format from user options
243
+ * Priority: 1) --format flag, 2) file extension, 3) terminal default
244
+ */
245
+ function getOutputFormat(
246
+ options: GlanceOptions,
247
+ ): "terminal" | "markdown" | "json" | "plain" {
248
+ // 1. If format is explicitly specified, use it
249
+ if (options.format) {
250
+ const formatMap: Record<string, string> = {
251
+ md: "markdown",
252
+ json: "json",
253
+ plain: "plain",
254
+ markdown: "markdown",
255
+ terminal: "terminal",
256
+ };
257
+ return (formatMap[options.format.toLowerCase()] || "terminal") as
258
+ | "terminal"
259
+ | "markdown"
260
+ | "json"
261
+ | "plain";
262
+ }
263
+
264
+ // 2. If output file is specified, auto-detect from extension
265
+ if (options.output) {
266
+ const extension = getFileExtension(options.output).toLowerCase();
267
+ const extensionMap: Record<string, string> = {
268
+ md: "markdown",
269
+ markdown: "markdown",
270
+ json: "json",
271
+ txt: "plain",
272
+ text: "plain",
273
+ };
274
+ const detectedFormat = extensionMap[extension];
275
+ if (detectedFormat) {
276
+ return detectedFormat as "terminal" | "markdown" | "json" | "plain";
277
+ }
278
+ }
279
+
280
+ // 3. Default to terminal if no output file or unrecognized extension
281
+ return "terminal";
282
+ }
283
+
284
+ /**
285
+ * Save content to file
286
+ */
287
+ async function saveToFile(content: string, filename: string): Promise<void> {
288
+ try {
289
+ await writeFile(filename, content, "utf-8");
290
+ logger.info(`Content saved to ${filename}`);
291
+ } catch (error: unknown) {
292
+ throw new GlanceError(
293
+ error instanceof Error ? error.message : String(error),
294
+ ErrorCodes.EXPORT_FAILED,
295
+ `Failed to save content to ${filename}`,
296
+ false,
297
+ );
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Copy content to clipboard
303
+ */
304
+ async function copyToClipboard(content: string): Promise<void> {
305
+ try {
306
+ await clipboard.write(content);
307
+ logger.info(chalk.green("āœ“ Copied to clipboard"));
308
+ } catch (error: unknown) {
309
+ logger.warn(
310
+ chalk.yellow("⚠ Could not copy to clipboard:"),
311
+ error instanceof Error ? error.message : String(error),
312
+ );
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Handle full content mode with optional AI formatting and translation
318
+ */
319
+ async function handleFullContent(
320
+ content: string,
321
+ options: GlanceOptions & { language: string },
322
+ ): Promise<string> {
323
+ let finalContent = content;
324
+ const needsTranslation = options.language && options.language !== "en";
325
+
326
+ // Always apply smart formatting for better readability
327
+ const fullModeSpinner = createSpinner(
328
+ needsTranslation
329
+ ? "šŸŒ Translating and formatting full content..."
330
+ : "🧾 Applying smart formatting...",
331
+ );
332
+ fullModeSpinner.start();
333
+
334
+ try {
335
+ // Determine model to use
336
+ const model =
337
+ options.model ||
338
+ (await getDefaultModel(undefined, !!options.preferQuality));
339
+ const _provider = detectProvider(model);
340
+
341
+ // Use AI for translation or formatting
342
+ const aiOptions = {
343
+ model,
344
+ language: options.language,
345
+ stream: false, // Don't stream for full content
346
+ maxTokens: options.maxTokens || 8000,
347
+ translate: needsTranslation,
348
+ format: true, // Always apply smart formatting
349
+ };
350
+
351
+ const processedContent = await summarize(finalContent, {
352
+ model: aiOptions.model,
353
+ language: aiOptions.language,
354
+ stream: aiOptions.stream,
355
+ maxTokens: aiOptions.maxTokens,
356
+ translate: aiOptions.translate as boolean | undefined,
357
+ format: aiOptions.format,
358
+ });
359
+
360
+ finalContent = sanitizeAIResponse(processedContent);
361
+
362
+ fullModeSpinner.succeed(
363
+ needsTranslation
364
+ ? "Translation and formatting complete"
365
+ : "Smart formatting applied",
366
+ );
367
+ } catch (error: unknown) {
368
+ fullModeSpinner.fail(
369
+ needsTranslation
370
+ ? "Translation failed - showing original content"
371
+ : "Smart formatting failed - showing original content",
372
+ );
373
+ logger.error("Full content processing error:", error);
374
+ }
375
+
376
+ // Format the output
377
+ const formattedOutput = formatOutput(finalContent, {
378
+ format: getOutputFormat(options),
379
+ url: "full-content",
380
+ isFullContent: true,
381
+ });
382
+
383
+ // Save to file if output specified
384
+ if (options.output) {
385
+ await saveToFile(formattedOutput, options.output);
386
+ }
387
+
388
+ return formattedOutput;
389
+ }
390
+
391
+ /**
392
+ * Summarize content using AI - returns both raw and formatted versions
393
+ */
394
+ async function summarizeContentWithRaw(
395
+ content: string,
396
+ url: string,
397
+ options: GlanceOptions & { language: string },
398
+ ): Promise<{ rawSummary: string; formattedSummary: string }> {
399
+ const model =
400
+ options.model ||
401
+ (await getDefaultModel(undefined, !!options.preferQuality));
402
+
403
+ // Show cost warning if using premium model
404
+ if (!options.freeOnly) {
405
+ await showCostWarning(model);
406
+ }
407
+
408
+ const summarizeSpinner = options.stream
409
+ ? null
410
+ : createSpinner(`Processing with ${model}...`);
411
+
412
+ summarizeSpinner?.start();
413
+
414
+ try {
415
+ const rawSummary = await withRetry(
416
+ () =>
417
+ summarize(content, {
418
+ model,
419
+ tldr: options.tldr,
420
+ keyPoints: options.keyPoints,
421
+ eli5: options.eli5,
422
+ language: options.language,
423
+ stream: options.stream,
424
+ maxTokens: options.maxTokens,
425
+ customQuestion: options.customQuestion,
426
+ }),
427
+ {
428
+ attempts: 2,
429
+ onRetry: (attempt) => {
430
+ if (summarizeSpinner) {
431
+ summarizeSpinner.text = `Processing with ${model}... (retry ${attempt})`;
432
+ }
433
+ },
434
+ },
435
+ );
436
+
437
+ summarizeSpinner?.succeed("Summary generated successfully");
438
+
439
+ // Clean and format the summary
440
+ const cleanSummary = sanitizeOutputForTerminal(
441
+ sanitizeAIResponse(rawSummary),
442
+ );
443
+ const formattedSummary = formatOutput(cleanSummary, {
444
+ format: getOutputFormat(options),
445
+ url: url,
446
+ customQuestion: options.customQuestion,
447
+ });
448
+
449
+ return { rawSummary: cleanSummary, formattedSummary };
450
+ } catch (error: unknown) {
451
+ summarizeSpinner?.fail("Failed to generate summary");
452
+ throw new GlanceError(
453
+ error instanceof Error ? error.message : String(error),
454
+ ErrorCodes.SUMMARIZE_FAILED,
455
+ "Failed to generate summary. The AI service might be unavailable.",
456
+ true,
457
+ "Try a different model with --model or check your API keys",
458
+ );
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Summarize content using AI
464
+ */
465
+ async function _summarizeContent(
466
+ content: string,
467
+ url: string,
468
+ options: GlanceOptions & { language: string },
469
+ ): Promise<string> {
470
+ const model =
471
+ options.model ||
472
+ (await getDefaultModel(undefined, !!options.preferQuality));
473
+
474
+ // Show cost warning if using premium model
475
+ if (!options.freeOnly) {
476
+ await showCostWarning(model);
477
+ }
478
+
479
+ const summarizeSpinner = options.stream
480
+ ? null
481
+ : createSpinner(`Processing with ${model}...`);
482
+
483
+ summarizeSpinner?.start();
484
+
485
+ try {
486
+ const summary = await withRetry(
487
+ () =>
488
+ summarize(content, {
489
+ model,
490
+ tldr: options.tldr,
491
+ keyPoints: options.keyPoints,
492
+ eli5: options.eli5,
493
+ language: options.language,
494
+ stream: options.stream,
495
+ maxTokens: options.maxTokens,
496
+ customQuestion: options.customQuestion,
497
+ }),
498
+ {
499
+ attempts: 2,
500
+ onRetry: (attempt) => {
501
+ if (summarizeSpinner) {
502
+ summarizeSpinner.text = `Processing with ${model}... (retry ${attempt})`;
503
+ }
504
+ },
505
+ },
506
+ );
507
+
508
+ summarizeSpinner?.succeed("Summary generated successfully");
509
+
510
+ // Clean and format the summary
511
+ const cleanSummary = sanitizeOutputForTerminal(sanitizeAIResponse(summary));
512
+ const formattedSummary = formatOutput(cleanSummary, {
513
+ format: getOutputFormat(options),
514
+ url: url,
515
+ customQuestion: options.customQuestion,
516
+ });
517
+
518
+ return formattedSummary;
519
+ } catch (error: unknown) {
520
+ summarizeSpinner?.fail("Failed to generate summary");
521
+ throw new GlanceError(
522
+ error instanceof Error ? error.message : String(error),
523
+ ErrorCodes.SUMMARIZE_FAILED,
524
+ "Failed to generate summary. The AI service might be unavailable.",
525
+ true,
526
+ "Try a different model with --model or check your API keys",
527
+ );
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Handle voice synthesis
533
+ */
534
+ async function handleVoiceSynthesis(
535
+ cleanedText: string,
536
+ options: GlanceOptions & { language: string },
537
+ ): Promise<void> {
538
+ try {
539
+ const synthesizer = createVoiceSynthesizer();
540
+
541
+ if (options.audioOutput) {
542
+ const audioSpinner = createSpinner(
543
+ `šŸŽµ Generating audio file: ${options.audioOutput}`,
544
+ );
545
+ audioSpinner.start();
546
+
547
+ // Use the already cleaned text directly
548
+ const result = await synthesizer.synthesizeCleanedText(cleanedText, {
549
+ voice: options.voice,
550
+ language: options.language,
551
+ outputFile: options.audioOutput,
552
+ });
553
+
554
+ if (!result.success) {
555
+ throw new Error(result.error || "Voice synthesis failed");
556
+ }
557
+
558
+ audioSpinner.succeed(`šŸŽµ Audio saved to ${options.audioOutput}`);
559
+ } else {
560
+ const readSpinner = createSpinner(
561
+ `šŸŽ¤ Generating speech and preparing to read aloud...`,
562
+ );
563
+ readSpinner.start();
564
+
565
+ // Use the already cleaned text directly
566
+ const result = await synthesizer.synthesizeCleanedText(cleanedText, {
567
+ voice: options.voice,
568
+ language: options.language,
569
+ });
570
+
571
+ if (!result.success) {
572
+ throw new Error(result.error || "Voice synthesis failed");
573
+ }
574
+
575
+ readSpinner.succeed(`šŸŽ¤ Reading aloud completed`);
576
+ }
577
+ } catch (error: unknown) {
578
+ logger.error("Voice synthesis failed:", error);
579
+ throw new GlanceError(
580
+ error instanceof Error ? error.message : String(error),
581
+ ErrorCodes.VOICE_SYNTHESIS_FAILED,
582
+ "Failed to synthesize voice. Check your voice settings or try a different voice.",
583
+ false,
584
+ );
585
+ }
586
+ }
587
+
588
+ /**
589
+ * Handle screenshot capture
590
+ */
591
+ async function handleScreenshot(url: string, filename: string): Promise<void> {
592
+ const screenshotSpinner = createSpinner(`Capturing screenshot: ${filename}`);
593
+ screenshotSpinner.start();
594
+
595
+ try {
596
+ await takeScreenshot(url, filename);
597
+ screenshotSpinner.succeed(`Screenshot saved to ${filename}`);
598
+ } catch (error: unknown) {
599
+ screenshotSpinner.fail("Failed to capture screenshot");
600
+ logger.error("Screenshot error:", error);
601
+ }
602
+ }
603
+
604
+ /**
605
+ * Export content to file
606
+ */
607
+ async function _exportContent(
608
+ content: string,
609
+ filename: string,
610
+ options: { url?: string; isFullContent?: boolean } = {},
611
+ ): Promise<void> {
612
+ const extension = getFileExtension(filename);
613
+ const format = extension || "txt";
614
+
615
+ const formattedContent = formatOutput(content, {
616
+ format: format as "terminal" | "markdown" | "json" | "html" | "plain",
617
+ url: options.url || "exported-content",
618
+ isFullContent: options.isFullContent,
619
+ });
620
+
621
+ try {
622
+ await writeFile(filename, formattedContent, "utf-8");
623
+ logger.info(`Content exported to ${filename}`);
624
+ } catch (error: unknown) {
625
+ throw new GlanceError(
626
+ error instanceof Error ? error.message : String(error),
627
+ ErrorCodes.EXPORT_FAILED,
628
+ `Failed to export content to ${filename}`,
629
+ false,
630
+ );
631
+ }
632
+ }
633
+
634
+ // Cache functionality removed - see next-features/cache-system-plan.md
635
+
636
+ /**
637
+ * List voices command
638
+ */
639
+ export async function listVoicesCommand(): Promise<void> {
640
+ try {
641
+ const synthesizer = createVoiceSynthesizer();
642
+ // Get all available voices
643
+ const englishVoices = await synthesizer.listVoices("en");
644
+ const frenchVoices = await synthesizer.listVoices("fr");
645
+ const spanishVoices = await synthesizer.listVoices("es");
646
+ const haitianVoices = await synthesizer.listVoices("ht");
647
+
648
+ console.log(chalk.bold("\nšŸŽ¤ Available Voices by Language:\n"));
649
+
650
+ if (englishVoices.length > 0) {
651
+ console.log(chalk.bold("šŸ‡ŗšŸ‡ø English:"));
652
+ for (const v of englishVoices) console.log(` ${v}`);
653
+ }
654
+
655
+ if (frenchVoices.length > 0) {
656
+ console.log(chalk.bold("\nšŸ‡«šŸ‡· French:"));
657
+ for (const v of frenchVoices) console.log(` ${v}`);
658
+ }
659
+
660
+ if (spanishVoices.length > 0) {
661
+ console.log(chalk.bold("\nšŸ‡ŖšŸ‡ø Spanish:"));
662
+ for (const v of spanishVoices) console.log(` ${v}`);
663
+ }
664
+
665
+ if (haitianVoices.length > 0) {
666
+ console.log(chalk.bold("\nšŸ‡­šŸ‡¹ Haitian Creole:"));
667
+ for (const v of haitianVoices) console.log(` ${v}`);
668
+ }
669
+
670
+ console.log(
671
+ chalk.dim("\nUse with: glance <url> --voice <voice-name> --read"),
672
+ );
673
+ } catch (error: unknown) {
674
+ logger.error("Failed to list voices:", error);
675
+ throw error;
676
+ }
677
+ }
678
+
679
+ /**
680
+ * Check services command
681
+ */
682
+ export async function checkServicesCommand(): Promise<void> {
683
+ const spinner = createSpinner("Detecting available services...");
684
+ spinner.start();
685
+
686
+ try {
687
+ // Use our own validators instead of the old detectServices
688
+ const [ollamaCheck, openaiCheck, geminiCheck] = await Promise.all([
689
+ validateAPIKeys("ollama").catch(() => ({
690
+ valid: false,
691
+ error: "Connection failed",
692
+ })),
693
+ validateAPIKeys("openai").catch(() => ({
694
+ valid: false,
695
+ error: "API key missing",
696
+ })),
697
+ validateAPIKeys("google").catch(() => ({
698
+ valid: false,
699
+ error: "API key missing",
700
+ })),
701
+ ]);
702
+
703
+ // Get Ollama models if available
704
+ let ollamaModels: string[] = [];
705
+ if (ollamaCheck.valid) {
706
+ try {
707
+ const endpoint =
708
+ process.env.OLLAMA_ENDPOINT || "http://localhost:11434";
709
+ const response = await fetch(`${endpoint}/api/tags`);
710
+ if (response.ok) {
711
+ const data = await response.json();
712
+ ollamaModels =
713
+ data.models?.map((m: { name: string }) => m.name) || [];
714
+ }
715
+ } catch {
716
+ // Ignore model fetch errors
717
+ }
718
+ }
719
+
720
+ // Check ElevenLabs
721
+ const elevenlabsCheck = {
722
+ valid: !!process.env.ELEVENLABS_API_KEY,
723
+ error: process.env.ELEVENLABS_API_KEY ? undefined : "API key missing",
724
+ };
725
+
726
+ spinner.succeed("Service detection complete");
727
+
728
+ const serviceStatus: ServiceStatus = {
729
+ ollama: {
730
+ available: ollamaCheck.valid,
731
+ models: ollamaModels,
732
+ error: ollamaCheck.error,
733
+ },
734
+ openai: {
735
+ available: openaiCheck.valid,
736
+ error: openaiCheck.error,
737
+ },
738
+ gemini: {
739
+ available: geminiCheck.valid,
740
+ error: geminiCheck.error,
741
+ },
742
+ elevenlabs: {
743
+ available: elevenlabsCheck.valid,
744
+ voices: [],
745
+ error: elevenlabsCheck.error,
746
+ },
747
+ defaultModel: ollamaCheck.valid
748
+ ? "ollama"
749
+ : openaiCheck.valid
750
+ ? "gpt-4o-mini"
751
+ : geminiCheck.valid
752
+ ? "gemini-2.0-flash-exp"
753
+ : "None available",
754
+ priority: "Free services first",
755
+ recommendations: [],
756
+ };
757
+
758
+ // Add recommendations based on missing services
759
+ if (!serviceStatus.ollama.available) {
760
+ serviceStatus.recommendations?.push("Install Ollama for free local AI");
761
+ }
762
+ if (!serviceStatus.openai.available) {
763
+ serviceStatus.recommendations?.push(
764
+ "Add OpenAI API key for premium quality",
765
+ );
766
+ }
767
+ if (!serviceStatus.elevenlabs.available) {
768
+ serviceStatus.recommendations?.push(
769
+ "Add ElevenLabs API key for natural voice synthesis",
770
+ );
771
+ }
772
+
773
+ showServiceStatus(serviceStatus);
774
+ } catch (error: unknown) {
775
+ spinner.fail("Service detection failed");
776
+ logger.error("Service detection error:", error);
777
+
778
+ // Show empty status with error message
779
+ const emptyStatus: ServiceStatus = {
780
+ ollama: { available: false, error: "Detection failed" },
781
+ openai: { available: false, error: "Detection failed" },
782
+ gemini: { available: false, error: "Detection failed" },
783
+ elevenlabs: { available: false, error: "Detection failed" },
784
+ recommendations: ["Check your internet connection and try again"],
785
+ };
786
+
787
+ showServiceStatus(emptyStatus);
788
+ throw error;
789
+ }
790
+ }
791
+
792
+ /**
793
+ * List Ollama models command
794
+ */
795
+ export async function listModelsCommand(): Promise<void> {
796
+ const endpoint = process.env.OLLAMA_ENDPOINT || CONFIG.OLLAMA_ENDPOINT;
797
+ const spinner = createSpinner("Fetching Ollama models...");
798
+ spinner.start();
799
+
800
+ try {
801
+ const response = await fetch(`${endpoint}/api/tags`);
802
+
803
+ if (!response.ok) {
804
+ throw new Error(`Failed to fetch models: ${response.statusText}`);
805
+ }
806
+
807
+ const data = (await response.json()) as {
808
+ models: Array<{
809
+ name: string;
810
+ size?: number;
811
+ details?: { parameter_size?: string };
812
+ }>;
813
+ };
814
+ spinner.succeed("Models fetched successfully");
815
+
816
+ if (!data.models || data.models.length === 0) {
817
+ console.log(chalk.yellow("\nNo models found. Install models with:"));
818
+ console.log(chalk.cyan(" ollama pull llama3"));
819
+ console.log(chalk.cyan(" ollama pull mistral"));
820
+ return;
821
+ }
822
+
823
+ console.log(
824
+ chalk.bold(`\nšŸ“¦ Available Ollama Models (${data.models.length}):\n`),
825
+ );
826
+
827
+ data.models.forEach(
828
+ (model: {
829
+ name: string;
830
+ size?: number;
831
+ details?: { parameter_size?: string };
832
+ }) => {
833
+ const size = model.size ? `(${(model.size / 1e9).toFixed(1)}GB)` : "";
834
+ console.log(` ${chalk.cyan(model.name)} ${chalk.gray(size)}`);
835
+ if (model.details?.parameter_size) {
836
+ console.log(
837
+ ` ${chalk.gray(model.details.parameter_size)} parameters`,
838
+ );
839
+ }
840
+ },
841
+ );
842
+
843
+ console.log(
844
+ chalk.dim("\nUse any model with: glance <url> --model <model-name>"),
845
+ );
846
+ } catch (error: unknown) {
847
+ spinner.fail("Failed to fetch models");
848
+ console.error(
849
+ chalk.red("\nCannot connect to Ollama. Make sure it's running:"),
850
+ );
851
+ console.log(chalk.cyan(" ollama serve"));
852
+ throw error;
853
+ }
854
+ }