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 +0 -0
- commands/apps.py +202 -0
- commands/files.py +310 -0
- executor.py +273 -0
- gc_shell-0.1.0.dist-info/METADATA +169 -0
- gc_shell-0.1.0.dist-info/RECORD +13 -0
- gc_shell-0.1.0.dist-info/WHEEL +5 -0
- gc_shell-0.1.0.dist-info/entry_points.txt +2 -0
- gc_shell-0.1.0.dist-info/top_level.txt +5 -0
- main.py +12 -0
- registry/__init__.py +0 -0
- registry/scanner.py +96 -0
- shell.py +110 -0
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,,
|
main.py
ADDED
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
|