bro-cli 0.1.1.4__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.
bro_cli/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """bro_cli package."""
2
+
3
+ __all__ = ["__version__"]
4
+ __version__ = "0.1.0"
bro_cli/config.py ADDED
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ ENV_API_KEY = "BRO_GEMINI_KEY"
9
+ _CONFIG_DIR_NAME = "bro"
10
+ _CONFIG_FILE_NAME = "config.json"
11
+
12
+
13
+ def get_config_path() -> Path:
14
+ """Return the Linux-friendly config path for bro."""
15
+ xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
16
+ if xdg_config_home:
17
+ base_dir = Path(xdg_config_home)
18
+ else:
19
+ base_dir = Path.home() / ".config"
20
+ return base_dir / _CONFIG_DIR_NAME / _CONFIG_FILE_NAME
21
+
22
+
23
+ def prompt_for_api_key() -> str:
24
+ """Prompt user for API key until a non-empty value is provided."""
25
+ while True:
26
+ value = input("Enter your Gemini API key: ").strip()
27
+ if value:
28
+ return value
29
+ print("API key cannot be empty.")
30
+
31
+
32
+ def save_api_key(api_key: str) -> Path:
33
+ """Persist API key to config file with mode 0600."""
34
+ config_path = get_config_path()
35
+ config_path.parent.mkdir(parents=True, exist_ok=True)
36
+
37
+ with config_path.open("w", encoding="utf-8") as f:
38
+ json.dump({"api_key": api_key}, f)
39
+
40
+ os.chmod(config_path, 0o600)
41
+ return config_path
42
+
43
+
44
+ def load_api_key_from_file() -> Optional[str]:
45
+ """Read API key from config file when available and valid."""
46
+ config_path = get_config_path()
47
+ if not config_path.exists():
48
+ return None
49
+
50
+ try:
51
+ with config_path.open("r", encoding="utf-8") as f:
52
+ data = json.load(f)
53
+ except (OSError, json.JSONDecodeError):
54
+ return None
55
+
56
+ value = data.get("api_key") if isinstance(data, dict) else None
57
+ if not value or not isinstance(value, str):
58
+ return None
59
+ return value.strip() or None
60
+
61
+
62
+ def resolve_api_key() -> Optional[str]:
63
+ """Resolve key from env first, then config file."""
64
+ env_value = os.environ.get(ENV_API_KEY, "").strip()
65
+ if env_value:
66
+ return env_value
67
+ return load_api_key_from_file()
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ try:
6
+ from google import genai
7
+ from google.genai import types
8
+ except ImportError: # pragma: no cover - depends on local install state
9
+ genai = None
10
+ types = None
11
+
12
+
13
+ SYSTEM_INSTRUCTION = (
14
+ "You are a Linux terminal assistant with a friendly, human-like personality. "
15
+ "Act like a smart, slightly witty buddy who helps efficiently. "
16
+ "Keep responses concise but not robotic. "
17
+ "You may use light humor, casual phrasing, and opinions when appropriate. "
18
+ "Avoid long explanations unless asked. "
19
+ "No markdown formatting. Plain text only. "
20
+ "Prioritize clarity and usefulness over strict brevity."
21
+ "Sound like a developer friend, not a documentation page."
22
+ "STRICT RULE: NEVER use Markdown symbols. NO asterisks (*), NO hashtags (#), "
23
+ "NO bold text, NO bullet points using *. "
24
+ "For lists, use plain dashes (-) or simple indentation. "
25
+ "Your output must be pure plain text that looks good in a raw terminal. "
26
+ "Sound like a developer friend chatting, not a documentation page."
27
+ )
28
+
29
+
30
+
31
+ @dataclass
32
+ class ClientError(Exception):
33
+ message: str
34
+ exit_code: int = 1
35
+
36
+
37
+ class GeminiClient:
38
+ def __init__(self, api_key: str, use_search: bool = False) -> None:
39
+ if genai is None or types is None:
40
+ raise ClientError(
41
+ "Missing dependency: google-genai. Install bro again with its Python dependencies.",
42
+ exit_code=1,
43
+ )
44
+ self._client = genai.Client(api_key=api_key)
45
+ self._use_search = use_search
46
+ # Use a model that supports search for search tasks, and lite for normal tasks.
47
+ self._model = "gemini-3-flash-preview" if use_search else "gemini-2.5-flash-lite"
48
+
49
+ def _get_config(self) -> types.GenerateContentConfig:
50
+ config = types.GenerateContentConfig(
51
+ system_instruction=SYSTEM_INSTRUCTION,
52
+ max_output_tokens=500, # Enforce brevity at the API level
53
+ )
54
+ if self._use_search:
55
+ config.tools = [
56
+ types.Tool(
57
+ google_search=types.GoogleSearch()
58
+ )
59
+ ]
60
+ return config
61
+
62
+ def ask(self, prompt: str) -> str:
63
+ response = self._client.models.generate_content(
64
+ model=self._model,
65
+ contents=prompt,
66
+ config=self._get_config(),
67
+ )
68
+ text = getattr(response, "text", None)
69
+ if text and text.strip():
70
+ return text.strip()
71
+ return "No response text returned by Gemini."
72
+
73
+ def start_chat(self) -> "GeminiChatSession":
74
+ return GeminiChatSession(
75
+ self._client.chats.create(
76
+ model=self._model,
77
+ history=[],
78
+ config=self._get_config(),
79
+ )
80
+ )
81
+
82
+
83
+ class GeminiChatSession:
84
+ def __init__(self, chat_session) -> None:
85
+ self._chat_session = chat_session
86
+
87
+ def ask(self, prompt: str) -> str:
88
+ response = self._chat_session.send_message(prompt)
89
+ text = getattr(response, "text", None)
90
+ if text and text.strip():
91
+ return text.strip()
92
+ return "No response text returned by Gemini."
93
+
94
+
95
+ def map_exception(exc: Exception) -> ClientError:
96
+ """Map Gemini SDK and transport errors to stable CLI messages."""
97
+ exc_name = exc.__class__.__name__.lower()
98
+ raw_message = str(exc).lower()
99
+
100
+ if "unauth" in exc_name or "permission" in exc_name or "api key" in raw_message:
101
+ return ClientError("API key rejected. Run 'bro config' to update it.", exit_code=1)
102
+
103
+ if "resourceexhausted" in exc_name or "ratelimit" in raw_message or "429" in raw_message:
104
+ return ClientError("Rate limited by Gemini. Please retry shortly.", exit_code=2)
105
+
106
+ if "serviceunavailable" in exc_name or "503" in raw_message:
107
+ return ClientError("Gemini service unavailable. Retry in a moment.", exit_code=2)
108
+
109
+ if "connection" in raw_message or "network" in raw_message or "timeout" in raw_message:
110
+ return ClientError("Network error while contacting Gemini.", exit_code=2)
111
+
112
+ return ClientError(f"Unexpected Gemini error: {exc}", exit_code=2)
bro_cli/main.py ADDED
@@ -0,0 +1,156 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from typing import Sequence
6
+
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+ from rich.prompt import Prompt
10
+ from rich.theme import Theme
11
+
12
+ from .config import get_config_path, prompt_for_api_key, resolve_api_key, save_api_key
13
+ from .gemini_client import ClientError, GeminiClient, map_exception
14
+
15
+ # Professional Cyberpunk Theme
16
+ custom_theme = Theme({
17
+ "info": "cyan",
18
+ "warning": "yellow",
19
+ "error": "bold red",
20
+ "prompt": "bold green",
21
+ "ai_name": "bold magenta",
22
+ })
23
+ console = Console(theme=custom_theme)
24
+
25
+
26
+ def build_parser() -> argparse.ArgumentParser:
27
+ parser = argparse.ArgumentParser(
28
+ prog="bro",
29
+ description="Linux terminal client for chatting with Gemini",
30
+ )
31
+ parser.add_argument(
32
+ "-s",
33
+ "--search",
34
+ action="store_true",
35
+ help="Enable web search capability (grounding)",
36
+ )
37
+ parser.add_argument(
38
+ "command",
39
+ nargs="?",
40
+ help="Use config to set the API key, or provide a prompt to chat",
41
+ )
42
+ parser.add_argument(
43
+ "prompt",
44
+ nargs=argparse.REMAINDER,
45
+ help="Question to ask Gemini",
46
+ )
47
+ return parser
48
+
49
+
50
+ def run_config() -> int:
51
+ existing = resolve_api_key()
52
+ if existing:
53
+ print("An API key is already configured. Saving will overwrite the stored key.")
54
+
55
+ api_key = prompt_for_api_key()
56
+ path = save_api_key(api_key)
57
+ print("API key saved.")
58
+ print(f"Config file: {path}")
59
+ return 0
60
+
61
+
62
+ def _load_client(use_search: bool = False) -> GeminiClient:
63
+ api_key = resolve_api_key()
64
+ if not api_key:
65
+ raise ClientError(
66
+ "Gemini API key is missing. Run 'bro config' to set it.",
67
+ exit_code=1,
68
+ )
69
+ return GeminiClient(api_key=api_key, use_search=use_search)
70
+
71
+
72
+ def run_single_prompt(prompt_text: str, use_search: bool = False) -> int:
73
+ try:
74
+ client = _load_client(use_search=use_search)
75
+ with console.status("[bold cyan]Bro is thinking...", spinner="dots"):
76
+ response = client.ask(prompt_text)
77
+
78
+ console.print(Panel(
79
+ response,
80
+ title="[ai_name]Bro",
81
+ title_align="left",
82
+ border_style="magenta",
83
+ padding=(1, 2)
84
+ ))
85
+ return 0
86
+ except ClientError as exc:
87
+ console.print(f"[error]{exc.message}[/error]", style="red")
88
+ return exc.exit_code
89
+ except Exception as exc: # pragma: no cover - defensive layer
90
+ err = map_exception(exc)
91
+ console.print(f"[error]{err.message}[/error]", style="red")
92
+ return err.exit_code
93
+
94
+
95
+ def run_interactive_chat(use_search: bool = False) -> int:
96
+ try:
97
+ client = _load_client(use_search=use_search)
98
+ chat = client.start_chat()
99
+ except ClientError as exc:
100
+ console.print(f"[error]{exc.message}[/error]")
101
+ return exc.exit_code
102
+
103
+ mode_info = " [dim](Search enabled)[/dim]" if use_search else ""
104
+ console.print(f"[info]Interactive mode{mode_info}. Type 'exit' or 'quit' to leave.[/info]")
105
+
106
+ while True:
107
+ try:
108
+ # Styled prompt using rich.prompt.Prompt or just console.print
109
+ user_input = Prompt.ask("[prompt]bro[/prompt]")
110
+ except (KeyboardInterrupt, EOFError):
111
+ console.print("\n[info]Exiting. Catch ya later![/info]")
112
+ return 0
113
+
114
+ if not user_input:
115
+ continue
116
+ if user_input.lower() in {"exit", "quit"}:
117
+ console.print("[info]Exiting. Catch ya later![/info]")
118
+ return 0
119
+
120
+ try:
121
+ with console.status("[bold cyan]Thinking...", spinner="dots"):
122
+ response = chat.ask(user_input)
123
+
124
+ console.print(Panel(
125
+ response,
126
+ title="[ai_name]Bro",
127
+ title_align="left",
128
+ border_style="magenta",
129
+ padding=(1, 2)
130
+ ))
131
+ except Exception as exc:
132
+ err = map_exception(exc)
133
+ console.print(f"[error]{err.message}[/error]")
134
+ return err.exit_code
135
+
136
+
137
+ def main(argv: Sequence[str] | None = None) -> int:
138
+ parser = build_parser()
139
+ args = parser.parse_args(argv)
140
+
141
+ if args.command == "config" and not args.prompt:
142
+ return run_config()
143
+
144
+ if args.command == "config" and args.prompt:
145
+ parser.error("'config' does not accept a chat prompt")
146
+
147
+ if args.command is not None:
148
+ prompt_parts = [args.command, *args.prompt]
149
+ return run_single_prompt(" ".join(prompt_parts).strip(), use_search=args.search)
150
+
151
+ # No prompt and no subcommand means REPL mode.
152
+ return run_interactive_chat(use_search=args.search)
153
+
154
+
155
+ if __name__ == "__main__":
156
+ raise SystemExit(main())
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: bro-cli
3
+ Version: 0.1.1.4
4
+ Summary: Linux terminal Gemini client with secure local API key config
5
+ Author: Aether
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: google-genai>=1.20.0
9
+ Requires-Dist: rich
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8.0; extra == "dev"
12
+
13
+ # Bro CLI
14
+
15
+ A lightweight, fast, and secure Linux terminal client for Google's Gemini AI.
16
+
17
+ `bro` allows you to interact with Gemini directly from your command line, offering both single-prompt execution and a persistent interactive chat mode. It securely manages your API credentials using standard Linux XDG directories.
18
+
19
+ ## Features
20
+
21
+ - **Interactive & Single-Prompt Modes:** Seamlessly switch between quick one-off questions and interactive conversational sessions.
22
+ - **Secure Credential Management:** API keys are stored securely in `~/.config/bro/config.json` with strict user-only (`0600`) file permissions.
23
+ - **Environment Variable Support:** Override stored configurations on the fly using the `BRO_GEMINI_KEY` environment variable.
24
+ - **Terminal-Optimized Output:** Instructs Gemini to return direct, concise answers without unnecessary markdown formatting or conversational filler.
25
+
26
+ ## Requirements
27
+
28
+ - Linux
29
+ - Python 3.10 or higher
30
+ - [pipx](https://pipx.pypa.io/) (Required for isolated global CLI installation)
31
+
32
+ ## Installation
33
+
34
+ Install `bro-cli` globally as an isolated application using `pipx`.
35
+
36
+ To install directly from your Git repository:
37
+
38
+ ```bash
39
+ pipx install git+https://github.com/PromitSarker/Bro-CLI.git
40
+ ```
41
+
42
+ **Local Installation:**
43
+ If you have cloned the repository to your local machine, you can navigate to the project folder and install it by running:
44
+ ```bash
45
+ pipx install .
46
+ ```
47
+
48
+ **Updating:**
49
+ To upgrade to the latest version in the future:
50
+ ```bash
51
+ pipx upgrade bro-cli
52
+ ```
53
+
54
+ ## Configuration
55
+
56
+ Before using the client, you *must* configure your Google Gemini API key. Run the following command:
57
+
58
+ ```bash
59
+ bro config
60
+ ```
61
+
62
+ You will be prompted to paste your API key. It will be saved securely to your system.
63
+
64
+ ## Usage
65
+
66
+ ### Single Prompt
67
+
68
+ Ask a direct question. The client will return the answer to your standard output and exit:
69
+
70
+ ```bash
71
+ bro "Why life so hard ?"
72
+ ```
73
+
74
+ *Note: You can also skip the quotes for simple queries:*
75
+ ```bash
76
+ bro how to go and breathe ?
77
+ ```
78
+
79
+ ### Interactive Mode
80
+
81
+ Start a multi-turn chat session by running the command without any arguments:
82
+
83
+ ```bash
84
+ bro
85
+ ```
86
+
87
+ *Inside the interactive prompt, type `exit`, `quit`, or press `Ctrl+C`/`Ctrl+D` to end the session.*
88
+
89
+ ## Technical Details
90
+
91
+ - **Default Model:** `gemini-2.5-flash-lite`
92
+ - **SDK:** Powered by the official `google-genai` Python SDK.
@@ -0,0 +1,9 @@
1
+ bro_cli/__init__.py,sha256=7DeUeTgAqNqa6i2BfRkpkEUciONNmkXMiKI7ByXqZ3Q,72
2
+ bro_cli/config.py,sha256=PS8QvwCdgr9VqeTkzJ7JGet5Xl_t5EiXMY6Ip63LLzg,1914
3
+ bro_cli/gemini_client.py,sha256=KfXLytL6F6uvGQZVfHrc5BKIftg1W53L8gRcZ4g_hI8,4184
4
+ bro_cli/main.py,sha256=Ub2caZuoPceWKPxg-t7E5uwmKW2wV_e1f7XeQJGVap4,4754
5
+ bro_cli-0.1.1.4.dist-info/METADATA,sha256=b1j1jEXr1Yy3yBNbghxg4bQLaqyVlr4f1EE1mMUEMRQ,2660
6
+ bro_cli-0.1.1.4.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
7
+ bro_cli-0.1.1.4.dist-info/entry_points.txt,sha256=7hwnDDXGBHdSQVZfuSoakeZxrTnv1uDgl7etJbOv1Uo,42
8
+ bro_cli-0.1.1.4.dist-info/top_level.txt,sha256=KXV5cgSvg4V_kKTOPMjf8vTNq_6o0itjFcn-1GxygeI,8
9
+ bro_cli-0.1.1.4.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ bro = bro_cli.main:main
@@ -0,0 +1 @@
1
+ bro_cli