glance-cli 0.15.0 โ†’ 0.16.1

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.
@@ -4,7 +4,10 @@
4
4
  */
5
5
 
6
6
  import { writeFile } from "node:fs/promises";
7
+ import { createInterface } from "node:readline";
8
+ import { parseArgs } from "node:util";
7
9
  import chalk from "chalk";
10
+ import * as cheerio from "cheerio";
8
11
  import clipboard from "clipboardy";
9
12
  import {
10
13
  extractCleanText,
@@ -62,6 +65,8 @@ export interface GlanceOptions {
62
65
  preferQuality?: boolean;
63
66
  debug?: boolean;
64
67
  copy?: boolean;
68
+ browse?: boolean;
69
+ disableStdinHandling?: boolean; // For browse mode to prevent spinner interference
65
70
  }
66
71
 
67
72
  /**
@@ -86,7 +91,10 @@ export async function glance(
86
91
  // Note: Caching temporarily disabled to eliminate corruption issues
87
92
 
88
93
  // Fetch the webpage
89
- const fetchSpinner = createSpinner("Fetching webpage...");
94
+ const fetchSpinner = createSpinner(
95
+ "Fetching webpage...",
96
+ options.disableStdinHandling,
97
+ );
90
98
  fetchSpinner.start();
91
99
 
92
100
  let html: string;
@@ -112,7 +120,10 @@ export async function glance(
112
120
  }
113
121
 
114
122
  // Extract content
115
- const extractSpinner = createSpinner("Extracting content...");
123
+ const extractSpinner = createSpinner(
124
+ "Extracting content...",
125
+ options.disableStdinHandling,
126
+ );
116
127
  extractSpinner.start();
117
128
 
118
129
  const cleanText = extractCleanText(html);
@@ -328,6 +339,7 @@ async function handleFullContent(
328
339
  needsTranslation
329
340
  ? "๐ŸŒ Translating and formatting full content..."
330
341
  : "๐Ÿงพ Applying smart formatting...",
342
+ options.disableStdinHandling,
331
343
  );
332
344
  fullModeSpinner.start();
333
345
 
@@ -407,7 +419,10 @@ async function summarizeContentWithRaw(
407
419
 
408
420
  const summarizeSpinner = options.stream
409
421
  ? null
410
- : createSpinner(`Processing with ${model}...`);
422
+ : createSpinner(
423
+ `Processing with ${model}...`,
424
+ options.disableStdinHandling,
425
+ );
411
426
 
412
427
  summarizeSpinner?.start();
413
428
 
@@ -459,75 +474,6 @@ async function summarizeContentWithRaw(
459
474
  }
460
475
  }
461
476
 
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
477
  /**
532
478
  * Handle voice synthesis
533
479
  */
@@ -541,6 +487,7 @@ async function handleVoiceSynthesis(
541
487
  if (options.audioOutput) {
542
488
  const audioSpinner = createSpinner(
543
489
  `๐ŸŽต Generating audio file: ${options.audioOutput}`,
490
+ options.disableStdinHandling,
544
491
  );
545
492
  audioSpinner.start();
546
493
 
@@ -559,6 +506,7 @@ async function handleVoiceSynthesis(
559
506
  } else {
560
507
  const readSpinner = createSpinner(
561
508
  `๐ŸŽค Generating speech and preparing to read aloud...`,
509
+ options.disableStdinHandling,
562
510
  );
563
511
  readSpinner.start();
564
512
 
@@ -708,9 +656,13 @@ export async function checkServicesCommand(): Promise<void> {
708
656
  process.env.OLLAMA_ENDPOINT || "http://localhost:11434";
709
657
  const response = await fetch(`${endpoint}/api/tags`);
710
658
  if (response.ok) {
711
- const data = await response.json();
659
+ const data = (await response.json()) as {
660
+ models: { name: string }[];
661
+ };
712
662
  ollamaModels =
713
- data.models?.map((m: { name: string }) => m.name) || [];
663
+ (data as { models: { name: string }[] }).models?.map(
664
+ (m: { name: string }) => m.name,
665
+ ) || [];
714
666
  }
715
667
  } catch {
716
668
  // Ignore model fetch errors
@@ -852,3 +804,656 @@ export async function listModelsCommand(): Promise<void> {
852
804
  throw error;
853
805
  }
854
806
  }
807
+
808
+ /**
809
+ * Enhanced link extraction with categorization
810
+ */
811
+ function extractCategorizedLinks(html: string, baseUrl: string) {
812
+ const $ = cheerio.load(html);
813
+ const seenUrls = new Set<string>();
814
+
815
+ // Simplified categories: Navigation (same domain) and External (different domain)
816
+ const categories = {
817
+ navigation: [] as Array<{ href: string; text: string }>,
818
+ external: [] as Array<{ href: string; text: string }>,
819
+ };
820
+
821
+ // For backwards compatibility, keep these empty
822
+ const emptyCategories = {
823
+ content: [] as Array<{ href: string; text: string }>,
824
+ footer: [] as Array<{ href: string; text: string }>,
825
+ };
826
+
827
+ // Helper function to process links
828
+ const processLink = (element: any) => {
829
+ const $element = $(element);
830
+ let href = $element.attr("href")?.trim() || "";
831
+
832
+ // Skip invalid links
833
+ if (
834
+ !href ||
835
+ href.startsWith("javascript:") ||
836
+ href.startsWith("mailto:") ||
837
+ href.startsWith("tel:") ||
838
+ href === "#" ||
839
+ href.startsWith("#")
840
+ ) {
841
+ return;
842
+ }
843
+
844
+ // Extract clean text from the link element
845
+ let text = "";
846
+ const rawText = $element.text().trim() || "";
847
+
848
+ // Check if text contains CSS class names or other code artifacts
849
+ const isInvalidText = (t: string) => {
850
+ return (
851
+ t.startsWith(".css-") ||
852
+ t.includes("{") ||
853
+ t.includes("}") ||
854
+ t.includes("@media") ||
855
+ t.includes("::") ||
856
+ t.includes("var(--") ||
857
+ /^\.[a-z]+-[a-z0-9]+/i.test(t) || // CSS class pattern
858
+ /^#[a-z]+-[a-z0-9]+/i.test(t)
859
+ ); // CSS ID pattern
860
+ };
861
+
862
+ if (!isInvalidText(rawText)) {
863
+ text = rawText;
864
+ } else {
865
+ // Try to extract clean text by looking for actual text nodes
866
+ const imgAlt = $element.find("img").attr("alt") || "";
867
+ const ariaLabel = $element.attr("aria-label") || "";
868
+ const title = $element.attr("title") || "";
869
+
870
+ // Priority: aria-label > title > img alt > extracted text
871
+ if (ariaLabel && !isInvalidText(ariaLabel)) {
872
+ text = ariaLabel;
873
+ } else if (title && !isInvalidText(title)) {
874
+ text = title;
875
+ } else if (imgAlt && !isInvalidText(imgAlt)) {
876
+ text = imgAlt;
877
+ } else {
878
+ // Try to extract text without CSS elements
879
+ const cleanedElement = $element.clone();
880
+ cleanedElement.find("style").remove();
881
+ cleanedElement.find('[class*="css-"]').remove();
882
+ const cleanText = cleanedElement.text().trim() || "";
883
+
884
+ if (cleanText && !isInvalidText(cleanText)) {
885
+ text = cleanText;
886
+ }
887
+ }
888
+ }
889
+
890
+ // If still no valid text, create meaningful text from URL
891
+ if (!text || isInvalidText(text)) {
892
+ try {
893
+ const url = new URL(
894
+ href.startsWith("http") ? href : new URL(href, baseUrl).href,
895
+ );
896
+
897
+ // Special handling for known domains
898
+ const hostname = url.hostname.replace("www.", "");
899
+ const pathname = url.pathname;
900
+
901
+ // Extract meaningful name from pathname
902
+ if (pathname && pathname !== "/") {
903
+ const pathParts = pathname
904
+ .split("/")
905
+ .filter((p) => p && !p.match(/^\d+$/)); // Filter out numbers
906
+ if (pathParts.length > 0) {
907
+ const lastPart = pathParts[pathParts.length - 1] || "";
908
+ // Clean up the path part
909
+ text = lastPart
910
+ .replace(/[-_]/g, " ")
911
+ .replace(/\.[^.]+$/, "") // Remove file extension
912
+ .split(" ")
913
+ .map(
914
+ (word) =>
915
+ word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
916
+ )
917
+ .join(" ");
918
+ }
919
+ }
920
+
921
+ // If still no text, use the domain name
922
+ if (!text) {
923
+ text = hostname.charAt(0).toUpperCase() + hostname.slice(1);
924
+ }
925
+ } catch {
926
+ text = "Link";
927
+ }
928
+ }
929
+
930
+ // Final cleanup - remove extra whitespace and truncate if too long
931
+ text = text.replace(/\s+/g, " ").trim();
932
+ if (text.length > 100) {
933
+ text = text.substring(0, 97) + "...";
934
+ }
935
+
936
+ // Resolve relative URLs
937
+ try {
938
+ if (!href.startsWith("http")) {
939
+ href = new URL(href, baseUrl).href;
940
+ }
941
+ } catch {
942
+ return; // Skip invalid URLs
943
+ }
944
+
945
+ // Only accept HTTPS links for security
946
+ if (!href.startsWith("https://")) {
947
+ return;
948
+ }
949
+
950
+ // Remove query strings for cleaner URLs
951
+ try {
952
+ const url = new URL(href);
953
+ href = `${url.protocol}//${url.hostname}${url.pathname}`;
954
+ // Remove trailing slash if it's just the domain
955
+ if (url.pathname === "/") {
956
+ href = `${url.protocol}//${url.hostname}`;
957
+ }
958
+ } catch {
959
+ // If URL parsing fails, use original
960
+ }
961
+
962
+ // Check if we've already seen this URL (deduplication)
963
+ if (seenUrls.has(href)) {
964
+ return;
965
+ }
966
+ seenUrls.add(href);
967
+
968
+ // Determine if link is internal (navigation) or external
969
+ const linkUrl = new URL(href);
970
+ const base = new URL(baseUrl);
971
+
972
+ // Same domain = Navigation link
973
+ // Different domain = External link
974
+ const isSameDomain = linkUrl.hostname === base.hostname;
975
+
976
+ const linkData = { href, text };
977
+
978
+ if (isSameDomain) {
979
+ categories.navigation.push(linkData);
980
+ } else {
981
+ categories.external.push(linkData);
982
+ }
983
+ };
984
+
985
+ // Process ALL links on the page exactly once
986
+ $("a[href]").each((_, el) => {
987
+ processLink(el);
988
+ });
989
+
990
+ // Return categories with backwards-compatible structure
991
+ return {
992
+ navigation: categories.navigation,
993
+ external: categories.external,
994
+ content: emptyCategories.content,
995
+ footer: emptyCategories.footer,
996
+ };
997
+ }
998
+
999
+ /**
1000
+ * Browse command - interactive link exploration
1001
+ */
1002
+ export async function browseCommand(url: string): Promise<void> {
1003
+ console.log(chalk.bold("๐ŸŒ Browse Mode - Interactive Link Navigation"));
1004
+ console.log(
1005
+ chalk.dim("Navigate through links on the webpage interactively\n"),
1006
+ );
1007
+
1008
+ try {
1009
+ // Fetch the initial page
1010
+ const fetchSpinner = createSpinner("Fetching webpage...");
1011
+ fetchSpinner.start();
1012
+
1013
+ const html = await fetchPage(url, { fullRender: false });
1014
+ fetchSpinner.succeed("Webpage fetched");
1015
+
1016
+ // Extract links with categorization
1017
+ const extractSpinner = createSpinner(
1018
+ "Extracting and categorizing links...",
1019
+ );
1020
+ extractSpinner.start();
1021
+
1022
+ const categorizedLinks = extractCategorizedLinks(html, url);
1023
+ const allLinks = [
1024
+ ...categorizedLinks.navigation,
1025
+ ...categorizedLinks.content,
1026
+ ...categorizedLinks.external,
1027
+ ...categorizedLinks.footer,
1028
+ ];
1029
+ extractSpinner.succeed(`Found ${allLinks.length} links`);
1030
+
1031
+ if (allLinks.length === 0) {
1032
+ console.log(chalk.yellow("No links found on this page."));
1033
+ return;
1034
+ }
1035
+
1036
+ // Show initial page info
1037
+ const metadata = extractMetadata(html);
1038
+ const pageTitle = metadata.title || url;
1039
+ console.log(chalk.bold(`\nCurrent Page: ${pageTitle}`));
1040
+ if (metadata.description) {
1041
+ console.log(chalk.dim(`Description: ${metadata.description}`));
1042
+ }
1043
+ console.log("");
1044
+
1045
+ // Interactive link selection
1046
+ await interactiveLinkNavigation(categorizedLinks, url, pageTitle);
1047
+ } catch (error: unknown) {
1048
+ throw new GlanceError(
1049
+ error instanceof Error ? error.message : String(error),
1050
+ ErrorCodes.FETCH_FAILED,
1051
+ "Failed to start browse mode",
1052
+ false,
1053
+ );
1054
+ }
1055
+ }
1056
+
1057
+ /**
1058
+ * Interactive link navigation
1059
+ */
1060
+ async function interactiveLinkNavigation(
1061
+ categorizedLinks: {
1062
+ navigation: Array<{ href: string; text: string }>;
1063
+ content: Array<{ href: string; text: string }>;
1064
+ footer: Array<{ href: string; text: string }>;
1065
+ external: Array<{ href: string; text: string }>;
1066
+ },
1067
+ currentUrl: string,
1068
+ pageTitle: string,
1069
+ ): Promise<void> {
1070
+ const rl = createInterface({
1071
+ input: process.stdin,
1072
+ output: process.stdout,
1073
+ });
1074
+
1075
+ const browsingHistory: string[] = [currentUrl];
1076
+ let currentCategorizedLinks = categorizedLinks;
1077
+ let currentPage = currentUrl;
1078
+ let currentPageTitle = pageTitle;
1079
+ let displayMode: "all" | "nav" | "external" = "all";
1080
+
1081
+ const displayLinks = () => {
1082
+ let linkIndex = 1;
1083
+ const linkMap = new Map<number, { href: string; text: string }>();
1084
+
1085
+ // Show links based on display mode
1086
+ if (displayMode === "all" || displayMode === "nav") {
1087
+ if (currentCategorizedLinks.navigation.length > 0) {
1088
+ if (displayMode === "nav") {
1089
+ console.log(
1090
+ chalk.magenta(
1091
+ `\n๐Ÿงญ Navigation Links Only (${currentCategorizedLinks.navigation.length} total):`,
1092
+ ),
1093
+ );
1094
+ } else {
1095
+ console.log(
1096
+ chalk.magenta(
1097
+ `\nNavigation Links (${currentCategorizedLinks.navigation.length} total):`,
1098
+ ),
1099
+ );
1100
+ }
1101
+ currentCategorizedLinks.navigation.forEach((link) => {
1102
+ const displayText = link.text || "Link";
1103
+ console.log(
1104
+ chalk.cyan(` ${linkIndex}. `) + chalk.blue.underline(link.href),
1105
+ );
1106
+ linkMap.set(linkIndex, { href: link.href, text: link.text });
1107
+ linkIndex++;
1108
+ });
1109
+ }
1110
+ }
1111
+
1112
+ if (displayMode === "all" || displayMode === "external") {
1113
+ if (currentCategorizedLinks.external.length > 0) {
1114
+ if (displayMode === "external") {
1115
+ console.log(
1116
+ chalk.yellow(
1117
+ `\n๐ŸŒ External Links Only (${currentCategorizedLinks.external.length} total):`,
1118
+ ),
1119
+ );
1120
+ } else {
1121
+ console.log(
1122
+ chalk.yellow(
1123
+ `\nExternal Links (${currentCategorizedLinks.external.length} total):`,
1124
+ ),
1125
+ );
1126
+ }
1127
+ currentCategorizedLinks.external.forEach((link) => {
1128
+ const displayText = link.text || "Link";
1129
+ console.log(
1130
+ chalk.cyan(` ${linkIndex}. `) + chalk.blue.underline(link.href),
1131
+ );
1132
+ linkMap.set(linkIndex, { href: link.href, text: link.text });
1133
+ linkIndex++;
1134
+ });
1135
+ }
1136
+ }
1137
+
1138
+ return linkMap;
1139
+ };
1140
+
1141
+ let lastCommandResult: string | null = null;
1142
+ let lastCommand: string | null = null;
1143
+
1144
+ while (true) {
1145
+ // Clear screen and show current state
1146
+ console.clear();
1147
+
1148
+ // Show current page header
1149
+ console.log(chalk.bold.cyan(`\nCurrent Page: ${pageTitle}`));
1150
+ // console.log(chalk.dim("โ”€".repeat(60)));
1151
+
1152
+ // Display links
1153
+ const linkMap = displayLinks();
1154
+
1155
+ const totalDisplayed = linkMap.size;
1156
+
1157
+ // If there was a last command, show it and its results here
1158
+ if (lastCommand && lastCommandResult) {
1159
+ console.log("");
1160
+ console.log(chalk.dim("โ”€".repeat(60)));
1161
+ console.log(chalk.cyan("Last command: ") + chalk.yellow(lastCommand));
1162
+ console.log("");
1163
+ console.log(lastCommandResult);
1164
+ console.log(chalk.dim("โ”€".repeat(60)));
1165
+ }
1166
+
1167
+ // Show commands menu at the bottom
1168
+ console.log(chalk.dim("\nCommands:"));
1169
+ console.log(chalk.dim(` 1-${totalDisplayed}: Navigate to link`));
1170
+ console.log(chalk.dim(" 'n': Show only navigation links"));
1171
+ console.log(chalk.dim(" 'e': Show only external links"));
1172
+ console.log(chalk.dim(" 'a': Show all links"));
1173
+ console.log(chalk.dim(" 'b': Go back"));
1174
+ console.log(chalk.dim(" 'h': History"));
1175
+ console.log(chalk.dim(" 'q': Quit"));
1176
+ console.log(chalk.dim("\nEnhanced Commands:"));
1177
+ console.log(
1178
+ chalk.dim(" '1 --read -l fr': Navigate to link 1 and read in French"),
1179
+ );
1180
+ console.log(
1181
+ chalk.dim(" '2 --tldr --copy': Navigate to link 2, get TLDR and copy"),
1182
+ );
1183
+ console.log(
1184
+ chalk.dim(" '3 --eli5 -m gemini': Navigate to link 3, ELI5 with Gemini"),
1185
+ );
1186
+
1187
+ let input: string;
1188
+ try {
1189
+ input = await new Promise<string>((resolve, reject) => {
1190
+ rl.question(chalk.yellow("\n> "), (answer) => {
1191
+ resolve(answer);
1192
+ });
1193
+ // Check if readline was closed
1194
+ if ((rl as any).closed) {
1195
+ reject(new Error("Readline interface was closed"));
1196
+ }
1197
+ });
1198
+ } catch (error) {
1199
+ // Readline was closed, break out of the loop gracefully
1200
+ console.log(chalk.yellow("Browse mode ended"));
1201
+ break;
1202
+ }
1203
+
1204
+ const command = input.trim().toLowerCase();
1205
+
1206
+ if (command === "q" || command === "quit") {
1207
+ console.log(chalk.green("\n๐Ÿ‘‹ Exiting browse mode"));
1208
+ break;
1209
+ }
1210
+
1211
+ if (command === "n") {
1212
+ displayMode = "nav";
1213
+ lastCommand = null; // Clear last command for navigation changes
1214
+ lastCommandResult = null;
1215
+ continue;
1216
+ }
1217
+
1218
+ if (command === "e") {
1219
+ displayMode = "external";
1220
+ lastCommand = null; // Clear last command for navigation changes
1221
+ lastCommandResult = null;
1222
+ continue;
1223
+ }
1224
+
1225
+ if (command === "a") {
1226
+ displayMode = "all";
1227
+ lastCommand = null; // Clear last command for navigation changes
1228
+ lastCommandResult = null;
1229
+ continue;
1230
+ }
1231
+
1232
+ if (command === "b" || command === "back") {
1233
+ if (browsingHistory.length > 1) {
1234
+ browsingHistory.pop();
1235
+ const previousPage = browsingHistory[browsingHistory.length - 1];
1236
+
1237
+ try {
1238
+ const html = await fetchPage(previousPage!, { fullRender: false });
1239
+ currentCategorizedLinks = extractCategorizedLinks(
1240
+ html,
1241
+ previousPage!,
1242
+ );
1243
+ currentPage = previousPage!;
1244
+ displayMode = "all";
1245
+
1246
+ const metadata = extractMetadata(html);
1247
+ currentPageTitle = metadata.title || currentPage;
1248
+ pageTitle = currentPageTitle; // Update page title
1249
+
1250
+ // Clear last command after navigation
1251
+ lastCommand = null;
1252
+ lastCommandResult = null;
1253
+ } catch (error) {
1254
+ lastCommand = "b";
1255
+ lastCommandResult = chalk.red("Failed to go back to previous page");
1256
+ browsingHistory.push(currentPage);
1257
+ }
1258
+ } else {
1259
+ lastCommand = "b";
1260
+ lastCommandResult = chalk.yellow("No previous page in history");
1261
+ }
1262
+ continue;
1263
+ }
1264
+
1265
+ if (command === "h" || command === "history") {
1266
+ let historyOutput = chalk.bold("๐Ÿ“š Browsing History:\n");
1267
+ browsingHistory.forEach((url, index) => {
1268
+ const indicator = index === browsingHistory.length - 1 ? "โ†’ " : " ";
1269
+ historyOutput += chalk.cyan(indicator) + chalk.white(url) + "\n";
1270
+ });
1271
+ lastCommand = "h";
1272
+ lastCommandResult = historyOutput.trim();
1273
+ continue;
1274
+ }
1275
+
1276
+ // Handle numeric input for link navigation with optional CLI options
1277
+ // Parse input to support commands like "5 --read -l fr"
1278
+ const inputParts = input.trim().split(/\s+/);
1279
+ const linkNumber = Number.parseInt(inputParts[0] || "");
1280
+
1281
+ if (linkNumber >= 1 && linkNumber <= totalDisplayed) {
1282
+ const selectedLink = linkMap.get(linkNumber);
1283
+ if (selectedLink) {
1284
+ const targetUrl = new URL(selectedLink.href, currentPage).href;
1285
+
1286
+ // Check if additional CLI options were provided
1287
+ if (inputParts.length > 1) {
1288
+ // Parse CLI options after the link number
1289
+ try {
1290
+ const cliArgs = inputParts.slice(1);
1291
+ const { values } = parseArgs({
1292
+ args: cliArgs,
1293
+ allowPositionals: false,
1294
+ options: {
1295
+ // Summary options
1296
+ tldr: { type: "boolean" },
1297
+ "key-points": { type: "boolean" },
1298
+ eli5: { type: "boolean" },
1299
+ full: { type: "boolean" },
1300
+ ask: { type: "string", short: "q" },
1301
+
1302
+ // Language options
1303
+ language: { type: "string", short: "l" },
1304
+
1305
+ // Voice options
1306
+ read: { type: "boolean", short: "r" },
1307
+ voice: { type: "string" },
1308
+ "audio-output": { type: "string" },
1309
+
1310
+ // AI options
1311
+ model: { type: "string", short: "m" },
1312
+ stream: { type: "boolean" },
1313
+ "max-tokens": { type: "string" },
1314
+
1315
+ // Service options
1316
+ "free-only": { type: "boolean" },
1317
+ "prefer-quality": { type: "boolean" },
1318
+
1319
+ // Format & Output options
1320
+ format: { type: "string" },
1321
+ output: { type: "string", short: "o" },
1322
+ copy: { type: "boolean", short: "c" },
1323
+
1324
+ // Advanced options
1325
+ "full-render": { type: "boolean" },
1326
+ screenshot: { type: "string" },
1327
+ metadata: { type: "boolean" },
1328
+ links: { type: "boolean" },
1329
+ },
1330
+ });
1331
+
1332
+ console.log(chalk.blue(`๐Ÿ“ Processing: ${targetUrl}`));
1333
+
1334
+ // Show which options are being applied
1335
+ const activeOptions = [];
1336
+ if (values.tldr) activeOptions.push("TLDR");
1337
+ if (values.eli5) activeOptions.push("ELI5");
1338
+ if (values["key-points"]) activeOptions.push("Key Points");
1339
+ if (values.full) activeOptions.push("Full Content");
1340
+ if (values.read) activeOptions.push("Read Aloud");
1341
+ if (values.language)
1342
+ activeOptions.push(`Language: ${values.language}`);
1343
+ if (values.copy) activeOptions.push("Copy to Clipboard");
1344
+ if (values.model) activeOptions.push(`Model: ${values.model}`);
1345
+
1346
+ if (activeOptions.length > 0) {
1347
+ console.log(chalk.dim(`๐Ÿ”ง Options: ${activeOptions.join(", ")}`));
1348
+ }
1349
+ console.log("");
1350
+
1351
+ // Navigate and apply glance with options
1352
+ const glanceOptions: GlanceOptions = {
1353
+ model: values.model,
1354
+ language: values.language,
1355
+ tldr: values.tldr,
1356
+ keyPoints: values["key-points"],
1357
+ eli5: values.eli5,
1358
+ full: values.full,
1359
+ customQuestion: values.ask,
1360
+ stream: values.stream,
1361
+ maxTokens: values["max-tokens"]
1362
+ ? Number.parseInt(values["max-tokens"])
1363
+ : undefined,
1364
+ format: values.format,
1365
+ output: values.output,
1366
+ screenshot: values.screenshot,
1367
+ fullRender: values["full-render"],
1368
+ metadata: values.metadata,
1369
+ links: values.links,
1370
+ read: values.read,
1371
+ voice: values.voice,
1372
+ audioOutput: values["audio-output"],
1373
+ freeOnly: values["free-only"],
1374
+ preferQuality: values["prefer-quality"],
1375
+ copy: values.copy,
1376
+ disableStdinHandling: true, // Prevent spinner from interfering with readline
1377
+ };
1378
+
1379
+ // Run glance command with the options
1380
+ const result = await glance(targetUrl, glanceOptions);
1381
+
1382
+ // Store the result for display
1383
+ lastCommand = inputParts[0] + " " + inputParts.slice(1).join(" ");
1384
+
1385
+ if (glanceOptions.output) {
1386
+ // File was saved, show confirmation
1387
+ lastCommandResult = chalk.green(
1388
+ `โœ… Content saved to ${glanceOptions.output}`,
1389
+ );
1390
+ } else if (
1391
+ !glanceOptions.stream &&
1392
+ !glanceOptions.read &&
1393
+ !glanceOptions.audioOutput
1394
+ ) {
1395
+ // Show the result content
1396
+ lastCommandResult = chalk.bold.green("๐ŸŽฏ Result:\n") + result;
1397
+ } else {
1398
+ lastCommandResult = chalk.green("โœ… Processing completed");
1399
+ }
1400
+ } catch (parseError) {
1401
+ console.log(
1402
+ chalk.red(
1403
+ `Invalid options: ${parseError instanceof Error ? parseError.message : String(parseError)}`,
1404
+ ),
1405
+ );
1406
+ console.log(chalk.dim("Example: 5 --read -l fr"));
1407
+ }
1408
+ } else {
1409
+ // Regular navigation with default summary (like normal glance behavior)
1410
+ console.log(chalk.blue(`๐Ÿ“ Navigating to: ${targetUrl}`));
1411
+
1412
+ try {
1413
+ const html = await fetchPage(targetUrl, { fullRender: false });
1414
+ const newCategorizedLinks = extractCategorizedLinks(
1415
+ html,
1416
+ targetUrl,
1417
+ );
1418
+
1419
+ browsingHistory.push(targetUrl);
1420
+ currentCategorizedLinks = newCategorizedLinks;
1421
+ currentPage = targetUrl;
1422
+ displayMode = "all";
1423
+
1424
+ const metadata = extractMetadata(html);
1425
+ currentPageTitle = metadata.title || targetUrl;
1426
+ pageTitle = currentPageTitle; // Update page title
1427
+ const newTotalLinks =
1428
+ newCategorizedLinks.navigation.length +
1429
+ newCategorizedLinks.external.length;
1430
+
1431
+ // Get summary like normal glance behavior
1432
+ const result = await glance(targetUrl, {
1433
+ tldr: true,
1434
+ disableStdinHandling: true,
1435
+ });
1436
+
1437
+ // Store the result for display
1438
+ lastCommand = inputParts[0] || "";
1439
+ lastCommandResult = chalk.bold.green("๐Ÿ“„ Summary:\n") + result;
1440
+ } catch (error) {
1441
+ console.log(chalk.red("Failed to navigate to that link"));
1442
+ }
1443
+ }
1444
+ }
1445
+ } else if (linkNumber > 0) {
1446
+ console.log(
1447
+ chalk.yellow(`Invalid link number. Please choose 1-${totalDisplayed}`),
1448
+ );
1449
+ } else {
1450
+ console.log(
1451
+ chalk.yellow(
1452
+ "Invalid command. Type 'q' to quit or a number to navigate.",
1453
+ ),
1454
+ );
1455
+ }
1456
+ }
1457
+
1458
+ rl.close();
1459
+ }