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.
- package/CHANGELOG.md +25 -10
- package/README.md +122 -172
- package/dist/cli.js +158 -137
- package/package.json +2 -2
- package/src/cli/commands.ts +679 -74
- package/src/cli/config.ts +1 -1
- package/src/cli/index.ts +29 -2
- package/src/cli/utils.ts +3 -2
- package/src/core/formatter.ts +74 -5
package/src/cli/commands.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
+
}
|