grucli 3.3.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.
grucli/permissions.py ADDED
@@ -0,0 +1,181 @@
1
+ """
2
+ Per-tool-call permission system for grucli.
3
+ Implements granular permissions with session-scoped "Allow always" functionality.
4
+ """
5
+
6
+ from enum import Enum
7
+ from typing import Set, Dict, Optional, Tuple
8
+ from .theme import Colors, Borders, Icons, Styles, get_terminal_width
9
+
10
+
11
+ class PermissionGroup(Enum):
12
+ """Permission categories for tool operations."""
13
+ READ = "read" # read_file, get_current_directory_structure
14
+ WRITE = "write" # create_file, edit_file
15
+ DESTRUCTIVE = "delete" # delete_file
16
+
17
+
18
+ # Tool-to-permission-group mapping
19
+ TOOL_PERMISSION_MAP: Dict[str, PermissionGroup] = {
20
+ 'read_file': PermissionGroup.READ,
21
+ 'get_current_directory_structure': PermissionGroup.READ,
22
+ 'create_file': PermissionGroup.WRITE,
23
+ 'edit_file': PermissionGroup.WRITE,
24
+ 'delete_file': PermissionGroup.DESTRUCTIVE,
25
+ }
26
+
27
+
28
+ # Human-readable descriptions for permission groups
29
+ PERMISSION_DESCRIPTIONS: Dict[PermissionGroup, str] = {
30
+ PermissionGroup.READ: "Read files and directory structure",
31
+ PermissionGroup.WRITE: "Create and modify files",
32
+ PermissionGroup.DESTRUCTIVE: "Delete files permanently",
33
+ }
34
+
35
+
36
+ # Colors for permission groups
37
+ PERMISSION_COLORS: Dict[PermissionGroup, str] = {
38
+ PermissionGroup.READ: Colors.READ_OP,
39
+ PermissionGroup.WRITE: Colors.WRITE_OP,
40
+ PermissionGroup.DESTRUCTIVE: Colors.DESTRUCTIVE_OP,
41
+ }
42
+
43
+
44
+ # Icons for permission groups
45
+ PERMISSION_ICONS: Dict[PermissionGroup, str] = {
46
+ PermissionGroup.READ: Icons.READ,
47
+ PermissionGroup.WRITE: Icons.WRITE,
48
+ PermissionGroup.DESTRUCTIVE: Icons.DELETE,
49
+ }
50
+
51
+
52
+ class PermissionStore:
53
+ """
54
+ Session-scoped permission storage.
55
+ Tracks which permission groups have been granted "Allow always".
56
+ Resets when the session ends.
57
+ """
58
+
59
+ def __init__(self):
60
+ self._allowed_always: Set[PermissionGroup] = set()
61
+
62
+ def is_allowed(self, group: PermissionGroup) -> bool:
63
+ """Check if a permission group is already allowed."""
64
+ return group in self._allowed_always
65
+
66
+ def allow_always(self, group: PermissionGroup) -> None:
67
+ """Grant persistent permission for this session."""
68
+ self._allowed_always.add(group)
69
+ # If allowing write (edits), also allow read
70
+ if group == PermissionGroup.WRITE:
71
+ self._allowed_always.add(PermissionGroup.READ)
72
+
73
+ def revoke_always(self, group: PermissionGroup) -> None:
74
+ """Revoke persistent permission for this session."""
75
+ self._allowed_always.discard(group)
76
+
77
+ def reset(self) -> None:
78
+ """Clear all permissions (called on session end)."""
79
+ self._allowed_always.clear()
80
+
81
+ def get_allowed_groups(self) -> Set[PermissionGroup]:
82
+ """Get all currently allowed permission groups."""
83
+ return self._allowed_always.copy()
84
+
85
+
86
+ # Global permission store instance
87
+ PERMISSION_STORE = PermissionStore()
88
+
89
+
90
+ def get_tool_permission_group(tool_name: str) -> Optional[PermissionGroup]:
91
+ """Get the permission group for a tool."""
92
+ return TOOL_PERMISSION_MAP.get(tool_name)
93
+
94
+
95
+ def format_tool_for_permission(tool_name: str, args: dict) -> str:
96
+ """Format a tool call for display in permission prompt."""
97
+ group = get_tool_permission_group(tool_name)
98
+ if not group:
99
+ return f"{tool_name}(...)"
100
+
101
+ color = PERMISSION_COLORS.get(group, Colors.WHITE)
102
+ icon = PERMISSION_ICONS.get(group, "")
103
+
104
+ # Format args nicely
105
+ arg_parts = []
106
+ for key, value in args.items():
107
+ if isinstance(value, str) and len(value) > 50:
108
+ value = value[:47] + "..."
109
+ arg_parts.append(f"{Colors.MUTED}{key}={Colors.RESET}{repr(value)}")
110
+
111
+ args_str = ", ".join(arg_parts) if arg_parts else ""
112
+
113
+ return f"{color}{icon} {tool_name}{Colors.RESET}({args_str})"
114
+
115
+
116
+ def prompt_permission(tool_name: str, args: dict) -> str:
117
+ """
118
+ Display permission prompt and get user decision.
119
+
120
+ Returns:
121
+ 'once' - Allow this single invocation
122
+ 'always' - Allow all future calls in this permission group
123
+ 'deny' - Deny this and all remaining tool calls
124
+ """
125
+ from . import interrupt
126
+
127
+ group = get_tool_permission_group(tool_name)
128
+ if not group:
129
+ # Unknown tool - default to requiring permission
130
+ group = PermissionGroup.WRITE
131
+
132
+ # Check if already allowed
133
+ if PERMISSION_STORE.is_allowed(group):
134
+ return 'always'
135
+
136
+ color = PERMISSION_COLORS.get(group, Colors.WHITE)
137
+
138
+ # Print options (the tool box is printed by main.py)
139
+ print(f" {Colors.SUCCESS}1{Colors.RESET}) Allow once")
140
+ print(f" {Colors.PRIMARY}2{Colors.RESET}) Allow always {Colors.MUTED}(this session, {group.value} operations){Colors.RESET}")
141
+ print(f" {Colors.ERROR}3{Colors.RESET}) Deny {Colors.MUTED}(cancels all remaining operations){Colors.RESET}")
142
+ print()
143
+
144
+ while True:
145
+ try:
146
+ choice = interrupt.safe_input(f"{color}Choose [1/2/3]: {Colors.RESET}").strip()
147
+ if choice in ['1', '2', '3']:
148
+ print("\033[A\033[K" * 5, end="", flush=True)
149
+ if choice == '1':
150
+ return 'once'
151
+ elif choice == '2':
152
+ PERMISSION_STORE.allow_always(group)
153
+ print(f"{Colors.SUCCESS}{Icons.CHECK} Allowed {group.value} operations for this session{Colors.RESET}")
154
+ return 'always'
155
+ elif choice == '3':
156
+ print(f"{Colors.WARNING}{Icons.WARNING} Operation denied{Colors.RESET}")
157
+ return 'deny'
158
+ else:
159
+ print(f"{Colors.ERROR}Please enter 1, 2, or 3{Colors.RESET}")
160
+ pass
161
+ except KeyboardInterrupt:
162
+ print(f"\n{Colors.WARNING}{Icons.WARNING} Operation cancelled{Colors.RESET}")
163
+ return 'deny'
164
+
165
+
166
+ def check_permission(tool_name: str, args: dict) -> Tuple[bool, str]:
167
+ """
168
+ Check if a tool has permission to execute.
169
+
170
+ Returns:
171
+ (allowed: bool, decision: str)
172
+ - allowed: Whether the tool can execute
173
+ - decision: 'once', 'always', or 'deny'
174
+ """
175
+ group = get_tool_permission_group(tool_name)
176
+
177
+ if group and PERMISSION_STORE.is_allowed(group):
178
+ return True, 'always'
179
+
180
+ decision = prompt_permission(tool_name, args)
181
+ return decision != 'deny', decision
grucli/stats.py ADDED
@@ -0,0 +1,100 @@
1
+ """
2
+ Session statistics tracking for grucli.
3
+ """
4
+
5
+ import time
6
+ import uuid
7
+ import sys
8
+ from .theme import Colors, Borders, Icons
9
+
10
+
11
+ class SessionStats:
12
+ """Track statistics for the current session."""
13
+
14
+ def __init__(self):
15
+ self.session_id = str(uuid.uuid4())[:8] # Short ID
16
+ self.start_time = time.time()
17
+ self.tool_calls_total = 0
18
+ self.tool_calls_success = 0
19
+ self.tool_calls_failed = 0
20
+ self.model_usage = {} # {model_name: {'reqs': 0, 'input_tokens': 0, 'cache_reads': 0, 'output_tokens': 0}}
21
+ self.api_time_total = 0
22
+
23
+ def record_tool_call(self, success=True):
24
+ """Record a tool call result."""
25
+ self.tool_calls_total += 1
26
+ if success:
27
+ self.tool_calls_success += 1
28
+ else:
29
+ self.tool_calls_failed += 1
30
+
31
+ def record_request(self, model_name, api_duration=0):
32
+ """Record an API request."""
33
+ if model_name not in self.model_usage:
34
+ self.model_usage[model_name] = {'reqs': 0, 'input_tokens': 0, 'cache_reads': 0, 'output_tokens': 0}
35
+ self.model_usage[model_name]['reqs'] += 1
36
+ self.api_time_total += api_duration
37
+
38
+ def record_tokens(self, model_name, input_tokens=0, output_tokens=0, cache_reads=0):
39
+ """Record token usage for a model."""
40
+ if model_name not in self.model_usage:
41
+ self.model_usage[model_name] = {'reqs': 0, 'input_tokens': 0, 'cache_reads': 0, 'output_tokens': 0}
42
+ self.model_usage[model_name]['input_tokens'] += input_tokens
43
+ self.model_usage[model_name]['output_tokens'] += output_tokens
44
+ self.model_usage[model_name]['cache_reads'] += cache_reads
45
+
46
+ def get_formatted_summary(self):
47
+ """Generate a formatted summary of the session."""
48
+ end_time = time.time()
49
+ wall_time = end_time - self.start_time
50
+
51
+ minutes = int(wall_time // 60)
52
+ seconds = int(wall_time % 60)
53
+ wall_time_str = f"{minutes}m {seconds}s" if minutes > 0 else f"{seconds}s"
54
+
55
+ tool_success_rate = 0.0
56
+ if self.tool_calls_total > 0:
57
+ tool_success_rate = (self.tool_calls_success / self.tool_calls_total) * 100
58
+
59
+ api_time_pct = 0.0
60
+ if wall_time > 0:
61
+ api_time_pct = (self.api_time_total / wall_time) * 100
62
+
63
+ summary = []
64
+
65
+ # Header
66
+ summary.append("")
67
+ summary.append(f"{Colors.MUTED}{Borders.HORIZONTAL * 50}{Colors.RESET}")
68
+ summary.append(f"{Colors.PRIMARY}{Colors.BOLD}Session Summary{Colors.RESET} {Colors.MUTED}#{self.session_id}{Colors.RESET}")
69
+ summary.append(f"{Colors.MUTED}{Borders.HORIZONTAL * 50}{Colors.RESET}")
70
+
71
+ # Time
72
+ summary.append(f" {Colors.MUTED}Duration:{Colors.RESET} {Colors.WHITE}{wall_time_str}{Colors.RESET}")
73
+ summary.append(f" {Colors.MUTED}API Time:{Colors.RESET} {Colors.WHITE}{self.api_time_total:.1f}s{Colors.RESET} {Colors.MUTED}({api_time_pct:.0f}%){Colors.RESET}")
74
+
75
+ # Tools
76
+ if self.tool_calls_total > 0:
77
+ success_color = Colors.SUCCESS if self.tool_calls_failed == 0 else Colors.WARNING
78
+ summary.append(f" {Colors.MUTED}Tool Calls:{Colors.RESET} {Colors.WHITE}{self.tool_calls_total}{Colors.RESET} {Colors.MUTED}({Colors.SUCCESS}{Icons.CHECK}{self.tool_calls_success}{Colors.MUTED} {Colors.ERROR}{Icons.CROSS}{self.tool_calls_failed}{Colors.MUTED}){Colors.RESET}")
79
+
80
+ # Model usage
81
+ if self.model_usage:
82
+ summary.append("")
83
+ summary.append(f" {Colors.MUTED}Models Used:{Colors.RESET}")
84
+ for model, usage in self.model_usage.items():
85
+ # Truncate long model names
86
+ display_model = model if len(model) < 30 else model[:27] + "..."
87
+ summary.append(f" {Colors.SECONDARY}{display_model}{Colors.RESET} {Colors.MUTED}({usage['reqs']} requests){Colors.RESET}")
88
+
89
+ summary.append(f"{Colors.MUTED}{Borders.HORIZONTAL * 50}{Colors.RESET}")
90
+ summary.append("")
91
+
92
+ return "\n".join(summary)
93
+
94
+ def print_summary(self):
95
+ """Print the session summary."""
96
+ print(self.get_formatted_summary())
97
+
98
+
99
+ # Global instance
100
+ STATS = SessionStats()
@@ -0,0 +1,65 @@
1
+ # Agentic CLI LLM
2
+
3
+ You are a precise local development assistant. You operate strictly within the current directory using relative paths.
4
+
5
+ ## Environment & Paths
6
+ - **Relative Paths Only**: Use paths starting with `/` relative to the project root (e.g., `/src/main.py`). Never use absolute paths or `../`.
7
+ - **Context**: The current project structure is:
8
+ <auto_inject_file_tree>
9
+ Read files before assuming their content.
10
+
11
+ ## Tools
12
+ Call tools using Python syntax: `tool_name(arg="value")`.
13
+
14
+ **Recommended: One Tool Call Per Response**: It is generally best to run only one tool call per response for reliability. However, if the situation clearly benefits—such as when making multiple small, non-conflicting edits—you may batch several tool calls together in a single response. Use your judgment and prioritize reliability.
15
+
16
+ - `read_file(path, start_line=None, end_line=None)`: Reads file content. Use `start_line` and `end_line` (1-indexed) for large files.
17
+ - `create_file(path, content)`: Creates a new file (and directories if needed).
18
+ - `edit_file(path, old_string, new_string)`: Replaces the **exact, unique** occurrence of `old_string`. Always provide enough context in `old_string` to ensure it only matches once.
19
+ - `delete_file(path)`: Deletes a file. Use with caution.
20
+ - `get_current_directory_structure()`: Returns the latest file tree. ONLY use if a file is not found or you don't know where it is.
21
+
22
+ ## STRICT Tool Usage Protocols (CRITICAL)
23
+
24
+ ### 1. NO Redundant Verification (MOST IMPORTANT)
25
+ - **NEVER** immediately read a file you just created or edited. Trust that the tool worked.
26
+ - **NEVER** read a file if the user provided the full content in the prompt.
27
+ - **Exception**: Only read back if you encounter a specific error message or if the user explicitly asks "Verify the file content".
28
+ - **ANTI-PATTERN (DO NOT DO THIS)**:
29
+ 1. `create_file(path="script.py", ...)`
30
+ 2. "Now I will read it to verify." -> `read_file(path="script.py")` <--- WRONG! STOP!
31
+
32
+ ### 2. When NOT to use tools
33
+ - **Chat**: "Hello", "Thanks", "What is Python?" -> **No tools**. Text only. Respond naturally and politely to pleasantries.
34
+ - **Clarification**: Need more info? Ask text questions. Don't scan directory or read files just to "look busy".
35
+ - **Redundancy**: NEVER use a tool if the information is already present in the prompt or recent context.
36
+ - **Empty Directory**: If context says "Current directory empty", **do not** run `get_current_directory_structure()`.
37
+ - **Directory Structure**: NEVER use `get_current_directory_structure()` unless a file is not found or when the user explicitly asks for it.
38
+
39
+ ### 3. When to USE tools
40
+ - **Mandatory Action**: If the user asks for a code change, bug fix, or new file, you **MUST** use the appropriate tool (`create_file`, `edit_file`). **Never** output code blocks as plain text if the intent is to modify the codebase.
41
+ - **Exploration**: ONLY if a file is not found in the provided tree, or the user explicitly asks for it -> `get_current_directory_structure()`
42
+ - **Modifying**: "Fix the bug in line 10" -> `read_file(...)` (to find context) -> `edit_file(...)`.
43
+
44
+ ## Rules of Engagement
45
+ 1. **Tool Invocation**: Any tool call **must be the final output** of your response.
46
+ 2. **No Post-Tool Talk**: Do not add explanations, summaries, or "Done" messages after a tool call.
47
+ 3. **Atomic Changes**: Prefer small, verifiable edits over large rewrites. Instead of trying to edit a full 100-line function in a single tool call, batch multiple smaller tool calls in the same response—each operating on a small part (e.g., a few lines) of the function.
48
+ - **Example**:
49
+ Instead of:
50
+ `edit_file(path="/foo.py", old_string="(entire 100-line function)", new_string="(entire new function)")`
51
+ Prefer:
52
+ ```
53
+ edit_file(path="/foo.py", old_string="def foo(...):\n # lines 1-10", new_string="def foo(...):\n # updated lines 1-10")
54
+ edit_file(path="/foo.py", old_string=" # lines 11-20", new_string=" # updated lines 11-20")
55
+ ```
56
+ This greatly reduces risk and aids verification.
57
+ 4. **No Hallucinations**: If a file path isn't in the tree and you don't know where it is, use `get_current_directory_structure()`. Do not use it otherwise.
58
+ 5. **Conciseness**: Minimize fluff. Focus on reasoning and action. However, do not be robotic; acknowledge greetings and thanks naturally alongside normal language.
59
+ 6. **Tool Necessity**: Only trigger tools when a task requires action or specific information not present in the current prompt context. Avoid "informational" tool calls if you can answer from context.
60
+ 7. **No Passive Code Blocks**: If the user's request implies a file modification, never output a markdown code block alone. You must call the relevant tool.
61
+ 8. **Efficiency**: Use your existing knowledge and the provided file tree to answer simple questions before reaching for a tool.
62
+
63
+ ## Example output
64
+ I'll check the config before updating it.
65
+ read_file(path="/config.json")
grucli/theme.py ADDED
@@ -0,0 +1,144 @@
1
+ """
2
+ Centralized theme and color constants for grucli UI.
3
+ All ANSI color codes and styling are defined here for consistency.
4
+ """
5
+
6
+ from enum import Enum
7
+
8
+
9
+ class Colors:
10
+ """ANSI color codes for terminal output."""
11
+
12
+ # Branding colors
13
+ PRIMARY = "\033[38;5;33m" # Deep sky blue (better contrast than 39)
14
+ SECONDARY = "\033[38;5;135m" # Medium purple (better contrast than 141)
15
+ ACCENT = "\033[38;5;214m" # Orange/Gold
16
+
17
+ # Semantic colors
18
+ SUCCESS = "\033[38;5;71m" # Medium green
19
+ WARNING = "\033[38;5;214m" # Orange
20
+ ERROR = "\033[38;5;196m" # Red
21
+ INFO = "\033[38;5;33m" # Blue
22
+ MUTED = "\033[38;5;244m" # Medium gray (readable on both)
23
+ WHITE = "\033[97m" # Bright white
24
+ THINK_COLOR = "\033[38;5;213m" # Pinkish for reasoning
25
+
26
+ # Tool category colors
27
+ READ_OP = "\033[38;5;45m" # Cyan for read operations
28
+ WRITE_OP = "\033[38;5;214m" # Orange for write operations
29
+ DESTRUCTIVE_OP = "\033[91m" # Red for destructive operations
30
+
31
+ # UI element colors
32
+ PROMPT = "\033[38;5;39;1m" # Bold blue for prompts
33
+ HEADER = "\033[1;4m" # Bold underline for headers
34
+ TITLE = "\033[38;5;39;1m" # Bold blue for titles
35
+ SUBTITLE = "\033[38;5;245m" # Dim gray for subtitles
36
+
37
+ # Text modifiers
38
+ DIM = "\033[2m"
39
+ BOLD = "\033[1m"
40
+ ITALIC = "\033[3m"
41
+ UNDERLINE = "\033[4m"
42
+ REVERSE = "\033[7m"
43
+ RESET = "\033[0m"
44
+
45
+ # Input states
46
+ INPUT_ACTIVE = "\033[38;5;39m" # Blue for active input
47
+ INPUT_DISABLED = "\033[38;5;240m" # Dark gray for disabled
48
+ INPUT_CONFIRM = "\033[38;5;220m" # Gold for confirmation
49
+ INPUT_ERROR = "\033[91m" # Red for error state
50
+
51
+
52
+ class Borders:
53
+ """Box drawing characters for clean UI elements."""
54
+
55
+ # Rounded corners
56
+ TOP_LEFT = "╭"
57
+ TOP_RIGHT = "╮"
58
+ BOTTOM_LEFT = "╰"
59
+ BOTTOM_RIGHT = "╯"
60
+
61
+ # Lines
62
+ HORIZONTAL = "─"
63
+ VERTICAL = "│"
64
+
65
+ # T-junctions
66
+ T_LEFT = "├"
67
+ T_RIGHT = "┤"
68
+ T_TOP = "┬"
69
+ T_BOTTOM = "┴"
70
+
71
+ # Cross
72
+ CROSS = "┼"
73
+
74
+
75
+ class Icons:
76
+ """Unicode icons for visual enhancement."""
77
+
78
+ # Status icons
79
+ CHECK = "✓"
80
+ CROSS = "✗"
81
+ WARNING = "⚠"
82
+ INFO = "ℹ"
83
+
84
+ # Tool icons
85
+ READ = "[READ]"
86
+ WRITE = "[WRITE]"
87
+ DELETE = "[DELETE]"
88
+ FOLDER = "[FOLDER]"
89
+
90
+ # UI icons
91
+ ARROW_RIGHT = "→"
92
+ ARROW_DOWN = "↓"
93
+ BULLET = "•"
94
+ DIAMOND = "◆"
95
+ CIRCLE = "○"
96
+ CIRCLE_FILLED = "●"
97
+
98
+ # Spinner frames
99
+ SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
100
+
101
+
102
+ class Styles:
103
+ """Pre-composed style combinations."""
104
+
105
+ @staticmethod
106
+ def success(text: str) -> str:
107
+ return f"{Colors.SUCCESS}{text}{Colors.RESET}"
108
+
109
+ @staticmethod
110
+ def error(text: str) -> str:
111
+ return f"{Colors.ERROR}{text}{Colors.RESET}"
112
+
113
+ @staticmethod
114
+ def warning(text: str) -> str:
115
+ return f"{Colors.WARNING}{text}{Colors.RESET}"
116
+
117
+ @staticmethod
118
+ def info(text: str) -> str:
119
+ return f"{Colors.INFO}{text}{Colors.RESET}"
120
+
121
+ @staticmethod
122
+ def muted(text: str) -> str:
123
+ return f"{Colors.MUTED}{text}{Colors.RESET}"
124
+
125
+ @staticmethod
126
+ def bold(text: str) -> str:
127
+ return f"{Colors.BOLD}{text}{Colors.RESET}"
128
+
129
+ @staticmethod
130
+ def primary(text: str) -> str:
131
+ return f"{Colors.PRIMARY}{text}{Colors.RESET}"
132
+
133
+ @staticmethod
134
+ def accent(text: str) -> str:
135
+ return f"{Colors.ACCENT}{text}{Colors.RESET}"
136
+
137
+
138
+ def get_terminal_width() -> int:
139
+ """Get terminal width, defaulting to 80 if unavailable."""
140
+ import shutil
141
+ try:
142
+ return shutil.get_terminal_size().columns
143
+ except Exception:
144
+ return 80