termask-ai 1.0.0__py3-none-any.whl

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.
termai_pkg/__init__.py ADDED
@@ -0,0 +1,1244 @@
1
+ import os
2
+ import sys
3
+ import json
4
+ import requests
5
+ import subprocess
6
+ from pathlib import Path
7
+ import copy # Import copy for deepcopy
8
+ import shutil # Import shutil to check for editor availability
9
+
10
+ # --- Configuration Paths (XDG Base Directory Specification) ---
11
+ # https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
12
+ APP_NAME = "termai"
13
+
14
+ # XDG_CONFIG_HOME: user-specific config files (default: ~/.config)
15
+ # This is where credentials and settings live.
16
+ _xdg_config_home = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
17
+ CONFIG_DIR = _xdg_config_home / APP_NAME
18
+ CONFIG_FILE = CONFIG_DIR / "config.json"
19
+
20
+ # XDG_DATA_HOME: user-specific data files (default: ~/.local/share)
21
+ # Reserved for future use (e.g. chat history, caches).
22
+ _xdg_data_home = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
23
+ DATA_DIR = _xdg_data_home / APP_NAME
24
+
25
+ # Legacy paths — used only for one-time migration
26
+ _LEGACY_DATA_DIR = Path.home() / ".local" / "share" / APP_NAME
27
+ _LEGACY_CONFIG_FILE = _LEGACY_DATA_DIR / "config.json"
28
+ OLD_KEY_FILE = _LEGACY_DATA_DIR / "key"
29
+
30
+ # --- Colors ---
31
+ if sys.stdout.isatty():
32
+ GREEN = "\033[92m"
33
+ CYAN = "\033[96m"
34
+ YELLOW = "\033[93m"
35
+ RED = "\033[91m"
36
+ BLUE = "\033[94m"
37
+ RESET = "\033[0m"
38
+ BG_USER = "\033[48;5;99m\033[38;5;255m"
39
+ BG_HEADER = "\033[48;5;24m\033[38;5;255m"
40
+ else:
41
+ GREEN = ""
42
+ CYAN = ""
43
+ YELLOW = ""
44
+ RED = ""
45
+ BLUE = ""
46
+ RESET = ""
47
+ BG_USER = ""
48
+ BG_HEADER = ""
49
+
50
+ # --- Default Settings ---
51
+ # If the config file is deleted/missing, these values are used to recreate it.
52
+ DEFAULT_CONFIG = {
53
+ "active_profile": "gemini-default",
54
+ "profiles": {
55
+ "gemini-default": {
56
+ "provider": "gemini",
57
+ "api_key": "",
58
+ "model_name": "gemini-2.5-flash",
59
+ "system_instruction": "You are a CLI assistant for command-line users. Answer concisely and use clear formatting. Use standard Markdown for headers, bolding, bullet points, and code blocks.",
60
+ "generation_config": {
61
+ "temperature": 0.7,
62
+ "top_p": 0.9,
63
+ "top_k": 40,
64
+ "maxOutputTokens": 1024
65
+ }
66
+ },
67
+ "openai-default": {
68
+ "provider": "openai",
69
+ "base_url": "https://api.openai.com/v1",
70
+ "api_key": "",
71
+ "model_name": "gpt-4o",
72
+ "system_instruction": "You are a CLI assistant for command-line users. Answer concisely and use clear formatting. Use standard Markdown for headers, bolding, bullet points, and code blocks.",
73
+ "temperature": 0.7,
74
+ "max_tokens": 1024
75
+ }
76
+ },
77
+ "proxy": ""
78
+ }
79
+
80
+ def load_config():
81
+ """
82
+ Loads config.json from ~/.config/termai/ (XDG_CONFIG_HOME).
83
+ Auto-migrates config from old ~/.local/share/termai/ location on first run.
84
+ Handles migration from old nested structure and creates default file if missing.
85
+ """
86
+ # Ensure config directory exists
87
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
88
+
89
+ # --- One-time migration from old ~/.local/share/termai/config.json ---
90
+ if not CONFIG_FILE.exists() and _LEGACY_CONFIG_FILE.exists():
91
+ print(f"[{APP_NAME}] Migrating config to ~/.config/termai/config.json (XDG standard)...")
92
+ import shutil as _shutil
93
+ _shutil.copy2(_LEGACY_CONFIG_FILE, CONFIG_FILE)
94
+ # Rename the old file so migration doesn't re-trigger
95
+ _LEGACY_CONFIG_FILE.rename(_LEGACY_CONFIG_FILE.with_suffix(".json.bak"))
96
+ print(f"[{APP_NAME}] Migration complete. Old file backed up as config.json.bak")
97
+
98
+ config = {}
99
+ # 1. Check for Config File
100
+ if CONFIG_FILE.exists():
101
+ try:
102
+ with open(CONFIG_FILE, "r") as f:
103
+ config = json.load(f)
104
+ except json.JSONDecodeError:
105
+ print(f"[Error] Your config file ({CONFIG_FILE}) is invalid JSON.")
106
+ print("Please fix it or delete it to reset defaults.")
107
+ sys.exit(1)
108
+
109
+ # Migration from old static structure to profile-based structure
110
+ if "profiles" not in config:
111
+ print(f"[{APP_NAME}] Migrating config to profile-based structure...")
112
+ old_provider = config.get("provider", "gemini")
113
+ old_proxy = config.get("proxy", "")
114
+
115
+ new_config = copy.deepcopy(DEFAULT_CONFIG)
116
+ new_config["proxy"] = old_proxy
117
+
118
+ if "gemini_config" in config:
119
+ new_config["profiles"]["gemini-default"] = {
120
+ "provider": "gemini",
121
+ "api_key": config["gemini_config"].get("api_key", ""),
122
+ "model_name": config["gemini_config"].get("model_name", "gemini-2.5-flash"),
123
+ "system_instruction": config["gemini_config"].get("system_instruction", DEFAULT_CONFIG["profiles"]["gemini-default"]["system_instruction"]),
124
+ "generation_config": config["gemini_config"].get("generation_config", DEFAULT_CONFIG["profiles"]["gemini-default"]["generation_config"])
125
+ }
126
+
127
+ if "openai_config" in config:
128
+ new_config["profiles"]["openai-default"] = {
129
+ "provider": "openai",
130
+ "base_url": config["openai_config"].get("base_url", "https://api.openai.com/v1"),
131
+ "api_key": config["openai_config"].get("api_key", ""),
132
+ "model_name": config["openai_config"].get("model_name", "gpt-4o"),
133
+ "system_instruction": config["openai_config"].get("system_instruction", DEFAULT_CONFIG["profiles"]["openai-default"]["system_instruction"]),
134
+ "temperature": config["openai_config"].get("temperature", 0.7),
135
+ "max_tokens": config["openai_config"].get("max_tokens", 1024)
136
+ }
137
+
138
+ if old_provider == "openai":
139
+ new_config["active_profile"] = "openai-default"
140
+ else:
141
+ new_config["active_profile"] = "gemini-default"
142
+
143
+ config = new_config
144
+ with open(CONFIG_FILE, "w") as f:
145
+ json.dump(config, f, indent=4)
146
+ print("Migration complete.")
147
+ return new_config
148
+
149
+ # Modernize profiles to have base_url if they are openai provider and missing base_url
150
+ updated = False
151
+ if "profiles" in config:
152
+ for p_name, p_config in config["profiles"].items():
153
+ if p_config.get("provider") == "openai" and "base_url" not in p_config:
154
+ p_config["base_url"] = "https://api.openai.com/v1"
155
+ updated = True
156
+
157
+ # Check for restrictive legacy system instruction
158
+ sys_instr = p_config.get("system_instruction", "")
159
+ if "Do NOT use Markdown" in sys_instr or "Do NOT use backticks" in sys_instr:
160
+ if p_config.get("provider") == "gemini":
161
+ p_config["system_instruction"] = DEFAULT_CONFIG["profiles"]["gemini-default"]["system_instruction"]
162
+ else:
163
+ p_config["system_instruction"] = DEFAULT_CONFIG["profiles"]["openai-default"]["system_instruction"]
164
+ updated = True
165
+
166
+ if updated:
167
+ with open(CONFIG_FILE, "w") as f:
168
+ json.dump(config, f, indent=4)
169
+
170
+ return config
171
+
172
+ # If no config file exists, proceed with first run setup
173
+ # 2. Migration: If no config, check for old key file
174
+ gemini_api_key = ""
175
+ backup_file = DATA_DIR / "key.bak"
176
+ if OLD_KEY_FILE.exists():
177
+ print(f"[{APP_NAME}] Migrating legacy key file to new config format...")
178
+ with open(OLD_KEY_FILE, "r") as f:
179
+ gemini_api_key = f.read().strip()
180
+ OLD_KEY_FILE.rename(backup_file)
181
+
182
+ # 3. First Run Setup
183
+ new_config = copy.deepcopy(DEFAULT_CONFIG)
184
+ if sys.stdin.isatty() and "--complete" not in sys.argv:
185
+ print(f"[{APP_NAME}] First run! Choose your primary AI provider.")
186
+ provider = ""
187
+ while provider not in ["1", "2"]:
188
+ provider = input("Enter 1 for Gemini or 2 for OpenAI: ").strip()
189
+
190
+ if provider == "1":
191
+ new_config["active_profile"] = "gemini-default"
192
+ if not gemini_api_key:
193
+ print(f"[{APP_NAME}] Enter your Gemini API Key. Get it from aistudio.google.com")
194
+ gemini_api_key = input("Gemini API Key: ").strip()
195
+ if not gemini_api_key:
196
+ print("Error: Gemini key cannot be empty.")
197
+ sys.exit(1)
198
+ new_config["profiles"]["gemini-default"]["api_key"] = gemini_api_key
199
+
200
+ elif provider == "2":
201
+ new_config["active_profile"] = "openai-default"
202
+ print(f"[{APP_NAME}] Enter OpenAI Base URL (Press Enter for default: https://api.openai.com/v1)")
203
+ base_url = input("Base URL: ").strip()
204
+ if base_url:
205
+ new_config["profiles"]["openai-default"]["base_url"] = base_url
206
+
207
+ print(f"[{APP_NAME}] Enter your OpenAI or custom API Key.")
208
+ openai_api_key = input("API Key: ").strip()
209
+ if not openai_api_key:
210
+ print("Error: API Key cannot be empty.")
211
+ sys.exit(1)
212
+ new_config["profiles"]["openai-default"]["api_key"] = openai_api_key
213
+
214
+ print(f"[{APP_NAME}] Enter Model Name (Press Enter for default: gpt-4o)")
215
+ model_name = input("Model Name: ").strip()
216
+ if model_name:
217
+ new_config["profiles"]["openai-default"]["model_name"] = model_name
218
+ else:
219
+ # Default to Gemini if non-interactive and no config exists
220
+ if not gemini_api_key:
221
+ return None # Cannot proceed without an API key
222
+ new_config["profiles"]["gemini-default"]["api_key"] = gemini_api_key
223
+
224
+ # Save the new configuration
225
+ with open(CONFIG_FILE, "w") as f:
226
+ json.dump(new_config, f, indent=4)
227
+
228
+ print(f"Configuration saved to {CONFIG_FILE}\n")
229
+
230
+ # Clean up the legacy key backup file if it exists after migration
231
+ if backup_file.exists():
232
+ backup_file.unlink()
233
+
234
+ return new_config
235
+
236
+ def open_editor():
237
+ """Opens config.json in the user's terminal editor."""
238
+ # 1. Prioritize the user's explicit choice
239
+ editor = os.getenv('EDITOR')
240
+
241
+ # 2. If no $EDITOR, try to find 'vim'
242
+ if not editor and shutil.which('vim'):
243
+ editor = 'vim'
244
+
245
+ # 3. If still no editor, fall back to 'nano'
246
+ if not editor:
247
+ editor = 'nano'
248
+
249
+ print(f"Opening config in {editor}...")
250
+ try:
251
+ subprocess.call([editor, str(CONFIG_FILE)])
252
+ except FileNotFoundError:
253
+ print(f"[Error] Editor '{editor}' not found. Please install it or set the $EDITOR environment variable.")
254
+ return 1
255
+ return 0 # Return 0 for success
256
+
257
+ def print_help():
258
+ """Prints the help menu with available commands."""
259
+ help_markdown = """
260
+ # Termai - A CLI AI Assistant
261
+ A lightweight CLI tool for AI integration in your terminal.
262
+
263
+ ## Usage
264
+ * `ai [OPTIONS] "YOUR QUERY"`
265
+ * `cat file.txt | ai [OPTIONS] "OPTIONAL PROMPT"`
266
+
267
+ ## Options
268
+ * `-i`, `--chat`, `chat` : Start an interactive chat session
269
+ * `-p`, `--profile [name]` : Run query using or switching temporarily to a profile
270
+ * `-m`, `--model [name]` : List available Gemini models or set a specific one
271
+ * `profile [action]` : Profile management: `list`, `use`, `add`, `remove` (or `rm`)
272
+ * `completion [shell]` : Generate shell auto-completion script (`bash` or `zsh`)
273
+ * `-o`, `--save <file>` : Save the response or chat session to a file
274
+ * `--config` : Open configuration file
275
+ * `--debug` : Enable debug mode
276
+ * `--debug-config` : Print the loaded configuration (redacts keys)
277
+ * `--help`, `-h` : Show this help message
278
+ * `--reinstall` : Re-run the first-time setup
279
+
280
+ ## Legacy Profile Flags (deprecated)
281
+ * `--profiles` : List all configured profiles
282
+ * `--use [name]` : Set active profile default
283
+ * `--profile-add <name>` : Add a new custom profile
284
+ * `--profile-remove <n>` : Remove a profile
285
+
286
+ ## Examples
287
+ * `ai "How do I unzip a tar file?"`
288
+ * `ai chat`
289
+ * `ai chat -o session.md`
290
+ * `ai profile use`
291
+ * `ai -p local-ollama "What is Python?"`
292
+ * `ai --model`
293
+ * `cat error.log | ai "Explain this error briefly"`
294
+ """
295
+ print(render_markdown(help_markdown.strip()))
296
+ return 0 # Return 0 for success
297
+
298
+ def handle_completion(config):
299
+ """Generates autocomplete suggestions for bash/zsh tab completion."""
300
+ try:
301
+ complete_idx = sys.argv.index("--complete")
302
+ cword_idx = sys.argv.index("--cword")
303
+ # Extract command line words
304
+ words = sys.argv[complete_idx + 1:cword_idx]
305
+ cword = int(sys.argv[cword_idx + 1])
306
+ except (ValueError, IndexError):
307
+ return 0
308
+
309
+ if cword < 0 or cword >= len(words):
310
+ return 0
311
+
312
+ cur = words[cword]
313
+ suggestions = []
314
+
315
+ # Case 1: First argument completion (ai [tab] or ai ch[tab])
316
+ if cword == 1:
317
+ suggestions = [
318
+ "chat", "profile", "completion", "help",
319
+ "-i", "--chat", "-p", "--profile", "-m", "--model",
320
+ "--profiles", "--use", "--profile-add", "--profile-remove",
321
+ "--config", "--debug", "--debug-config", "--help", "-h", "--reinstall"
322
+ ]
323
+
324
+ # Case 2: Subcommands/Options under 'profile'
325
+ elif cword == 2 and words[1] == "profile":
326
+ suggestions = ["list", "use", "set", "add", "remove", "rm", "help", "--help", "-h"]
327
+
328
+ # Case 3: Profile names for 'profile use/set/remove/rm'
329
+ elif cword == 3 and words[1] == "profile" and words[2] in ["use", "set", "remove", "rm"]:
330
+ if config:
331
+ suggestions = list(config.get("profiles", {}).keys())
332
+
333
+ # Case 4: Profile names for legacy/temporary profile flags
334
+ elif cword >= 2 and words[cword - 1] in ["--use", "--profile-remove", "--profile", "-p"]:
335
+ if config:
336
+ suggestions = list(config.get("profiles", {}).keys())
337
+
338
+ # Case 5: Model names for --model/-m
339
+ elif cword >= 2 and words[cword - 1] in ["--model", "-m"]:
340
+ suggestions = ["gemini-2.5-flash", "gemini-2.5-pro", "gpt-4o", "gpt-4o-mini"]
341
+
342
+ # Case 6: Shell options for 'completion'
343
+ elif cword == 2 and words[1] == "completion":
344
+ suggestions = ["bash", "zsh"]
345
+
346
+ # Filter and print matching suggestions
347
+ matches = [s for s in suggestions if s.startswith(cur)]
348
+ for m in matches:
349
+ print(m)
350
+ return 0
351
+
352
+ def visual_len(s):
353
+ """Calculates the visual column width of a string in the terminal, accounting for double-width wide characters/emojis."""
354
+ length = 0
355
+ for char in s:
356
+ if ord(char) > 0x2000:
357
+ length += 2
358
+ else:
359
+ length += 1
360
+ return length
361
+
362
+ def visual_ljust(s, width):
363
+ """Pads a string with spaces to a visual width, rather than character count width."""
364
+ v_len = visual_len(s)
365
+ needed = width - v_len
366
+ if needed > 0:
367
+ return s + (" " * needed)
368
+ return s
369
+
370
+ def save_chat_history(history, filename, provider, target_profile, model_name):
371
+ """Saves the chat history to a file in a clean Markdown format."""
372
+ from datetime import datetime
373
+ try:
374
+ filepath = Path(filename).resolve()
375
+ filepath.parent.mkdir(parents=True, exist_ok=True)
376
+
377
+ with open(filepath, "w") as f:
378
+ f.write(f"# Termai Chat Session\n")
379
+ f.write(f"Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
380
+ f.write(f"Profile: {target_profile} | Provider: {provider.capitalize()} | Model: {model_name}\n\n")
381
+ f.write(f"---\n\n")
382
+
383
+ for msg in history:
384
+ if provider == "gemini":
385
+ role = "You" if msg.get("role") == "user" else "AI"
386
+ text = msg.get("parts", [{}])[0].get("text", "")
387
+ else: # openai
388
+ role = "You" if msg.get("role") == "user" else "AI"
389
+ text = msg.get("content", "")
390
+
391
+ f.write(f"### {role}\n{text}\n\n")
392
+
393
+ print(f"{GREEN}[✓] Chat history saved successfully to: {filepath}{RESET}")
394
+ return True
395
+ except Exception as e:
396
+ print(f"{RED}[Error] Failed to save chat history: {e}{RESET}")
397
+ return False
398
+
399
+ def save_single_response(text, filename):
400
+ """Saves a single AI response to a file."""
401
+ try:
402
+ filepath = Path(filename).resolve()
403
+ filepath.parent.mkdir(parents=True, exist_ok=True)
404
+ with open(filepath, "w") as f:
405
+ f.write(text.strip() + "\n")
406
+ print(f"\n{GREEN}[✓] Response saved to: {filepath}{RESET}")
407
+ except Exception as e:
408
+ print(f"\n{RED}[Error] Failed to save response: {e}{RESET}")
409
+
410
+ def print_user_message(prompt_text, message_text):
411
+ """Prints a styled user message block with full-width background color and clean word wrapping."""
412
+ if not BG_USER:
413
+ print(f"{prompt_text}{message_text}")
414
+ return
415
+
416
+ import shutil
417
+ import textwrap
418
+
419
+ try:
420
+ cols = shutil.get_terminal_size().columns
421
+ except Exception:
422
+ cols = 80
423
+
424
+ width = cols - 1 if cols > 1 else 1
425
+
426
+ lines = textwrap.wrap(message_text, width=width, initial_indent=prompt_text, subsequent_indent=" " * len(prompt_text))
427
+ for line in lines:
428
+ padded = visual_ljust(line, width)
429
+ print(f"{BG_USER}{padded}{RESET}")
430
+
431
+ def print_header_block(target_profile, provider, model_name):
432
+ """Prints the chat session header block with full-width background color."""
433
+ if not BG_HEADER:
434
+ print(f"\n💬 Termai Interactive Chat Session")
435
+ print(f"Using Profile: {target_profile} | Provider: {provider.capitalize()} | Model: {model_name}")
436
+ print(f"Type exit or quit (or Ctrl+D) to end the chat.\n")
437
+ return
438
+
439
+ import shutil
440
+ import textwrap
441
+
442
+ try:
443
+ cols = shutil.get_terminal_size().columns
444
+ except Exception:
445
+ cols = 80
446
+
447
+ width = cols - 1 if cols > 1 else 1
448
+
449
+ title = "💬 Termai Interactive Chat Session"
450
+ details = f"Using Profile: {target_profile} | Provider: {provider.capitalize()} | Model: {model_name}"
451
+ info = "Type exit or quit (or Ctrl+D) to end the chat."
452
+
453
+ lines = []
454
+ for text in [title, details, info]:
455
+ wrapped = textwrap.wrap(text, width=width - 4)
456
+ for w in wrapped:
457
+ lines.append(f" {w}")
458
+
459
+ pad = " " * width
460
+ print()
461
+ print(f"{BG_HEADER}{pad}{RESET}")
462
+ for line in lines:
463
+ padded = visual_ljust(line, width)
464
+ print(f"{BG_HEADER}{padded}{RESET}")
465
+ print(f"{BG_HEADER}{pad}{RESET}")
466
+
467
+ def list_profiles(config):
468
+ """Displays a formatted list of all configured profiles and indicates which is currently active."""
469
+ profiles = config.get("profiles", {})
470
+ active = config.get("active_profile", "")
471
+
472
+ print(f"\n{BLUE}💬 Configured Profiles:{RESET}")
473
+ for idx, p_name in enumerate(profiles.keys(), 1):
474
+ is_active = f" {GREEN}(active){RESET}" if p_name == active else ""
475
+ p_config = profiles[p_name]
476
+ prov = p_config.get("provider", "gemini")
477
+ model = p_config.get("model_name", "")
478
+ extra = f" ({p_config['base_url']})" if prov == "openai" and "base_url" in p_config else ""
479
+ print(f" {CYAN}{idx}. {p_name}{RESET} [{YELLOW}{prov}{RESET}] -> {model}{extra}{is_active}")
480
+ print()
481
+ return 0
482
+
483
+ def switch_profile(config, profile_name=None):
484
+ """Changes the active profile globally, either directly or via an interactive selection list."""
485
+ profiles = config.get("profiles", {})
486
+
487
+ if not profile_name:
488
+ # Interactive Selection list
489
+ print(f"\n{BLUE}💬 Select a profile to set as default active profile:{RESET}")
490
+ profile_list = list(profiles.keys())
491
+ active = config.get("active_profile", "")
492
+
493
+ for idx, p_name in enumerate(profile_list, 1):
494
+ is_active = f" {GREEN}(active){RESET}" if p_name == active else ""
495
+ p_config = profiles[p_name]
496
+ prov = p_config.get("provider", "gemini")
497
+ model = p_config.get("model_name", "")
498
+ print(f" {CYAN}{idx}. {p_name}{RESET} [{YELLOW}{prov}{RESET}] -> {model}{is_active}")
499
+
500
+ try:
501
+ choice = input(f"\nSelect a profile number to set as active (or press Enter to cancel): ").strip()
502
+ if not choice:
503
+ print("Cancelled.")
504
+ return 0
505
+ choice_idx = int(choice) - 1
506
+ if 0 <= choice_idx < len(profile_list):
507
+ profile_name = profile_list[choice_idx]
508
+ else:
509
+ print(f"{RED}[!] Invalid choice.{RESET}")
510
+ return 1
511
+ except ValueError:
512
+ print(f"{RED}[!] Invalid number entered.{RESET}")
513
+ return 1
514
+ except (KeyboardInterrupt, EOFError):
515
+ print("\nCancelled.")
516
+ return 0
517
+
518
+ if profile_name in profiles:
519
+ config["active_profile"] = profile_name
520
+ with open(CONFIG_FILE, "w") as f:
521
+ json.dump(config, f, indent=4)
522
+ print(f"{GREEN}[✓] Default active profile switched successfully to: {profile_name}{RESET}")
523
+ return 0
524
+ else:
525
+ print(f"{RED}[Error] Profile '{profile_name}' not found.{RESET}")
526
+ return 1
527
+
528
+ def add_profile(config, profile_name):
529
+ """Adds a new profile to config.json interactively."""
530
+ profiles = config.get("profiles", {})
531
+ if profile_name in profiles:
532
+ print(f"{RED}[Error] Profile '{profile_name}' already exists.{RESET}")
533
+ return 1
534
+
535
+ print(f"\n{BLUE}🚀 Adding new profile: {profile_name}{RESET}")
536
+ print("Choose profile provider type:")
537
+ print(" 1. Gemini")
538
+ print(" 2. OpenAI (or OpenAI-compatible custom endpoint)")
539
+
540
+ provider_type = ""
541
+ while provider_type not in ["1", "2"]:
542
+ try:
543
+ provider_type = input("Choice [1-2]: ").strip()
544
+ except (KeyboardInterrupt, EOFError):
545
+ print("\nCancelled.")
546
+ return 0
547
+
548
+ new_profile = {}
549
+ if provider_type == "1":
550
+ new_profile["provider"] = "gemini"
551
+ print(f"[{APP_NAME}] Enter your Gemini API Key. Get it from aistudio.google.com")
552
+ api_key = input("Gemini API Key: ").strip()
553
+ if not api_key:
554
+ print("Error: Key cannot be empty.")
555
+ return 1
556
+ new_profile["api_key"] = api_key
557
+ new_profile["model_name"] = "gemini-2.5-flash"
558
+ new_profile["system_instruction"] = DEFAULT_CONFIG["profiles"]["gemini-default"]["system_instruction"]
559
+ new_profile["generation_config"] = DEFAULT_CONFIG["profiles"]["gemini-default"]["generation_config"]
560
+ else:
561
+ new_profile["provider"] = "openai"
562
+ print(f"[{APP_NAME}] Enter OpenAI/Custom Base URL (Press Enter for default: https://api.openai.com/v1)")
563
+ base_url = input("Base URL: ").strip()
564
+ new_profile["base_url"] = base_url if base_url else "https://api.openai.com/v1"
565
+
566
+ print(f"[{APP_NAME}] Enter your OpenAI/Custom API Key.")
567
+ api_key = input("API Key: ").strip()
568
+ if not api_key:
569
+ print("Error: Key cannot be empty.")
570
+ return 1
571
+ new_profile["api_key"] = api_key
572
+
573
+ print(f"[{APP_NAME}] Enter Model Name (Press Enter for default: gpt-4o)")
574
+ model_name = input("Model Name: ").strip()
575
+ new_profile["model_name"] = model_name if model_name else "gpt-4o"
576
+ new_profile["system_instruction"] = DEFAULT_CONFIG["profiles"]["openai-default"]["system_instruction"]
577
+ new_profile["temperature"] = 0.7
578
+ new_profile["max_tokens"] = 1024
579
+
580
+ config["profiles"][profile_name] = new_profile
581
+ with open(CONFIG_FILE, "w") as f:
582
+ json.dump(config, f, indent=4)
583
+ print(f"{GREEN}[✓] Profile '{profile_name}' created successfully!{RESET}")
584
+ return 0
585
+
586
+ def remove_profile(config, profile_name):
587
+ """Deletes a profile from config.json."""
588
+ profiles = config.get("profiles", {})
589
+ active = config.get("active_profile", "")
590
+
591
+ if profile_name not in profiles:
592
+ print(f"{RED}[Error] Profile '{profile_name}' not found.{RESET}")
593
+ return 1
594
+
595
+ if profile_name == active:
596
+ print(f"{RED}[Error] Cannot remove currently active profile '{profile_name}'. Please switch to another profile first.{RESET}")
597
+ return 1
598
+
599
+ del config["profiles"][profile_name]
600
+ with open(CONFIG_FILE, "w") as f:
601
+ json.dump(config, f, indent=4)
602
+ print(f"{GREEN}[✓] Profile '{profile_name}' deleted successfully.{RESET}")
603
+ return 0
604
+
605
+ def handle_model_option(config):
606
+ """Fetches and displays available Gemini models interactively, or directly sets the model if specified."""
607
+ active_profile = config.get("active_profile", "")
608
+ profiles = config.get("profiles", {})
609
+ profile_config = profiles.get(active_profile, {})
610
+
611
+ provider = profile_config.get("provider", "gemini")
612
+ if provider != "gemini":
613
+ print(f"{YELLOW}[*] Model listing/switching is currently supported for profiles using the Gemini provider.{RESET}")
614
+ return 0
615
+
616
+ api_key = profile_config.get("api_key")
617
+ current_model = profile_config.get("model_name", "gemini-2.5-flash")
618
+
619
+ # Check if a model argument is provided after the flag
620
+ model_arg = ""
621
+ model_flags = ["--model", "-m"]
622
+ for i, arg in enumerate(sys.argv):
623
+ if arg in model_flags and i + 1 < len(sys.argv):
624
+ # Ensure it is not another flag
625
+ if not sys.argv[i + 1].startswith("-"):
626
+ model_arg = sys.argv[i + 1].strip()
627
+ break
628
+
629
+ if model_arg:
630
+ clean_model = model_arg.replace("models/", "")
631
+ config["profiles"][active_profile]["model_name"] = clean_model
632
+ with open(CONFIG_FILE, "w") as f:
633
+ json.dump(config, f, indent=4)
634
+ print(f"{GREEN}[✓] Model for profile '{active_profile}' updated successfully to: {clean_model}{RESET}")
635
+ return 0
636
+
637
+ if not api_key:
638
+ print(f"{RED}[Error] Gemini API key not found for active profile '{active_profile}'. Please configure it first.{RESET}")
639
+ return 1
640
+
641
+ print(f"{BLUE}[*] Fetching available models from Gemini API...{RESET}")
642
+ api_url = f"https://generativelanguage.googleapis.com/v1beta/models?key={api_key}"
643
+ try:
644
+ response = requests.get(api_url)
645
+ if response.status_code != 200:
646
+ print(f"{RED}[Error {response.status_code}] Failed to fetch models: {response.text}{RESET}")
647
+ return 1
648
+ data = response.json()
649
+ models = data.get("models", [])
650
+
651
+ generation_models = []
652
+ for m in models:
653
+ name = m.get("name", "")
654
+ if "generateContent" in m.get("supportedGenerationMethods", []):
655
+ short_name = name.replace("models/", "")
656
+ display_name = m.get("displayName", short_name)
657
+ desc = m.get("description", "No description available.")
658
+ generation_models.append({
659
+ "name": short_name,
660
+ "displayName": display_name,
661
+ "description": desc
662
+ })
663
+
664
+ if not generation_models:
665
+ print(f"{YELLOW}[!] No text generation models returned from Gemini API.{RESET}")
666
+ return 0
667
+
668
+ print(f"\n{BLUE}🔍 Available Gemini Text Models:{RESET}")
669
+ for idx, m in enumerate(generation_models, 1):
670
+ is_current = f" {GREEN}(active){RESET}" if m["name"] == current_model else ""
671
+ print(f" {CYAN}{idx}. {m['displayName']}{RESET} [{YELLOW}{m['name']}{RESET}]{is_current}")
672
+ print(f" {m['description']}")
673
+ print()
674
+
675
+ try:
676
+ choice = input(f"Select a model number to set as active (or press Enter to cancel): ").strip()
677
+ if not choice:
678
+ print("Cancelled.")
679
+ return 0
680
+ choice_idx = int(choice) - 1
681
+ if 0 <= choice_idx < len(generation_models):
682
+ selected_model = generation_models[choice_idx]["name"]
683
+ config["profiles"][active_profile]["model_name"] = selected_model
684
+ with open(CONFIG_FILE, "w") as f:
685
+ json.dump(config, f, indent=4)
686
+ print(f"{GREEN}[✓] Gemini model for profile '{active_profile}' successfully updated to: {selected_model}{RESET}")
687
+ else:
688
+ print(f"{RED}[!] Invalid choice.{RESET}")
689
+ except ValueError:
690
+ print(f"{RED}[!] Invalid number entered.{RESET}")
691
+ except (KeyboardInterrupt, EOFError):
692
+ print("\nCancelled.")
693
+
694
+ return 0
695
+ except Exception as e:
696
+ print(f"{RED}[Connection Error] Failed to contact Gemini API: {e}{RESET}")
697
+ return 1
698
+
699
+ def render_markdown(text):
700
+ """Renders basic Markdown beautifully in terminal using ANSI escape codes, with an optional rich-library fallback."""
701
+ if not sys.stdout.isatty():
702
+ return text
703
+
704
+ # Try to use rich library if available
705
+ try:
706
+ from rich.console import Console
707
+ from rich.markdown import Markdown
708
+ import io
709
+ string_io = io.StringIO()
710
+ console = Console(file=string_io, force_terminal=True)
711
+ console.print(Markdown(text))
712
+ return string_io.getvalue().strip()
713
+ except ImportError:
714
+ pass
715
+
716
+ # High-fidelity custom ANSI renderer fallback
717
+ lines = text.split("\n")
718
+ rendered_lines = []
719
+ in_code_block = False
720
+ code_lang = ""
721
+
722
+ for line in lines:
723
+ # 1. Handle Code Block boundaries
724
+ if line.strip().startswith("```"):
725
+ if not in_code_block:
726
+ in_code_block = True
727
+ code_lang = line.strip()[3:].strip()
728
+ lang_str = f" {code_lang.upper()} " if code_lang else " CODE "
729
+ border = f"\033[96m┌──────────────────{lang_str}──────────────────\033[0m"
730
+ rendered_lines.append(border)
731
+ else:
732
+ in_code_block = False
733
+ border = "\033[96m└───────────────────────────────────────────────\033[0m"
734
+ rendered_lines.append(border)
735
+ continue
736
+
737
+ # 2. Inside Code Block
738
+ if in_code_block:
739
+ rendered_lines.append(f"\033[93m{line}\033[0m")
740
+ continue
741
+
742
+ # 3. Headers (# Header, ## Header, etc.)
743
+ if line.strip().startswith("#"):
744
+ stripped = line.strip()
745
+ header_text = stripped.lstrip("#").strip()
746
+ rendered_lines.append(f"\033[1;96m✦ {header_text}\033[0m")
747
+ continue
748
+
749
+ # 4. Bullet Points (* item, - item, etc.)
750
+ stripped_line = line.lstrip()
751
+ indent = line[:len(line) - len(stripped_line)]
752
+ if stripped_line.startswith("* ") or stripped_line.startswith("- ") or stripped_line.startswith("+ "):
753
+ bullet_text = stripped_line[2:]
754
+ rendered_lines.append(f"{indent}\033[92m•\033[0m {bullet_text}")
755
+ continue
756
+
757
+ # 5. Inline formatting (Bold, Italic, Inline Code)
758
+ formatted_line = line
759
+ import re
760
+ formatted_line = re.sub(r'`([^`]+)`', r'\033[96m\1\033[0m', formatted_line)
761
+ formatted_line = re.sub(r'\*\*([^*]+)\*\*', r'\033[1m\1\033[22m', formatted_line)
762
+ formatted_line = re.sub(r'\*([^*]+)\*', r'\033[3m\1\033[23m', formatted_line)
763
+
764
+ rendered_lines.append(formatted_line)
765
+
766
+ return "\n".join(rendered_lines)
767
+
768
+ def send_gemini_request(profile_config, user_input, debug_mode, proxy="", history=None, output_file=None):
769
+ api_key = profile_config.get("api_key")
770
+ model_name = profile_config.get("model_name", "gemini-2.5-flash")
771
+ system_instr = profile_config.get("system_instruction", "")
772
+ gen_config = profile_config.get("generation_config", {})
773
+ api_url = f"https://generativelanguage.googleapis.com/v1beta/models/{model_name}:generateContent?key={api_key}"
774
+
775
+ payload_contents = history if history is not None else [{"parts": [{"text": user_input}]}]
776
+ payload = {
777
+ "contents": payload_contents,
778
+ "systemInstruction": {"parts": [{"text": system_instr}]},
779
+ "generationConfig": gen_config
780
+ }
781
+ if debug_mode: print(f"[Debug] Provider: Gemini | Model: {model_name} | Temp: {gen_config.get('temperature')} | Proxy: {proxy if proxy else 'None'}")
782
+ try:
783
+ proxies = {"http": proxy, "https": proxy} if proxy else None
784
+ response = requests.post(api_url, json=payload, proxies=proxies)
785
+ if debug_mode:
786
+ print(f"[Debug] Status: {response.status_code}")
787
+ if response.status_code != 200:
788
+ if response.status_code == 429:
789
+ print(f"\n[Error 429] You have exceeded your Gemini API quota.")
790
+ print("Please check your usage and billing details at aistudio.google.com.")
791
+ else:
792
+ print(f"\n[Error {response.status_code}]")
793
+ print(response.text)
794
+ return 1
795
+ data = response.json()
796
+ if "promptFeedback" in data and "blockReason" in data["promptFeedback"]:
797
+ print(f"[Blocked] Reason: {data['promptFeedback']['blockReason']}")
798
+ return 0
799
+ if "candidates" in data and data["candidates"]:
800
+ cand = data["candidates"][0]
801
+ if "content" in cand and "parts" in cand["content"] and cand["content"]["parts"]:
802
+ response_text = cand['content']['parts'][0]['text']
803
+ rendered_text = render_markdown(response_text)
804
+ print(rendered_text.strip())
805
+ if history is not None:
806
+ history.append({"role": "model", "parts": [{"text": response_text}]})
807
+ if output_file and history is None:
808
+ save_single_response(response_text, output_file)
809
+ else:
810
+ print("[No content returned]")
811
+ if debug_mode: print(data)
812
+ else:
813
+ print("[Error] Invalid response format from Gemini")
814
+ if debug_mode: print(data)
815
+ return 0
816
+ except Exception as e:
817
+ print(f"\n[Connection Error] {e}")
818
+ return 1
819
+
820
+ def send_openai_request(profile_config, user_input, debug_mode, proxy="", history=None, output_file=None):
821
+ api_key = profile_config.get("api_key")
822
+ model_name = profile_config.get("model_name", "gpt-4o")
823
+ system_instr = profile_config.get("system_instruction", "")
824
+ temperature = profile_config.get("temperature", 0.7)
825
+ max_tokens = profile_config.get("max_tokens", 1024)
826
+ base_url = profile_config.get("base_url", "https://api.openai.com/v1")
827
+ # Form the completions endpoint URL robustly
828
+ if base_url.endswith("/"):
829
+ base_url = base_url[:-1]
830
+
831
+ if base_url.endswith("/chat/completions"):
832
+ api_url = base_url
833
+ else:
834
+ api_url = f"{base_url}/chat/completions"
835
+
836
+ headers = {
837
+ "Content-Type": "application/json",
838
+ "Authorization": f"Bearer {api_key}"
839
+ }
840
+
841
+ if history is not None:
842
+ payload_messages = [{"role": "system", "content": system_instr}] + history
843
+ else:
844
+ payload_messages = [
845
+ {"role": "system", "content": system_instr},
846
+ {"role": "user", "content": user_input}
847
+ ]
848
+
849
+ payload = {
850
+ "model": model_name,
851
+ "messages": payload_messages,
852
+ "temperature": temperature,
853
+ "max_tokens": max_tokens
854
+ }
855
+ if debug_mode: print(f"[Debug] Provider: OpenAI | Model: {model_name} | Temp: {temperature} | Proxy: {proxy if proxy else 'None'}")
856
+ try:
857
+ proxies = {"http": proxy, "https": proxy} if proxy else None
858
+ response = requests.post(api_url, headers=headers, json=payload, proxies=proxies)
859
+ if debug_mode:
860
+ print(f"[Debug] Status: {response.status_code}")
861
+ if response.status_code != 200:
862
+ if response.status_code == 429:
863
+ print(f"\n[Error 429] You have exceeded your OpenAI API quota.")
864
+ print("Please check your usage and billing details at platform.openai.com.")
865
+ else:
866
+ print(f"\n[Error {response.status_code}]")
867
+ print(response.text)
868
+ return 1
869
+ data = response.json()
870
+ if "choices" in data and data["choices"]:
871
+ message = data["choices"][0].get("message", {})
872
+ content = message.get("content", "")
873
+ if content:
874
+ rendered_text = render_markdown(content)
875
+ print(rendered_text.strip())
876
+ if history is not None:
877
+ history.append({"role": "assistant", "content": content})
878
+ if output_file and history is None:
879
+ save_single_response(content, output_file)
880
+ else:
881
+ print("[No content returned]")
882
+ if debug_mode: print(data)
883
+ else:
884
+ print("[Error] Invalid response format from OpenAI")
885
+ if debug_mode: print(data)
886
+ return 0
887
+ except Exception as e:
888
+ print(f"\n[Connection Error] {e}")
889
+ return 1
890
+
891
+ def cli_entry_point():
892
+ # Handle --reinstall flag first
893
+ if "--reinstall" in sys.argv:
894
+ if CONFIG_FILE.exists():
895
+ print(f"[{APP_NAME}] Deleting existing config for reinstall...")
896
+ CONFIG_FILE.unlink()
897
+ else:
898
+ print(f"[{APP_NAME}] No existing config found. Starting first-time setup...")
899
+
900
+ config = load_config()
901
+
902
+ if "--complete" in sys.argv:
903
+ return handle_completion(config)
904
+
905
+ if "--reinstall" in sys.argv:
906
+ print(f"[{APP_NAME}] Reinstall complete.")
907
+ return 0
908
+
909
+ # Handle --debug-config flag
910
+ if "--debug-config" in sys.argv:
911
+ if not config:
912
+ print("[Error] No configuration file found. Run `ai --reinstall` to create one.")
913
+ return 1
914
+
915
+ debug_config = copy.deepcopy(config)
916
+ if "profiles" in debug_config:
917
+ for p_name in debug_config["profiles"]:
918
+ p_cfg = debug_config["profiles"][p_name]
919
+ if "api_key" in p_cfg:
920
+ key = p_cfg["api_key"]
921
+ p_cfg["api_key"] = f"***{key[-4:]}" if key else ""
922
+
923
+ print(json.dumps(debug_config, indent=4))
924
+ return 0
925
+
926
+ if config is None and not sys.stdin.isatty():
927
+ return 1
928
+
929
+ # Handle 'profile' subcommand
930
+ if len(sys.argv) > 1 and sys.argv[1] == "profile" and sys.stdin.isatty():
931
+ subcommand = sys.argv[2] if len(sys.argv) > 2 else "list"
932
+
933
+ if subcommand in ["--help", "-h", "help"]:
934
+ print(f"\n{GREEN}Termai Profile Management{RESET}")
935
+ print(f"Manage different AI configuration profiles.\n")
936
+ print(f"{YELLOW}Usage:{RESET}")
937
+ print(f" ai profile list List all configured profiles (alias: ai profile)")
938
+ print(f" ai profile use [name] Set a profile as the active default (interactive if no name)")
939
+ print(f" ai profile add <name> Add a new custom profile")
940
+ print(f" ai profile remove <name> Remove a profile (alias: rm)")
941
+ return 0
942
+
943
+ elif subcommand == "list":
944
+ return list_profiles(config)
945
+
946
+ elif subcommand in ["use", "set"]:
947
+ profile_name = sys.argv[3] if len(sys.argv) > 3 else None
948
+ return switch_profile(config, profile_name)
949
+
950
+ elif subcommand == "add":
951
+ if len(sys.argv) > 3:
952
+ return add_profile(config, sys.argv[3])
953
+ else:
954
+ print(f"{RED}[Error] Please provide a name for the new profile: ai profile add <name>{RESET}")
955
+ return 1
956
+
957
+ elif subcommand in ["remove", "rm"]:
958
+ if len(sys.argv) > 3:
959
+ return remove_profile(config, sys.argv[3])
960
+ else:
961
+ print(f"{RED}[Error] Please provide a profile name to remove: ai profile remove <name>{RESET}")
962
+ return 1
963
+ else:
964
+ print(f"{RED}[Error] Unknown profile subcommand '{subcommand}'.")
965
+ print(f"Run 'ai profile --help' to see available commands.{RESET}")
966
+ return 1
967
+
968
+ # Handle 'completion' subcommand
969
+ if len(sys.argv) > 1 and sys.argv[1] == "completion" and sys.stdin.isatty():
970
+ shell = sys.argv[2] if len(sys.argv) > 2 else None
971
+ if not shell:
972
+ print(f"{RED}[Error] Please specify a shell: ai completion [bash|zsh]{RESET}")
973
+ return 1
974
+
975
+ if shell == "bash":
976
+ print(f"""# Bash completion for termai (ai)
977
+ _ai_completion() {{
978
+ local cur
979
+ COMPREPLY=()
980
+ cur="${{COMP_WORDS[COMP_CWORD]}}"
981
+ local IFS=$'\\n'
982
+ COMPREPLY=( $(ai --complete "${{COMP_WORDS[@]}}" --cword "$COMP_CWORD") )
983
+ return 0
984
+ }}
985
+ complete -F _ai_completion ai""")
986
+ return 0
987
+ elif shell == "zsh":
988
+ print(f"""# Zsh completion for termai (ai)
989
+ (( $+functions[compdef] )) || {{ autoload -Uz compinit && compinit; }}
990
+ _ai_completion() {{
991
+ local -a replies
992
+ local IFS=$'\\n'
993
+ replies=($(ai --complete "${{words[@]}}" --cword $((CURRENT-1))))
994
+ compadd -a replies
995
+ }}
996
+ compdef _ai_completion ai""")
997
+ return 0
998
+ else:
999
+ print(f"{RED}[Error] Unsupported shell '{shell}'. Supported: bash, zsh.{RESET}")
1000
+ return 1
1001
+
1002
+ if "--help" in sys.argv or "-h" in sys.argv:
1003
+ return print_help()
1004
+
1005
+ if "--config" in sys.argv:
1006
+ return open_editor()
1007
+
1008
+ # Legacy Profile management options
1009
+ if "--profiles" in sys.argv:
1010
+ return list_profiles(config)
1011
+
1012
+ if "--use" in sys.argv:
1013
+ idx = sys.argv.index("--use")
1014
+ profile_name = sys.argv[idx + 1] if idx + 1 < len(sys.argv) else None
1015
+ return switch_profile(config, profile_name)
1016
+
1017
+ if "--profile-add" in sys.argv:
1018
+ idx = sys.argv.index("--profile-add")
1019
+ if idx + 1 < len(sys.argv):
1020
+ return add_profile(config, sys.argv[idx + 1])
1021
+ else:
1022
+ print(f"{RED}[Error] Please provide a name for the new profile: ai --profile-add <name>{RESET}")
1023
+ return 1
1024
+
1025
+ if "--profile-remove" in sys.argv:
1026
+ idx = sys.argv.index("--profile-remove")
1027
+ if idx + 1 < len(sys.argv):
1028
+ return remove_profile(config, sys.argv[idx + 1])
1029
+ else:
1030
+ print(f"{RED}[Error] Please provide a profile name to remove: ai --profile-remove <name>{RESET}")
1031
+ return 1
1032
+
1033
+ if "--model" in sys.argv or "-m" in sys.argv:
1034
+ return handle_model_option(config)
1035
+
1036
+ debug_mode = "--debug" in sys.argv
1037
+ chat_mode = any(x in sys.argv for x in ["--chat", "-i", "chat"])
1038
+
1039
+ chat_flags = ["--chat", "-i", "chat"]
1040
+ profile_flags = ["--profile", "-p"]
1041
+ model_flags = ["--model", "-m"]
1042
+ save_flags = ["--save", "-o"]
1043
+
1044
+ output_file = None
1045
+ for flag in save_flags:
1046
+ if flag in sys.argv:
1047
+ idx = sys.argv.index(flag)
1048
+ if idx + 1 < len(sys.argv) and not sys.argv[idx + 1].startswith("-"):
1049
+ output_file = sys.argv[idx + 1].strip()
1050
+ break
1051
+
1052
+ # Check if a custom profile is temporarily chosen
1053
+ target_profile = config.get("active_profile", "")
1054
+ temp_profile = None
1055
+ for flag in profile_flags:
1056
+ if flag in sys.argv:
1057
+ idx = sys.argv.index(flag)
1058
+ if idx + 1 < len(sys.argv) and not sys.argv[idx + 1].startswith("-"):
1059
+ temp_profile = sys.argv[idx + 1].strip()
1060
+ break
1061
+ else:
1062
+ # Interactive Run - no direct profile argument provided, prompt user to select a profile
1063
+ profiles = config.get("profiles", {})
1064
+ profile_list = list(profiles.keys())
1065
+ print(f"\n{BLUE}💬 Select a profile to run this query:{RESET}")
1066
+ for p_idx, p_name in enumerate(profile_list, 1):
1067
+ p_config = profiles[p_name]
1068
+ prov = p_config.get("provider", "gemini")
1069
+ print(f" {CYAN}{p_idx}. {p_name}{RESET} [{YELLOW}{prov}{RESET}]")
1070
+ try:
1071
+ choice = input(f"\nSelect a profile number: ").strip()
1072
+ if not choice:
1073
+ print("Cancelled.")
1074
+ return 0
1075
+ choice_idx = int(choice) - 1
1076
+ if 0 <= choice_idx < len(profile_list):
1077
+ temp_profile = profile_list[choice_idx]
1078
+ else:
1079
+ print(f"{RED}[!] Invalid choice.{RESET}")
1080
+ return 1
1081
+ except (ValueError, IndexError):
1082
+ print(f"{RED}[!] Invalid choice.{RESET}")
1083
+ return 1
1084
+ except (KeyboardInterrupt, EOFError):
1085
+ print("\nCancelled.")
1086
+ return 0
1087
+
1088
+ if temp_profile:
1089
+ if temp_profile in config.get("profiles", {}):
1090
+ target_profile = temp_profile
1091
+ else:
1092
+ print(f"{RED}[Error] Profile '{temp_profile}' not found in configuration.{RESET}")
1093
+ return 1
1094
+
1095
+ # Filter out configuration, help, reinstall, chat, model, profile, and save flags/arguments from prompt text
1096
+ filtered_args = []
1097
+ skip = False
1098
+ for idx, arg in enumerate(sys.argv[1:]):
1099
+ if skip:
1100
+ skip = False
1101
+ continue
1102
+ if arg in model_flags + profile_flags + save_flags:
1103
+ if idx + 2 < len(sys.argv) and not sys.argv[idx + 2].startswith("-"):
1104
+ skip = True
1105
+ continue
1106
+ if arg in ["--debug", "--config", "--help", "-h", "--reinstall", "--debug-config", "--profiles", "--use", "--profile-add", "--profile-remove"] + chat_flags:
1107
+ continue
1108
+ filtered_args.append(arg)
1109
+ args = filtered_args
1110
+
1111
+ # Handle interactive chat session mode
1112
+ if chat_mode:
1113
+ active_config = config["profiles"][target_profile]
1114
+ provider = active_config.get("provider", "gemini")
1115
+ proxy = config.get("proxy", "")
1116
+ if provider == "gemini":
1117
+ model_name = active_config.get("model_name", "gemini-2.5-flash")
1118
+ else:
1119
+ model_name = active_config.get("model_name", "gpt-4o")
1120
+
1121
+ # Read piped content if stdin is not a TTY (before we redirect it)
1122
+ piped_content = ""
1123
+ if not sys.stdin.isatty():
1124
+ piped_content = sys.stdin.read().strip()
1125
+ # Redirect stdin back to the interactive terminal (/dev/tty) so input() works
1126
+ try:
1127
+ sys.stdin = open('/dev/tty')
1128
+ except OSError:
1129
+ pass
1130
+
1131
+ print_header_block(target_profile, provider, model_name)
1132
+
1133
+ history = []
1134
+ initial_prompt = ""
1135
+ display_prompt = ""
1136
+
1137
+ if piped_content:
1138
+ if args:
1139
+ user_question = " ".join(args)
1140
+ initial_prompt = f"Context:\n```\n{piped_content}\n```\n\nQuestion: {user_question}"
1141
+ display_prompt = f"[Piped Context] + {user_question}"
1142
+ else:
1143
+ initial_prompt = f"I have provided some context below. Please acknowledge receipt of this context, briefly summarize it, and wait for my questions about it.\n\nContext:\n```\n{piped_content}\n```"
1144
+ display_prompt = "[Piped Context] (Awaiting your questions)"
1145
+ elif args:
1146
+ initial_prompt = " ".join(args)
1147
+ display_prompt = initial_prompt
1148
+
1149
+ if initial_prompt:
1150
+ print_user_message(" You >>> ", display_prompt)
1151
+ if provider == "gemini":
1152
+ history.append({"role": "user", "parts": [{"text": initial_prompt}]})
1153
+ status = send_gemini_request(active_config, "", debug_mode, proxy=proxy, history=history)
1154
+ else:
1155
+ history.append({"role": "user", "content": initial_prompt})
1156
+ status = send_openai_request(active_config, "", debug_mode, proxy=proxy, history=history)
1157
+
1158
+ if status != 0:
1159
+ print(f"{RED}[Error] Failed to get response. Continuing session...{RESET}")
1160
+
1161
+ while True:
1162
+ try:
1163
+ prompt = f"\n You >>> "
1164
+ user_input = input(prompt)
1165
+ user_input = user_input.strip()
1166
+ if not user_input:
1167
+ continue
1168
+
1169
+ # Rewrite typed text with beautiful full-width purple background block
1170
+ if BG_USER:
1171
+ import shutil
1172
+ import math
1173
+ try:
1174
+ cols = shutil.get_terminal_size().columns
1175
+ except Exception:
1176
+ cols = 80
1177
+ total_len = len(prompt) + len(user_input)
1178
+ n_lines = math.ceil(total_len / cols) if cols else 1
1179
+ sys.stdout.write(f"\033[{n_lines}A\r\033[J")
1180
+ sys.stdout.flush()
1181
+
1182
+ print_user_message(" You >>> ", user_input)
1183
+
1184
+ if user_input.lower() in ["exit", "quit"]:
1185
+ print(f"\n{YELLOW}Goodbye!{RESET}")
1186
+ break
1187
+
1188
+ if user_input.lower().startswith("save ") or user_input.lower().startswith("/save "):
1189
+ parts = user_input.split(None, 1)
1190
+ if len(parts) > 1:
1191
+ filename = parts[1].strip()
1192
+ save_chat_history(history, filename, provider, target_profile, model_name)
1193
+ else:
1194
+ print(f"{RED}[Error] Please provide a filename: save <filename>{RESET}")
1195
+ continue
1196
+
1197
+ if provider == "gemini":
1198
+ history.append({"role": "user", "parts": [{"text": user_input}]})
1199
+ status = send_gemini_request(active_config, "", debug_mode, proxy=proxy, history=history)
1200
+ else:
1201
+ history.append({"role": "user", "content": user_input})
1202
+ status = send_openai_request(active_config, "", debug_mode, proxy=proxy, history=history)
1203
+
1204
+ if status != 0:
1205
+ print(f"{RED}[Error] Failed to get response. Continuing session...{RESET}")
1206
+ except (KeyboardInterrupt, EOFError):
1207
+ print(f"\n{YELLOW}Goodbye!{RESET}")
1208
+ break
1209
+
1210
+ # Auto-save history on exit if output_file is set
1211
+ if output_file and history:
1212
+ save_chat_history(history, output_file, provider, target_profile, model_name)
1213
+ return 0
1214
+
1215
+ user_input = ""
1216
+ if not sys.stdin.isatty():
1217
+ user_input = sys.stdin.read().strip()
1218
+ if args: user_input += "\n" + " ".join(args)
1219
+ elif args:
1220
+ user_input = " ".join(args)
1221
+ else:
1222
+ return print_help()
1223
+
1224
+ active_config = config["profiles"][target_profile]
1225
+ provider = active_config.get("provider", "gemini")
1226
+ proxy = config.get("proxy", "")
1227
+
1228
+ if provider == "gemini":
1229
+ return send_gemini_request(active_config, user_input, debug_mode, proxy=proxy, output_file=output_file)
1230
+ elif provider == "openai":
1231
+ return send_openai_request(active_config, user_input, debug_mode, proxy=proxy, output_file=output_file)
1232
+ else:
1233
+ print(f"[Error] Invalid provider '{provider}' in profile '{target_profile}'. Use 'gemini' or 'openai'.")
1234
+ return 1
1235
+
1236
+ def main():
1237
+ try:
1238
+ sys.exit(cli_entry_point())
1239
+ except KeyboardInterrupt:
1240
+ print("\nCancelled.")
1241
+ sys.exit(130)
1242
+
1243
+ if __name__ == "__main__":
1244
+ main()