gc-shell 0.1.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.
commands/__init__.py ADDED
File without changes
commands/apps.py ADDED
@@ -0,0 +1,202 @@
1
+ """App management commands — open, close, list apps, list running."""
2
+
3
+ import subprocess
4
+
5
+ from rich.console import Console
6
+ from rich.table import Table
7
+
8
+ from registry.scanner import get_registry_path, load_registry
9
+
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # Fuzzy matching
13
+ # ---------------------------------------------------------------------------
14
+
15
+ def _fuzzy_match(query: str, registry: dict) -> tuple | None:
16
+ """Find the best match for *query* in the registry.
17
+
18
+ Match precedence:
19
+ 1. Exact key match (e.g. "google chrome" → "google chrome")
20
+ 2. Query is a substring of a key (e.g. "chrome" → "google chrome")
21
+ 3. Key is a substring of the query
22
+ When multiple candidates tie, the shortest key wins (most specific).
23
+ """
24
+ query = query.lower().strip()
25
+
26
+ # 1. Exact match
27
+ if query in registry:
28
+ return query, registry[query]
29
+
30
+ # 2/3. Substring matches
31
+ matches = [
32
+ (key, info)
33
+ for key, info in registry.items()
34
+ if query in key or key in query
35
+ ]
36
+
37
+ if len(matches) == 1:
38
+ return matches[0]
39
+ if len(matches) > 1:
40
+ matches.sort(key=lambda x: len(x[0]))
41
+ return matches[0]
42
+
43
+ return None
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Commands
48
+ # ---------------------------------------------------------------------------
49
+
50
+ def open_app(name: str, console: Console) -> bool:
51
+ """Open an application by name.
52
+
53
+ Returns True if the app was found (whether or not it launched successfully),
54
+ False if no match was found so the caller can fall through.
55
+ """
56
+ registry = load_registry()
57
+
58
+ if not registry:
59
+ console.print(
60
+ "[yellow]⚠ No app registry found. "
61
+ "Run [bold]scan[/bold] first.[/yellow]"
62
+ )
63
+ return True # handled (with a warning), don't fall through
64
+
65
+ match = _fuzzy_match(name, registry)
66
+
67
+ if match:
68
+ _key, info = match
69
+ app_name = info["name"]
70
+ console.print(f"[green]▶[/green] Opening [bold]{app_name}[/bold]...")
71
+ try:
72
+ subprocess.run(
73
+ ["open", "-a", info["path"]],
74
+ check=True,
75
+ capture_output=True,
76
+ text=True,
77
+ )
78
+ except subprocess.CalledProcessError as e:
79
+ console.print(
80
+ f"[red]✗ Failed to open {app_name}:[/red] {e.stderr.strip()}"
81
+ )
82
+ return True
83
+
84
+ return False # not in registry — let the executor fall through
85
+
86
+
87
+ def close_app(name: str, console: Console) -> None:
88
+ """Close an application by name via AppleScript quit."""
89
+ registry = load_registry()
90
+
91
+ if not registry:
92
+ console.print(
93
+ "[yellow]⚠ No app registry found. "
94
+ "Run [bold]scan[/bold] first.[/yellow]"
95
+ )
96
+ return
97
+
98
+ match = _fuzzy_match(name, registry)
99
+
100
+ if match:
101
+ _key, info = match
102
+ app_name = info["name"]
103
+ console.print(f"[red]■[/red] Closing [bold]{app_name}[/bold]...")
104
+ try:
105
+ subprocess.run(
106
+ [
107
+ "osascript",
108
+ "-e",
109
+ f'tell application "{app_name}" to quit',
110
+ ],
111
+ check=True,
112
+ capture_output=True,
113
+ text=True,
114
+ )
115
+ except subprocess.CalledProcessError:
116
+ # Fallback: SIGTERM via pkill
117
+ try:
118
+ subprocess.run(
119
+ ["pkill", "-f", app_name], capture_output=True
120
+ )
121
+ except Exception:
122
+ console.print(f"[red]✗ Could not close {app_name}[/red]")
123
+ else:
124
+ console.print(f"[red]✗ App '{name}' not found in registry.[/red]")
125
+ console.print(
126
+ "[dim]Run [bold]scan[/bold] to update the app registry.[/dim]"
127
+ )
128
+
129
+
130
+ def list_apps(console: Console) -> None:
131
+ """Print a table of all registered applications."""
132
+ registry = load_registry()
133
+
134
+ if not registry:
135
+ console.print(
136
+ "[yellow]⚠ No app registry found. "
137
+ "Run [bold]scan[/bold] first.[/yellow]"
138
+ )
139
+ return
140
+
141
+ table = Table(
142
+ title="Registered Applications",
143
+ show_lines=False,
144
+ border_style="dim",
145
+ title_style="bold cyan",
146
+ )
147
+ table.add_column("#", style="dim", justify="right", width=4)
148
+ table.add_column("Application", style="cyan bold", no_wrap=True)
149
+ table.add_column("Path", style="dim")
150
+
151
+ for i, key in enumerate(sorted(registry.keys()), 1):
152
+ info = registry[key]
153
+ table.add_row(str(i), info["name"], info["path"])
154
+
155
+ console.print()
156
+ console.print(table)
157
+ console.print(
158
+ f"\n[dim]{len(registry)} applications registered • "
159
+ f"Registry: {get_registry_path()}[/dim]\n"
160
+ )
161
+
162
+
163
+ def list_running(console: Console) -> None:
164
+ """List currently running GUI applications via System Events."""
165
+ try:
166
+ result = subprocess.run(
167
+ [
168
+ "osascript",
169
+ "-e",
170
+ 'tell application "System Events" to get name of '
171
+ "every process whose background only is false",
172
+ ],
173
+ capture_output=True,
174
+ text=True,
175
+ check=True,
176
+ )
177
+ app_names = sorted(
178
+ name.strip() for name in result.stdout.strip().split(", ")
179
+ )
180
+
181
+ table = Table(
182
+ title="Running Applications",
183
+ show_lines=False,
184
+ border_style="dim",
185
+ title_style="bold green",
186
+ )
187
+ table.add_column("#", style="dim", justify="right", width=4)
188
+ table.add_column("Application", style="green bold", no_wrap=True)
189
+
190
+ for i, name in enumerate(app_names, 1):
191
+ table.add_row(str(i), name)
192
+
193
+ console.print()
194
+ console.print(table)
195
+ console.print(f"\n[dim]{len(app_names)} applications running[/dim]\n")
196
+
197
+ except subprocess.CalledProcessError as e:
198
+ console.print(
199
+ f"[red]✗ Failed to list running apps:[/red] {e.stderr.strip()}"
200
+ )
201
+ except Exception as e:
202
+ console.print(f"[red]✗ Error:[/red] {e}")
commands/files.py ADDED
@@ -0,0 +1,310 @@
1
+ """File utility commands — tree, preview, mkp, search."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from rich.console import Console
7
+ from rich.syntax import Syntax
8
+ from rich.table import Table
9
+ from rich.tree import Tree
10
+
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Helpers
14
+ # ---------------------------------------------------------------------------
15
+
16
+ def _format_size(size: int) -> str:
17
+ """Return a human-readable file-size string."""
18
+ for unit in ("B", "KB", "MB", "GB"):
19
+ if size < 1024:
20
+ return f"{size:.0f}{unit}" if unit == "B" else f"{size:.1f}{unit}"
21
+ size /= 1024
22
+ return f"{size:.1f}TB"
23
+
24
+
25
+ _LEXER_MAP: dict[str, str] = {
26
+ "py": "python", "js": "javascript", "ts": "typescript",
27
+ "jsx": "jsx", "tsx": "tsx", "rb": "ruby", "go": "go",
28
+ "rs": "rust", "java": "java", "c": "c", "cpp": "cpp",
29
+ "h": "c", "hpp": "cpp", "cs": "csharp", "swift": "swift",
30
+ "kt": "kotlin", "sh": "bash", "zsh": "bash", "bash": "bash",
31
+ "json": "json", "yaml": "yaml", "yml": "yaml",
32
+ "toml": "toml", "xml": "xml", "html": "html",
33
+ "css": "css", "scss": "scss", "md": "markdown",
34
+ "sql": "sql", "dockerfile": "dockerfile",
35
+ "makefile": "makefile", "mk": "makefile",
36
+ "r": "r", "lua": "lua", "pl": "perl", "php": "php",
37
+ }
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # tree
42
+ # ---------------------------------------------------------------------------
43
+
44
+ def handle_tree(args: list[str], cwd: str, console: Console) -> None:
45
+ """Display a directory tree using Rich's Tree widget.
46
+
47
+ Usage: tree [path] [--depth N]
48
+ Defaults to the current directory with a depth of 3.
49
+ """
50
+ target = cwd
51
+ max_depth = 3
52
+
53
+ i = 0
54
+ while i < len(args):
55
+ if args[i] == "--depth" and i + 1 < len(args):
56
+ try:
57
+ max_depth = int(args[i + 1])
58
+ except ValueError:
59
+ console.print("[red]✗ --depth requires a number[/red]")
60
+ return
61
+ i += 2
62
+ else:
63
+ target = (
64
+ args[i]
65
+ if os.path.isabs(args[i])
66
+ else str(Path(cwd) / args[i])
67
+ )
68
+ i += 1
69
+
70
+ target_path = Path(target).resolve()
71
+
72
+ if not target_path.exists():
73
+ console.print(f"[red]✗ Path not found: {target}[/red]")
74
+ return
75
+ if not target_path.is_dir():
76
+ console.print(f"[red]✗ Not a directory: {target}[/red]")
77
+ return
78
+
79
+ tree = Tree(
80
+ f"[bold cyan]📁 {target_path.name or str(target_path)}[/bold cyan]",
81
+ guide_style="dim",
82
+ )
83
+ _build_tree(target_path, tree, max_depth, current_depth=0)
84
+
85
+ console.print()
86
+ console.print(tree)
87
+ console.print()
88
+
89
+
90
+ def _build_tree(
91
+ path: Path, tree: Tree, max_depth: int, current_depth: int
92
+ ) -> None:
93
+ """Recursively populate a Rich Tree, skipping hidden entries."""
94
+ if current_depth >= max_depth:
95
+ return
96
+
97
+ try:
98
+ entries = sorted(
99
+ path.iterdir(),
100
+ key=lambda p: (not p.is_dir(), p.name.lower()),
101
+ )
102
+ except PermissionError:
103
+ tree.add("[red]⚠ Permission denied[/red]")
104
+ return
105
+
106
+ # Skip hidden files/dirs
107
+ entries = [e for e in entries if not e.name.startswith(".")]
108
+
109
+ for entry in entries:
110
+ if entry.is_dir():
111
+ branch = tree.add(f"[bold blue]📁 {entry.name}[/bold blue]")
112
+ _build_tree(entry, branch, max_depth, current_depth + 1)
113
+ else:
114
+ try:
115
+ size_str = _format_size(entry.stat().st_size)
116
+ except OSError:
117
+ size_str = "?"
118
+ tree.add(f"[green]{entry.name}[/green] [dim]({size_str})[/dim]")
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # preview
123
+ # ---------------------------------------------------------------------------
124
+
125
+ def handle_preview(args: list[str], cwd: str, console: Console) -> None:
126
+ """Preview a file with syntax highlighting.
127
+
128
+ Usage: preview <file> [--lines N]
129
+ Defaults to 30 lines.
130
+ """
131
+ if not args:
132
+ console.print("[red]✗ Usage: preview <file> [--lines N][/red]")
133
+ return
134
+
135
+ max_lines = 30
136
+ filepath = None
137
+
138
+ i = 0
139
+ while i < len(args):
140
+ if args[i] == "--lines" and i + 1 < len(args):
141
+ try:
142
+ max_lines = int(args[i + 1])
143
+ except ValueError:
144
+ console.print("[red]✗ --lines requires a number[/red]")
145
+ return
146
+ i += 2
147
+ else:
148
+ filepath = args[i]
149
+ i += 1
150
+
151
+ if not filepath:
152
+ console.print("[red]✗ No file specified[/red]")
153
+ return
154
+
155
+ target = (
156
+ Path(filepath)
157
+ if os.path.isabs(filepath)
158
+ else Path(cwd) / filepath
159
+ )
160
+
161
+ if not target.exists():
162
+ console.print(f"[red]✗ File not found: {filepath}[/red]")
163
+ return
164
+ if not target.is_file():
165
+ console.print(f"[red]✗ Not a file: {filepath}[/red]")
166
+ return
167
+
168
+ try:
169
+ content = target.read_text(errors="replace")
170
+ lines = content.splitlines()
171
+ total_lines = len(lines)
172
+ preview_content = "\n".join(lines[:max_lines])
173
+
174
+ ext = target.suffix.lstrip(".")
175
+ lexer = _LEXER_MAP.get(ext.lower(), "text")
176
+
177
+ syntax = Syntax(
178
+ preview_content,
179
+ lexer,
180
+ theme="monokai",
181
+ line_numbers=True,
182
+ word_wrap=False,
183
+ )
184
+
185
+ console.print()
186
+ console.print(
187
+ f"[bold cyan]📄 {target.name}[/bold cyan] "
188
+ f"[dim]({total_lines} lines total)[/dim]"
189
+ )
190
+ console.print(syntax)
191
+
192
+ if total_lines > max_lines:
193
+ console.print(
194
+ f"\n[dim]... showing {max_lines} of {total_lines} lines "
195
+ f"(use --lines N to show more)[/dim]"
196
+ )
197
+ console.print()
198
+
199
+ except Exception as e:
200
+ console.print(f"[red]✗ Error reading file:[/red] {e}")
201
+
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # mkp (mkdir -p)
205
+ # ---------------------------------------------------------------------------
206
+
207
+ def handle_mkp(args: list[str], cwd: str, console: Console) -> None:
208
+ """Create directories recursively (like mkdir -p).
209
+
210
+ Usage: mkp <dir> [dir ...]
211
+ """
212
+ if not args:
213
+ console.print("[red]✗ Usage: mkp <directory> [directory ...][/red]")
214
+ return
215
+
216
+ for dir_name in args:
217
+ target = (
218
+ Path(dir_name)
219
+ if os.path.isabs(dir_name)
220
+ else Path(cwd) / dir_name
221
+ )
222
+ try:
223
+ target.mkdir(parents=True, exist_ok=True)
224
+ console.print(f"[green]✓[/green] Created [bold]{target}[/bold]")
225
+ except Exception as e:
226
+ console.print(f"[red]✗ Failed to create {dir_name}:[/red] {e}")
227
+
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # search (recursive glob)
231
+ # ---------------------------------------------------------------------------
232
+
233
+ def handle_search(args: list[str], cwd: str, console: Console) -> None:
234
+ """Find files matching a glob pattern.
235
+
236
+ Usage: search <pattern> [--path <dir>]
237
+ Searches recursively from the current directory (or --path).
238
+ Named 'search' to avoid shadowing the system 'find' command.
239
+ """
240
+ if not args:
241
+ console.print("[red]✗ Usage: search <pattern> [--path <dir>][/red]")
242
+ return
243
+
244
+ pattern = args[0]
245
+ search_dir = cwd
246
+
247
+ i = 1
248
+ while i < len(args):
249
+ if args[i] == "--path" and i + 1 < len(args):
250
+ search_dir = (
251
+ args[i + 1]
252
+ if os.path.isabs(args[i + 1])
253
+ else str(Path(cwd) / args[i + 1])
254
+ )
255
+ i += 2
256
+ else:
257
+ i += 1
258
+
259
+ search_path = Path(search_dir)
260
+ if not search_path.exists():
261
+ console.print(f"[red]✗ Path not found: {search_dir}[/red]")
262
+ return
263
+
264
+ matches = list(search_path.rglob(pattern))
265
+
266
+ # Filter out entries inside hidden directories
267
+ matches = [
268
+ m
269
+ for m in matches
270
+ if not any(
271
+ part.startswith(".") for part in m.relative_to(search_path).parts
272
+ )
273
+ ]
274
+
275
+ if not matches:
276
+ console.print(f"[yellow]No files matching '{pattern}' found.[/yellow]")
277
+ return
278
+
279
+ table = Table(
280
+ title=f"Files matching '{pattern}'",
281
+ show_lines=False,
282
+ border_style="dim",
283
+ title_style="bold cyan",
284
+ )
285
+ table.add_column("#", style="dim", justify="right", width=4)
286
+ table.add_column("File", style="cyan", no_wrap=True)
287
+ table.add_column("Size", style="dim", justify="right")
288
+ table.add_column("Path", style="dim")
289
+
290
+ display_limit = 50
291
+ for i, match in enumerate(matches[:display_limit], 1):
292
+ try:
293
+ size = (
294
+ _format_size(match.stat().st_size)
295
+ if match.is_file()
296
+ else "[dir]"
297
+ )
298
+ except OSError:
299
+ size = "?"
300
+ rel = str(match.relative_to(search_path).parent)
301
+ table.add_row(str(i), match.name, size, rel if rel != "." else "")
302
+
303
+ console.print()
304
+ console.print(table)
305
+
306
+ if len(matches) > display_limit:
307
+ console.print(
308
+ f"\n[dim]... showing {display_limit} of {len(matches)} results[/dim]"
309
+ )
310
+ console.print(f"\n[dim]{len(matches)} files found[/dim]\n")
executor.py ADDED
@@ -0,0 +1,273 @@
1
+ """Command executor — routes input to custom handlers or forwards to system shell.
2
+
3
+ This module is the brain of the routing layer. It maintains shell state
4
+ (current_directory, previous_directory) and decides whether a command is
5
+ handled by our own code or forwarded to the user's real zsh shell.
6
+
7
+ State is stored in module-level variables instead of a class.
8
+ """
9
+
10
+ import os
11
+ import subprocess
12
+ from pathlib import Path
13
+
14
+ from rich.console import Console
15
+ from rich.table import Table
16
+
17
+ from commands.apps import close_app, list_apps, list_running, open_app
18
+ from commands.files import (
19
+ handle_mkp,
20
+ handle_preview,
21
+ handle_search,
22
+ handle_tree,
23
+ )
24
+ from registry.scanner import scan_applications
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Module-level state (instead of class attributes)
29
+ # ---------------------------------------------------------------------------
30
+
31
+ console: Console = None # Set by init()
32
+ current_directory: str = os.getcwd()
33
+ previous_directory: str = None
34
+
35
+ # Descriptions shown by the `help` command
36
+ COMMAND_HELP = {
37
+ "cd": "Change directory (cd <path> | cd ~ | cd - | cd)",
38
+ "clear": "Clear the terminal screen",
39
+ "scan": "Scan /Applications and build the app registry",
40
+ "open": "Open an application (open <app_name>)",
41
+ "close": "Close a running application (close <app_name>)",
42
+ "apps": "List all registered applications",
43
+ "running": "List currently running GUI applications",
44
+ "tree": "Display directory tree (tree [path] [--depth N])",
45
+ "preview": "Preview file with syntax highlighting (preview <file> [--lines N])",
46
+ "mkp": "Create directories recursively (mkp <dir> [dir ...])",
47
+ "search": "Find files by glob pattern (search <pattern> [--path <dir>])",
48
+ "help": "Show this help message",
49
+ "exit": "Exit Goutham Shell (also: quit)",
50
+ }
51
+
52
+
53
+ def init(shell_console: Console) -> None:
54
+ """Initialize the executor with a Rich console."""
55
+ global console
56
+ console = shell_console
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Main routing function
61
+ # ---------------------------------------------------------------------------
62
+
63
+ def execute(user_input: str) -> None:
64
+ """Parse the first token and route to the right handler."""
65
+ parts = user_input.strip().split()
66
+ if not parts:
67
+ return
68
+
69
+ cmd = parts[0]
70
+ args = parts[1:]
71
+ cmd_lower = cmd.lower()
72
+
73
+ handler = {
74
+ "cd": lambda: handle_cd(args),
75
+ "clear": lambda: handle_clear(),
76
+ "help": lambda: handle_help(),
77
+ "scan": lambda: handle_scan(),
78
+ "open": lambda: handle_open(args, user_input),
79
+ "close": lambda: handle_close(args),
80
+ "apps": lambda: handle_apps(),
81
+ "running": lambda: handle_running(),
82
+ "tree": lambda: handle_tree_cmd(args),
83
+ "preview": lambda: handle_preview_cmd(args),
84
+ "mkp": lambda: handle_mkp_cmd(args),
85
+ "search": lambda: handle_search_cmd(args),
86
+ }.get(cmd_lower)
87
+
88
+ if handler:
89
+ handler()
90
+ else:
91
+ forward_to_shell(user_input)
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Built-in handlers
96
+ # ---------------------------------------------------------------------------
97
+
98
+ def handle_cd(args: list) -> None:
99
+ """Change the shell's working directory.
100
+
101
+ Supports: cd, cd ~, cd ~/..., cd -, cd <relative>, cd <absolute>.
102
+ """
103
+ global current_directory, previous_directory
104
+
105
+ if not args:
106
+ target = Path.home()
107
+ elif args[0] == "-":
108
+ if previous_directory:
109
+ target = Path(previous_directory)
110
+ console.print(f"[dim]{target}[/dim]")
111
+ else:
112
+ console.print("[yellow]cd: no previous directory[/yellow]")
113
+ return
114
+ else:
115
+ raw = " ".join(args)
116
+ if raw.startswith("~"):
117
+ raw = str(Path.home()) + raw[1:]
118
+ target = Path(raw)
119
+ if not target.is_absolute():
120
+ target = Path(current_directory) / target
121
+
122
+ target = target.resolve()
123
+
124
+ if target.is_dir():
125
+ previous_directory = current_directory
126
+ current_directory = str(target)
127
+ else:
128
+ console.print(
129
+ f"[red]cd: no such directory: {args[0] if args else ''}[/red]"
130
+ )
131
+
132
+
133
+ def handle_clear() -> None:
134
+ """Clear the terminal screen."""
135
+ os.system("clear")
136
+
137
+
138
+ def handle_help() -> None:
139
+ """Show available custom commands."""
140
+ table = Table(
141
+ title="Goutham Shell Commands",
142
+ show_lines=False,
143
+ border_style="dim",
144
+ title_style="bold cyan",
145
+ )
146
+ table.add_column("Command", style="cyan bold", no_wrap=True, min_width=12)
147
+ table.add_column("Description", style="white")
148
+
149
+ for cmd, desc in COMMAND_HELP.items():
150
+ table.add_row(cmd, desc)
151
+
152
+ console.print()
153
+ console.print(table)
154
+ console.print(
155
+ "\n[dim]All other commands are forwarded transparently "
156
+ "to your system shell (zsh).[/dim]\n"
157
+ )
158
+
159
+
160
+ def handle_scan() -> None:
161
+ """Scan for applications."""
162
+ scan_applications(console)
163
+
164
+
165
+ def find_file(filename: str) -> Path | None:
166
+ """Search common locations for a file."""
167
+ search_dirs = [
168
+ Path(current_directory),
169
+ Path.home(),
170
+ Path.home() / "Downloads",
171
+ Path.home() / "Desktop",
172
+ Path.home() / "Documents",
173
+ ]
174
+
175
+ for directory in search_dirs:
176
+ candidate = directory / filename
177
+ if candidate.exists():
178
+ return candidate
179
+
180
+ return None
181
+
182
+ def handle_open(args: list, raw_input: str) -> None:
183
+ """Open an application — or fall through to system open."""
184
+ name = " ".join(args)
185
+
186
+ # First try opening as an application
187
+ if open_app(name, console):
188
+ return
189
+
190
+ # Then try opening as a file
191
+ file_path = find_file(name)
192
+ if file_path:
193
+ subprocess.run(["open", str(file_path)])
194
+ return
195
+
196
+ # Otherwise let macOS handle it
197
+ forward_to_shell(raw_input)
198
+
199
+ first = args[0]
200
+ # If the argument looks like a path, URL, or flag → forward to system
201
+ if (
202
+ first.startswith("-")
203
+ or first.startswith("/")
204
+ or first.startswith("./")
205
+ or first.startswith("..")
206
+ or "://" in first
207
+ or first == "."
208
+ ):
209
+ forward_to_shell(raw_input)
210
+ return
211
+
212
+ # Try the app registry; fall through to system open if not found
213
+ app_name = " ".join(args)
214
+ if not open_app(app_name, console):
215
+ forward_to_shell(raw_input)
216
+
217
+
218
+ def handle_close(args: list) -> None:
219
+ """Close an application."""
220
+ if not args:
221
+ console.print("[red]✗ Usage: close <app_name>[/red]")
222
+ return
223
+ close_app(" ".join(args), console)
224
+
225
+
226
+ def handle_apps() -> None:
227
+ """List registered applications."""
228
+ list_apps(console)
229
+
230
+
231
+ def handle_running() -> None:
232
+ """List running applications."""
233
+ list_running(console)
234
+
235
+
236
+ def handle_tree_cmd(args: list) -> None:
237
+ """Display directory tree."""
238
+ handle_tree(args, current_directory, console)
239
+
240
+
241
+ def handle_preview_cmd(args: list) -> None:
242
+ """Preview a file."""
243
+ handle_preview(args, current_directory, console)
244
+
245
+
246
+ def handle_mkp_cmd(args: list) -> None:
247
+ """Create directories."""
248
+ handle_mkp(args, current_directory, console)
249
+
250
+
251
+ def handle_search_cmd(args: list) -> None:
252
+ """Find files."""
253
+ handle_search(args, current_directory, console)
254
+
255
+
256
+ # ---------------------------------------------------------------------------
257
+ # System shell forwarding
258
+ # ---------------------------------------------------------------------------
259
+
260
+ def forward_to_shell(user_input: str) -> None:
261
+ """Forward a command verbatim to /bin/zsh with the shell's cwd."""
262
+ try:
263
+ subprocess.run(
264
+ user_input,
265
+ shell=True,
266
+ cwd=current_directory,
267
+ executable="/bin/zsh",
268
+ env={**os.environ, "CLICOLOR_FORCE": "1"},
269
+ )
270
+ except FileNotFoundError:
271
+ console.print("[red]✗ Command not found[/red]")
272
+ except Exception as e:
273
+ console.print(f"[red]✗ Error:[/red] {e}")
@@ -0,0 +1,169 @@
1
+ Metadata-Version: 2.4
2
+ Name: gc-shell
3
+ Version: 0.1.0
4
+ Summary: An intelligent macOS shell
5
+ Author: Goutham Reddy
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: rich>=13.0
10
+ Requires-Dist: prompt_toolkit>=3.0
11
+
12
+ # 🚀 Goutham Shell
13
+
14
+ A custom interactive shell for macOS that combines the power of a standard terminal with personalized built-in commands.
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ # Clone the repository
20
+ git clone <repo-url>
21
+ cd goutham-shell
22
+
23
+ # Install in development mode
24
+ pip install -e .
25
+
26
+ # Launch the shell
27
+ goutham
28
+ ```
29
+
30
+ ## Quick Start
31
+
32
+ ```
33
+ $ goutham
34
+ 🚀 Welcome to Goutham Shell v0.1.0
35
+ Type help for commands, exit to quit.
36
+
37
+ 💡 No app registry found. Run scan to discover installed applications.
38
+
39
+ goutham ~ ❯ scan
40
+ 🔍 Scanning for applications...
41
+ ✓ Found 142 applications
42
+ ✓ Registry saved to ~/.goutham/apps.json
43
+
44
+ goutham ~ ❯ open chrome
45
+ ▶ Opening Google Chrome...
46
+
47
+ goutham ~ ❯ running
48
+ ┌──────────────────────────────┐
49
+ │ Running Applications │
50
+ ├────┬─────────────────────────┤
51
+ │ # │ Application │
52
+ ├────┼─────────────────────────┤
53
+ │ 1 │ Finder │
54
+ │ 2 │ Google Chrome │
55
+ │ 3 │ Terminal │
56
+ └────┴─────────────────────────┘
57
+
58
+ goutham ~ ❯ ls -la
59
+ total 32
60
+ drwxr-xr-x 8 user staff 256 Jun 26 12:00 .
61
+ ...
62
+ ```
63
+
64
+ ## Features
65
+
66
+ ### System Commands
67
+
68
+ All standard terminal commands work transparently — they're forwarded to your system's `zsh` shell:
69
+
70
+ - `ls`, `pwd`, `cat`, `grep`, `echo`
71
+ - `git status`, `git commit`, `git push`
72
+ - `python app.py`, `node server.js`
73
+ - `docker ps`, `npm run dev`
74
+ - Pipes (`|`), redirection (`>`), chaining (`&&`)
75
+
76
+ ### Custom Commands
77
+
78
+ | Command | Description |
79
+ |---------|-------------|
80
+ | `scan` | Scan /Applications and build the app registry |
81
+ | `open <app>` | Open an application by name (fuzzy matching) |
82
+ | `close <app>` | Close a running application |
83
+ | `apps` | List all registered applications |
84
+ | `running` | List currently running GUI applications |
85
+ | `tree [path]` | Display a directory tree (`--depth N`) |
86
+ | `preview <file>` | Preview a file with syntax highlighting (`--lines N`) |
87
+ | `mkp <dir>` | Create nested directories (like `mkdir -p`) |
88
+ | `search <pattern>` | Find files by glob pattern (`--path <dir>`) |
89
+ | `help` | Show all available commands |
90
+ | `exit` / `quit` | Exit the shell |
91
+
92
+ ### App Management
93
+
94
+ On first run, use `scan` to discover installed applications. Then use `open` and `close` with **fuzzy name matching** — no need to type the full name:
95
+
96
+ ```
97
+ goutham ~ ❯ open chrome # Opens "Google Chrome"
98
+ goutham ~ ❯ open code # Opens "Visual Studio Code"
99
+ goutham ~ ❯ close spotify # Closes "Spotify"
100
+ ```
101
+
102
+ The `open` command is smart — paths, URLs, and flags are forwarded to the system `open`:
103
+
104
+ ```
105
+ goutham ~ ❯ open . # Opens current dir in Finder
106
+ goutham ~ ❯ open https://x.com # Opens URL in default browser
107
+ goutham ~ ❯ open -a Safari # System open with flags
108
+ ```
109
+
110
+ ## Architecture
111
+
112
+ Goutham Shell is a **wrapper shell** — it intercepts custom commands while forwarding everything else transparently to `zsh`.
113
+
114
+ ```
115
+ User Input → Command Parser → Custom Command? → Execute Handler
116
+ ↓ No
117
+ Forward to zsh via subprocess
118
+ ```
119
+
120
+ Key design decisions:
121
+
122
+ - **Shell state is maintained in-process**: `cd` updates `current_directory` so subsequent commands run in the right place.
123
+ - **No hardcoded paths**: All app paths come from the scanned registry at `~/.goutham/apps.json`.
124
+ - **`shell=True` forwarding**: Preserves pipes, redirection, globbing, and chaining — users get the full power of `zsh`.
125
+
126
+ ## Requirements
127
+
128
+ - macOS 10.15+
129
+ - Python 3.9+
130
+ - [Rich](https://github.com/Textualize/rich) (installed automatically)
131
+
132
+ ## Configuration
133
+
134
+ All configuration is stored in `~/.goutham/`:
135
+
136
+ | File | Purpose |
137
+ |------|---------|
138
+ | `apps.json` | Application registry (generated by `scan`) |
139
+
140
+ ## Project Structure
141
+
142
+ ```
143
+ goutham-shell/
144
+ ├── main.py # Entry point
145
+ ├── shell.py # Interactive REPL loop
146
+ ├── executor.py # Command routing & shell state
147
+ ├── commands/
148
+ │ ├── apps.py # open, close, apps, running
149
+ │ └── files.py # tree, preview, mkp, search
150
+ ├── registry/
151
+ │ └── scanner.py # macOS app scanner
152
+ ├── ai/ # Phase 5+ (AI commands)
153
+ ├── utils/
154
+ ├── pyproject.toml
155
+ └── README.md
156
+ ```
157
+
158
+ ## Roadmap
159
+
160
+ - [x] Phase 1 — Interactive shell skeleton
161
+ - [x] Phase 2 — System command forwarding with persistent state
162
+ - [x] Phase 3 — Custom built-in commands (app management)
163
+ - [x] Phase 4 — File utility commands
164
+ - [ ] Phase 5 — AI commands (`ai explain`, `ai generate`, `ai summarize`)
165
+ - [ ] Phase 6 — LangGraph agent workflows
166
+
167
+ ## License
168
+
169
+ MIT
@@ -0,0 +1,13 @@
1
+ executor.py,sha256=IhA5eNbJBHcS-WYcMM6Sis2KQ_RGXsRwzqfEsIpLTf0,8116
2
+ main.py,sha256=5d9l7Ye0rK-yqIE7GyYrBvkM-nj1q5qEhKrcAcLBdN8,180
3
+ shell.py,sha256=K-Ze0eRycFCQN2oPLvTZujP3Eu6h7FXSUrPHmNV0d6o,2951
4
+ commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ commands/apps.py,sha256=5kADaULuJQZbvAvgfqP7UB8sO6Yyy-GM7Qjk5uxuhJg,6210
6
+ commands/files.py,sha256=j5R-jQ3Z-SEZOlLeHTF9L7bx2IgKhIB9jJtT_DNCwgg,9406
7
+ registry/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ registry/scanner.py,sha256=PBNqBsOSwIiZQ1WJu3Rdp4oEXhRJSLuE5QGFbhoPlwY,2851
9
+ gc_shell-0.1.0.dist-info/METADATA,sha256=FYhWH4BuhMydZcS8DjmF7Js4hA0Gh3HI4k56h576z6I,5137
10
+ gc_shell-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ gc_shell-0.1.0.dist-info/entry_points.txt,sha256=EIe5PhQA8eRMB3OuxrA7yHWpGYy6vJgNCu2NLIn8-CU,39
12
+ gc_shell-0.1.0.dist-info/top_level.txt,sha256=PuwTqKOZ65P7DAM-AyyNdXuRMtneQC1NqGMSd3-BDqA,38
13
+ gc_shell-0.1.0.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
+ gc-shell = main:main
@@ -0,0 +1,5 @@
1
+ commands
2
+ executor
3
+ main
4
+ registry
5
+ shell
main.py ADDED
@@ -0,0 +1,12 @@
1
+ """Entry point for Goutham Shell."""
2
+
3
+ from shell import run_shell
4
+
5
+
6
+ def main():
7
+ """Create and run the Goutham Shell."""
8
+ run_shell()
9
+
10
+
11
+ if __name__ == "__main__":
12
+ main()
registry/__init__.py ADDED
File without changes
registry/scanner.py ADDED
@@ -0,0 +1,96 @@
1
+ """Application scanner for macOS — discovers .app bundles and builds a registry."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+
11
+ REGISTRY_DIR = Path.home() / ".goutham"
12
+ REGISTRY_FILE = REGISTRY_DIR / "apps.json"
13
+
14
+
15
+ def get_registry_path() -> Path:
16
+ """Return the path to the app registry file."""
17
+ return REGISTRY_FILE
18
+
19
+
20
+ def load_registry() -> dict:
21
+ """Load the app registry from disk. Returns empty dict if not found."""
22
+ if not REGISTRY_FILE.exists():
23
+ return {}
24
+ try:
25
+ with open(REGISTRY_FILE, "r") as f:
26
+ return json.load(f)
27
+ except (json.JSONDecodeError, IOError):
28
+ return {}
29
+
30
+
31
+ def scan_applications(console: Console) -> dict:
32
+ """Scan /Applications and ~/Applications for .app bundles, write registry.
33
+
34
+ Walks both directories recursively, skipping .app bundles nested inside
35
+ other .app bundles (which are internal frameworks, not user-facing apps).
36
+ Results are written to ~/.goutham/apps.json keyed by lowercased app name.
37
+ """
38
+ console.print("\n[bold cyan]🔍 Scanning for applications...[/bold cyan]\n")
39
+
40
+ scan_dirs = [
41
+ Path("/Applications"),
42
+ Path("/System/Applications"),
43
+ Path("/System/Library/CoreServices"),
44
+ Path.home() / "Applications",
45
+ ]
46
+
47
+ apps: dict[str, dict[str, str]] = {}
48
+
49
+ for scan_dir in scan_dirs:
50
+ if not scan_dir.exists():
51
+ continue
52
+
53
+ for entry in scan_dir.rglob("*.app"):
54
+ # Skip .app bundles nested inside another .app bundle
55
+ relative = entry.relative_to(scan_dir)
56
+ is_nested = any(
57
+ part.endswith(".app") for part in relative.parts[:-1]
58
+ )
59
+ if is_nested:
60
+ continue
61
+
62
+ app_name = entry.stem # strip .app extension
63
+ key = app_name.lower()
64
+ apps[key] = {
65
+ "name": app_name,
66
+ "path": str(entry),
67
+ }
68
+
69
+ # Ensure config directory exists and write registry
70
+ REGISTRY_DIR.mkdir(parents=True, exist_ok=True)
71
+ with open(REGISTRY_FILE, "w") as f:
72
+ json.dump(apps, f, indent=2, sort_keys=True)
73
+
74
+ # Display results in a Rich table
75
+ table = Table(
76
+ title="Discovered Applications",
77
+ show_lines=False,
78
+ border_style="dim",
79
+ title_style="bold cyan",
80
+ )
81
+ table.add_column("Application", style="cyan", no_wrap=True)
82
+ table.add_column("Path", style="dim")
83
+
84
+ for key in sorted(apps.keys()):
85
+ info = apps[key]
86
+ table.add_row(info["name"], info["path"])
87
+
88
+ console.print(table)
89
+ console.print(
90
+ f"\n[green]✓[/green] Found [bold]{len(apps)}[/bold] applications"
91
+ )
92
+ console.print(
93
+ f"[green]✓[/green] Registry saved to [dim]{REGISTRY_FILE}[/dim]\n"
94
+ )
95
+
96
+ return apps
shell.py ADDED
@@ -0,0 +1,110 @@
1
+ """Interactive shell loop — prompt, read input, delegate to executor.
2
+
3
+ This module owns the UI (prompt, welcome banner, goodbye message) and
4
+ nothing else. All command logic lives in executor.py and the commands/
5
+ package.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+ from rich.text import Text
13
+
14
+ import executor
15
+ from registry.scanner import get_registry_path
16
+ from prompt_toolkit import PromptSession
17
+ from prompt_toolkit.history import FileHistory
18
+ from prompt_toolkit.formatted_text import HTML
19
+
20
+
21
+ # One shared console for the entire shell
22
+ console = Console()
23
+
24
+ history_file = str(Path.home() / ".gc_shell_history")
25
+
26
+ session = PromptSession(
27
+ history=FileHistory(history_file)
28
+ )
29
+
30
+
31
+ def build_prompt() -> str:
32
+ """Build a Rich-styled prompt showing the current directory."""
33
+ cwd = executor.current_directory
34
+ home = str(Path.home())
35
+
36
+ if cwd == home:
37
+ display = "~"
38
+ elif cwd.startswith(home + "/"):
39
+ display = "~" + cwd[len(home):]
40
+ else:
41
+ display = cwd
42
+
43
+ return HTML(
44
+ f"<ansicyan><b>gc-shell</b></ansicyan> "
45
+ f"<ansigray>{display}</ansigray> "
46
+ f"<ansigreen><b>❯</b></ansigreen> "
47
+ )
48
+
49
+
50
+ def print_welcome() -> None:
51
+ """Print the welcome banner."""
52
+ banner = Text()
53
+ banner.append("🚀 ", style="bold")
54
+ banner.append("Goutham Shell", style="bold cyan")
55
+ banner.append(" v0.1.0\n", style="dim")
56
+ banner.append(" Type ", style="dim")
57
+ banner.append("help", style="bold cyan")
58
+ banner.append(" for commands, ", style="dim")
59
+ banner.append("exit", style="bold cyan")
60
+ banner.append(" to quit.", style="dim")
61
+
62
+ console.print()
63
+ console.print(Panel(banner, border_style="cyan", padding=(0, 2)))
64
+ console.print()
65
+
66
+
67
+ def check_first_run() -> None:
68
+ """If no app registry exists, suggest running scan."""
69
+ if not get_registry_path().exists():
70
+ console.print(
71
+ "[yellow]💡 No app registry found. "
72
+ "Run [bold]scan[/bold] to discover installed "
73
+ "applications.[/yellow]\n"
74
+ )
75
+
76
+
77
+ def print_goodbye() -> None:
78
+ """Print the goodbye message."""
79
+ console.print("\n[bold cyan]👋 See you later![/bold cyan]\n")
80
+
81
+
82
+ def run_shell() -> None:
83
+ """Start the interactive read-eval-print loop."""
84
+ # Initialize the executor with our console
85
+ executor.init(console)
86
+
87
+ print_welcome()
88
+ check_first_run()
89
+
90
+ while True:
91
+ try:
92
+ prompt = build_prompt()
93
+ user_input = session.prompt(prompt).strip()
94
+
95
+ if not user_input:
96
+ continue
97
+
98
+ if user_input.lower() in ("exit", "quit"):
99
+ print_goodbye()
100
+ break
101
+
102
+ executor.execute(user_input)
103
+
104
+ except KeyboardInterrupt:
105
+ console.print() # newline after ^C
106
+ continue
107
+ except EOFError:
108
+ console.print()
109
+ print_goodbye()
110
+ break