glance-cli 0.12.0 → 0.13.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.
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Type definitions for CLI modules
3
+ */
4
+
5
+ // Service detection types
6
+ export interface ServiceStatus {
7
+ ollama: {
8
+ available: boolean;
9
+ models?: string[];
10
+ error?: string;
11
+ };
12
+ openai: {
13
+ available: boolean;
14
+ error?: string;
15
+ };
16
+ gemini: {
17
+ available: boolean;
18
+ error?: string;
19
+ };
20
+ elevenlabs: {
21
+ available: boolean;
22
+ voices?: string[];
23
+ error?: string;
24
+ };
25
+ defaultModel?: string;
26
+ priority?: string;
27
+ recommendations?: string[];
28
+ }
29
+
30
+ // Voice synthesis types
31
+ export interface VoiceOptions {
32
+ voice?: string;
33
+ language?: string;
34
+ speed?: number;
35
+ pitch?: number;
36
+ }
37
+
38
+ // Cache options types
39
+ export interface CacheOptions {
40
+ noCache?: boolean;
41
+ ttl?: number;
42
+ compress?: boolean;
43
+ }
44
+
45
+ // Model provider types
46
+ export type ModelProvider = "openai" | "google" | "ollama" | "auto";
47
+
48
+ // Summary types
49
+ export interface SummaryOptions {
50
+ tldr?: boolean;
51
+ keyPoints?: boolean;
52
+ eli5?: boolean;
53
+ full?: boolean;
54
+ customQuestion?: string;
55
+ language?: string;
56
+ stream?: boolean;
57
+ maxTokens?: number;
58
+ }
59
+
60
+ // Fetch options types
61
+ export interface FetchOptions {
62
+ fullRender?: boolean;
63
+ timeout?: number;
64
+ userAgent?: string;
65
+ }
66
+
67
+ // Format options types
68
+ export interface FormatOptions {
69
+ url?: string;
70
+ language?: string;
71
+ customQuestion?: string;
72
+ customTitle?: string;
73
+ isFullContent?: boolean;
74
+ }
75
+
76
+ // CLI parsed values types
77
+ export interface CliValues {
78
+ help?: boolean;
79
+ version?: boolean;
80
+ tldr?: boolean;
81
+ "key-points"?: boolean;
82
+ eli5?: boolean;
83
+ full?: boolean;
84
+ ask?: string;
85
+ language?: string;
86
+ read?: boolean;
87
+ voice?: string;
88
+ "list-voices"?: boolean;
89
+ "audio-output"?: string;
90
+ model?: string;
91
+ "list-models"?: boolean;
92
+ stream?: boolean;
93
+ "max-tokens"?: string;
94
+ "check-services"?: boolean;
95
+ "free-only"?: boolean;
96
+ "prefer-quality"?: boolean;
97
+ "no-cache"?: boolean;
98
+ "clear-cache"?: boolean;
99
+ "full-render"?: boolean;
100
+ screenshot?: string;
101
+ metadata?: boolean;
102
+ links?: boolean;
103
+ debug?: boolean;
104
+ }
105
+
106
+ // Error types with proper structure
107
+ export interface GlanceErrorDetails {
108
+ code: string;
109
+ message: string;
110
+ userMessage: string;
111
+ recoverable?: boolean;
112
+ hint?: string;
113
+ cause?: Error;
114
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Utility functions for Glance CLI
3
+ */
4
+
5
+ import ora, { type Ora } from "ora";
6
+ import {
7
+ hasBinaryArtifacts,
8
+ nuclearCleanText,
9
+ sanitizeAIResponse,
10
+ } from "../core/text-cleaner";
11
+ import { CONFIG } from "./config";
12
+ import { GlanceError } from "./errors";
13
+ import { logger } from "./logger";
14
+
15
+ /**
16
+ * Sanitize text output for terminal display
17
+ */
18
+ export function sanitizeOutputForTerminal(text: string): string {
19
+ // Preserve terminal color codes while cleaning text
20
+ const ansiRegex = /\x1b\[[0-9;]*m/g;
21
+ const ansiCodes: string[] = [];
22
+ let cleanText = text;
23
+
24
+ // Extract ANSI codes
25
+ let match: RegExpExecArray | null;
26
+ // biome-ignore lint/suspicious/noAssignInExpressions: RegExp.exec pattern is idiomatic
27
+ while ((match = ansiRegex.exec(text)) !== null) {
28
+ ansiCodes.push(match[0]);
29
+ }
30
+
31
+ // Check if nuclear cleaning is needed
32
+ const needsNuclearCleaning = hasBinaryArtifacts(text);
33
+
34
+ if (needsNuclearCleaning) {
35
+ // Apply nuclear cleaning for binary artifacts
36
+ logger.warn("Binary artifacts detected, applying nuclear cleaning");
37
+ cleanText = nuclearCleanText(sanitizeAIResponse(text));
38
+ } else {
39
+ // Light cleaning for normal text
40
+ cleanText = text
41
+ // Remove null bytes and other control characters
42
+ .replace(/\0/g, "")
43
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
44
+ // Fix common encoding issues
45
+ .replace(/’/g, "'")
46
+ .replace(/â€"/g, "—")
47
+ .replace(/“/g, '"')
48
+ .replace(/�/g, '"')
49
+ .replace(/‘/g, "'")
50
+ .replace(/…/g, "...")
51
+ .replace(/é/g, "é")
52
+ .replace(/è/g, "è")
53
+ .replace(/Ã /g, "à")
54
+ .replace(/â/g, "â")
55
+ .replace(/î/g, "î")
56
+ .replace(/ô/g, "ô")
57
+ .replace(/ç/g, "ç")
58
+ // Fix spacing
59
+ .replace(/\r\n/g, "\n")
60
+ .replace(/\r/g, "\n")
61
+ .replace(/\n{4,}/g, "\n\n\n")
62
+ .trim();
63
+ }
64
+
65
+ return cleanText;
66
+ }
67
+
68
+ /**
69
+ * Retry logic for async operations
70
+ */
71
+ export async function withRetry<T>(
72
+ operation: () => Promise<T>,
73
+ options: {
74
+ attempts?: number;
75
+ delay?: number;
76
+ backoff?: number;
77
+ onRetry?: (attempt: number, error: unknown) => void;
78
+ } = {},
79
+ ): Promise<T> {
80
+ const {
81
+ attempts = CONFIG.RETRY_ATTEMPTS,
82
+ delay = CONFIG.RETRY_DELAY,
83
+ backoff = 2,
84
+ onRetry,
85
+ } = options;
86
+
87
+ let lastError: unknown;
88
+
89
+ for (let attempt = 1; attempt <= attempts; attempt++) {
90
+ try {
91
+ return await operation();
92
+ } catch (error: unknown) {
93
+ lastError = error;
94
+
95
+ // Don't retry on non-recoverable errors
96
+ if (error instanceof GlanceError && !error.recoverable) {
97
+ throw error;
98
+ }
99
+
100
+ // Check for specific non-recoverable HTTP status codes
101
+ if (
102
+ error &&
103
+ typeof error === "object" &&
104
+ "status" in error &&
105
+ typeof error.status === "number" &&
106
+ [400, 401, 403, 404].includes(error.status)
107
+ ) {
108
+ throw error;
109
+ }
110
+
111
+ if (attempt < attempts) {
112
+ const waitTime = delay * backoff ** (attempt - 1);
113
+
114
+ if (onRetry) {
115
+ onRetry(attempt, error);
116
+ } else {
117
+ logger.debug(
118
+ `Retry attempt ${attempt}/${attempts} after ${waitTime}ms`,
119
+ );
120
+ }
121
+
122
+ await new Promise((resolve) => setTimeout(resolve, waitTime));
123
+ }
124
+ }
125
+ }
126
+
127
+ throw lastError;
128
+ }
129
+
130
+ /**
131
+ * Create a spinner with consistent styling
132
+ */
133
+ export function createSpinner(text: string): Ora {
134
+ return ora({
135
+ text,
136
+ spinner: "dots",
137
+ color: "cyan",
138
+ });
139
+ }
140
+
141
+ /**
142
+ * Format file size for display
143
+ */
144
+ export function formatFileSize(bytes: number): string {
145
+ const units = ["B", "KB", "MB", "GB"];
146
+ let size = bytes;
147
+ let unitIndex = 0;
148
+
149
+ while (size >= 1024 && unitIndex < units.length - 1) {
150
+ size /= 1024;
151
+ unitIndex++;
152
+ }
153
+
154
+ return `${size.toFixed(2)} ${units[unitIndex]}`;
155
+ }
156
+
157
+ /**
158
+ * Format duration for display
159
+ */
160
+ export function formatDuration(ms: number): string {
161
+ if (ms < 1000) {
162
+ return `${ms}ms`;
163
+ }
164
+
165
+ const seconds = Math.floor(ms / 1000);
166
+ const minutes = Math.floor(seconds / 60);
167
+
168
+ if (minutes > 0) {
169
+ return `${minutes}m ${seconds % 60}s`;
170
+ }
171
+
172
+ return `${seconds}s`;
173
+ }
174
+
175
+ /**
176
+ * Check if running in TTY environment
177
+ */
178
+ export function isTTY(): boolean {
179
+ return process.stdout.isTTY || false;
180
+ }
181
+
182
+ /**
183
+ * Get terminal width
184
+ */
185
+ export function getTerminalWidth(): number {
186
+ return process.stdout.columns || 80;
187
+ }
188
+
189
+ /**
190
+ * Truncate text to fit terminal width
191
+ */
192
+ export function truncateToWidth(text: string, maxWidth?: number): string {
193
+ const width = maxWidth || getTerminalWidth() - 4; // Leave some margin
194
+
195
+ if (text.length <= width) {
196
+ return text;
197
+ }
198
+
199
+ return `${text.substring(0, width - 3)}...`;
200
+ }
201
+
202
+ /**
203
+ * Parse file extension for export
204
+ */
205
+ export function getFileExtension(filename: string): string {
206
+ const parts = filename.split(".");
207
+ return parts.length > 1 ? (parts[parts.length - 1]?.toLowerCase() ?? "") : "";
208
+ }
209
+
210
+ /**
211
+ * Check if model is a premium model
212
+ */
213
+ export function isPremiumModel(model: string): boolean {
214
+ const premiumPrefixes = ["gpt-4", "claude", "gemini-pro", "gemini-ultra"];
215
+ return premiumPrefixes.some((prefix) =>
216
+ model.toLowerCase().startsWith(prefix),
217
+ );
218
+ }
219
+
220
+ /**
221
+ * Format model name for display
222
+ */
223
+ export function formatModelName(model: string): string {
224
+ // Add provider labels for clarity
225
+ if (model.startsWith("gpt")) {
226
+ return `${model} (OpenAI)`;
227
+ }
228
+ if (model.startsWith("gemini")) {
229
+ return `${model} (Google)`;
230
+ }
231
+ if (
232
+ model.includes("llama") ||
233
+ model.includes("mistral") ||
234
+ model.includes("phi")
235
+ ) {
236
+ return `${model} (Local)`;
237
+ }
238
+ return model;
239
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Input validation utilities for Glance CLI
3
+ */
4
+
5
+ import { LANGUAGE_MAP } from "./config";
6
+
7
+ export function validateURL(urlString: string): {
8
+ valid: boolean;
9
+ error?: string;
10
+ } {
11
+ try {
12
+ const url = new URL(urlString);
13
+
14
+ if (!["http:", "https:"].includes(url.protocol)) {
15
+ return {
16
+ valid: false,
17
+ error: "Only HTTP and HTTPS URLs are supported",
18
+ };
19
+ }
20
+
21
+ if (!url.hostname || url.hostname.length < 3) {
22
+ return {
23
+ valid: false,
24
+ error: "Invalid hostname",
25
+ };
26
+ }
27
+
28
+ return { valid: true };
29
+ } catch (_error) {
30
+ return {
31
+ valid: false,
32
+ error:
33
+ "Invalid URL format. Please provide a valid URL starting with http:// or https://",
34
+ };
35
+ }
36
+ }
37
+
38
+ export function validateLanguage(lang: string): {
39
+ valid: boolean;
40
+ error?: string;
41
+ } {
42
+ const supportedLanguages = Object.keys(LANGUAGE_MAP);
43
+
44
+ if (!supportedLanguages.includes(lang)) {
45
+ return {
46
+ valid: false,
47
+ error: `Language '${lang}' is not supported. Supported languages: ${supportedLanguages.join(", ")}`,
48
+ };
49
+ }
50
+
51
+ return { valid: true };
52
+ }
53
+
54
+ export function validateMaxTokens(value: string | undefined): {
55
+ valid: boolean;
56
+ parsed?: number;
57
+ error?: string;
58
+ } {
59
+ if (!value) {
60
+ return { valid: true };
61
+ }
62
+
63
+ const parsed = parseInt(value, 10);
64
+
65
+ if (Number.isNaN(parsed)) {
66
+ return {
67
+ valid: false,
68
+ error: "max-tokens must be a valid number",
69
+ };
70
+ }
71
+
72
+ if (parsed < 1 || parsed > 100000) {
73
+ return {
74
+ valid: false,
75
+ error: "max-tokens must be between 1 and 100000",
76
+ };
77
+ }
78
+
79
+ return { valid: true, parsed };
80
+ }
81
+
82
+ export async function validateAPIKeys(
83
+ provider: "openai" | "google" | "ollama",
84
+ ): Promise<{ valid: boolean; error?: string; hint?: string }> {
85
+ switch (provider) {
86
+ case "openai": {
87
+ const apiKey = process.env.OPENAI_API_KEY;
88
+ if (!apiKey) {
89
+ return {
90
+ valid: false,
91
+ error: "OpenAI API key not found",
92
+ hint: "Set your API key with: export OPENAI_API_KEY=sk-...",
93
+ };
94
+ }
95
+
96
+ if (!apiKey.startsWith("sk-")) {
97
+ return {
98
+ valid: false,
99
+ error: "Invalid OpenAI API key format",
100
+ hint: "OpenAI API keys should start with 'sk-'",
101
+ };
102
+ }
103
+
104
+ // Basic length check
105
+ if (apiKey.length < 40) {
106
+ return {
107
+ valid: false,
108
+ error: "OpenAI API key appears to be incomplete",
109
+ hint: "Make sure you've copied the entire API key",
110
+ };
111
+ }
112
+
113
+ return { valid: true };
114
+ }
115
+
116
+ case "google": {
117
+ const apiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
118
+ if (!apiKey) {
119
+ return {
120
+ valid: false,
121
+ error: "Gemini API key not found",
122
+ hint: "Set your API key with: export GEMINI_API_KEY=...",
123
+ };
124
+ }
125
+
126
+ // Basic length check for Google API keys
127
+ if (apiKey.length < 30) {
128
+ return {
129
+ valid: false,
130
+ error: "Gemini API key appears to be incomplete",
131
+ hint: "Make sure you've copied the entire API key",
132
+ };
133
+ }
134
+
135
+ return { valid: true };
136
+ }
137
+
138
+ case "ollama": {
139
+ // Ollama doesn't require API keys, just check if it's running
140
+ const endpoint = process.env.OLLAMA_ENDPOINT || "http://localhost:11434";
141
+
142
+ try {
143
+ const controller = new AbortController();
144
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
145
+
146
+ const response = await fetch(`${endpoint}/api/version`, {
147
+ signal: controller.signal,
148
+ });
149
+
150
+ clearTimeout(timeoutId);
151
+
152
+ if (!response.ok) {
153
+ return {
154
+ valid: false,
155
+ error: `Ollama is not responding at ${endpoint}`,
156
+ hint: "Make sure Ollama is running. Start it with: ollama serve",
157
+ };
158
+ }
159
+
160
+ return { valid: true };
161
+ } catch (_error) {
162
+ return {
163
+ valid: false,
164
+ error: `Cannot connect to Ollama at ${endpoint}`,
165
+ hint: "Install and start Ollama: https://ollama.ai/download",
166
+ };
167
+ }
168
+ }
169
+
170
+ default:
171
+ return {
172
+ valid: false,
173
+ error: `Unknown provider: ${provider}`,
174
+ };
175
+ }
176
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Glance CLI - Main entry point
5
+ *
6
+ * This file serves as a thin wrapper that imports and runs
7
+ * the modularized CLI from the cli/ directory.
8
+ *
9
+ */
10
+
11
+ import { runCli } from "./cli/index";
12
+
13
+ // Run the CLI
14
+ runCli().catch((error) => {
15
+ console.error("Fatal error:", error);
16
+ process.exit(1);
17
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Compatibility layer for Bun and Node.js
3
+ */
4
+
5
+ import { execSync } from "node:child_process";
6
+ import * as fs from "node:fs/promises";
7
+
8
+ // Runtime detection
9
+ export const isBun = typeof Bun !== "undefined";
10
+
11
+ // Process arguments
12
+ export const argv = isBun ? Bun.argv : process.argv;
13
+
14
+ // Environment variables
15
+ export const env = isBun ? Bun.env : process.env;
16
+
17
+ // File operations
18
+ export async function writeFile(
19
+ filePath: string,
20
+ content: string | Buffer,
21
+ ): Promise<void> {
22
+ if (isBun) {
23
+ await Bun.write(filePath, content);
24
+ } else {
25
+ await fs.writeFile(filePath, content);
26
+ }
27
+ }
28
+
29
+ export async function readFile(filePath: string): Promise<string> {
30
+ if (isBun) {
31
+ const file = Bun.file(filePath);
32
+ return (await file.exists()) ? await file.text() : "";
33
+ } else {
34
+ try {
35
+ return await fs.readFile(filePath, "utf-8");
36
+ } catch {
37
+ return "";
38
+ }
39
+ }
40
+ }
41
+
42
+ export async function readFileBuffer(filePath: string): Promise<Buffer> {
43
+ if (isBun) {
44
+ const file = Bun.file(filePath);
45
+ const buffer = await file.arrayBuffer();
46
+ return Buffer.from(buffer);
47
+ } else {
48
+ return await fs.readFile(filePath);
49
+ }
50
+ }
51
+
52
+ export async function fileExists(filePath: string): Promise<boolean> {
53
+ if (isBun) {
54
+ return Bun.file(filePath).exists();
55
+ } else {
56
+ try {
57
+ await fs.access(filePath);
58
+ return true;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+ }
64
+
65
+ // Shell operations
66
+ export async function shell(command: string): Promise<void> {
67
+ if (isBun) {
68
+ await Bun.$`sh -c ${command}`.quiet();
69
+ } else {
70
+ execSync(command, { stdio: "ignore" });
71
+ }
72
+ }
73
+
74
+ export async function mkdirp(dirPath: string): Promise<void> {
75
+ await fs.mkdir(dirPath, { recursive: true });
76
+ }
77
+
78
+ export async function rm(filePath: string): Promise<void> {
79
+ try {
80
+ await fs.unlink(filePath);
81
+ } catch {
82
+ // Ignore if file doesn't exist
83
+ }
84
+ }
85
+
86
+ export async function rmrf(dirPath: string): Promise<void> {
87
+ try {
88
+ await fs.rm(dirPath, { recursive: true, force: true });
89
+ } catch {
90
+ // Ignore if directory doesn't exist
91
+ }
92
+ }
93
+
94
+ export async function mv(src: string, dest: string): Promise<void> {
95
+ await fs.rename(src, dest);
96
+ }