cw-relay 0.1.0__tar.gz
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.
- cw_relay-0.1.0/.gitignore +29 -0
- cw_relay-0.1.0/LICENSE +21 -0
- cw_relay-0.1.0/PKG-INFO +88 -0
- cw_relay-0.1.0/README.md +62 -0
- cw_relay-0.1.0/pyproject.toml +40 -0
- cw_relay-0.1.0/src/relay/__init__.py +1 -0
- cw_relay-0.1.0/src/relay/__main__.py +4 -0
- cw_relay-0.1.0/src/relay/cli.py +401 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
*.egg-info/
|
|
7
|
+
*.egg
|
|
8
|
+
build/
|
|
9
|
+
dist/
|
|
10
|
+
.venv/
|
|
11
|
+
venv/
|
|
12
|
+
env/
|
|
13
|
+
.pytest_cache/
|
|
14
|
+
.mypy_cache/
|
|
15
|
+
.ruff_cache/
|
|
16
|
+
.tox/
|
|
17
|
+
|
|
18
|
+
# IDE
|
|
19
|
+
.vscode/
|
|
20
|
+
.idea/
|
|
21
|
+
*.swp
|
|
22
|
+
|
|
23
|
+
# OS
|
|
24
|
+
.DS_Store
|
|
25
|
+
Thumbs.db
|
|
26
|
+
|
|
27
|
+
# Local config — never commit a real API key
|
|
28
|
+
config.json
|
|
29
|
+
.env
|
cw_relay-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Casper White
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
cw_relay-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cw-relay
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Claude Code-style terminal chatbot powered by Gemini, with file and shell access.
|
|
5
|
+
Project-URL: Homepage, https://github.com/casperwhite-commits/relay
|
|
6
|
+
Project-URL: Issues, https://github.com/casperwhite-commits/relay/issues
|
|
7
|
+
Author: Casper White
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai,assistant,chat,cli,gemini,terminal
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: google-genai>=0.3.0
|
|
24
|
+
Requires-Dist: rich>=13.0.0
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# relay
|
|
28
|
+
|
|
29
|
+
A Claude Code-style terminal chatbot, powered by Google Gemini, with direct file and shell access.
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
┌─────────────────────────────────────────┐
|
|
33
|
+
│ relay · terminal chat (Gemini) │
|
|
34
|
+
└─────────────────────────────────────────┘
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install relay-cli
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Python 3.10+ on macOS, Linux, or Windows.
|
|
44
|
+
|
|
45
|
+
## Setup
|
|
46
|
+
|
|
47
|
+
You need a Google Gemini API key — grab a free one at [aistudio.google.com](https://aistudio.google.com/app/apikey).
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
relay --set-key
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
(Paste at the hidden prompt — Cmd+V / Ctrl+V works even though nothing visibly happens.)
|
|
54
|
+
|
|
55
|
+
Or set the `GEMINI_API_KEY` environment variable, which takes precedence over the stored config.
|
|
56
|
+
|
|
57
|
+
## Use
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
relay
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Type to chat. Relay can read files, search your filesystem, run shell commands, and write files on its own when the conversation calls for it.
|
|
64
|
+
|
|
65
|
+
### Commands
|
|
66
|
+
|
|
67
|
+
| command | action |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `/help` | show command list |
|
|
70
|
+
| `/exit`, `/quit` | leave |
|
|
71
|
+
| `/clear` | reset the conversation |
|
|
72
|
+
| `/cls` | clear the screen, keep the conversation |
|
|
73
|
+
| `/model <name>` | switch model (e.g. `gemini-2.5-pro`) |
|
|
74
|
+
| `/system <text>` | replace the system instruction |
|
|
75
|
+
| `/cwd <path>` | change working directory |
|
|
76
|
+
| `/save <path>` | save the transcript to a file |
|
|
77
|
+
|
|
78
|
+
### Tools the assistant uses automatically
|
|
79
|
+
|
|
80
|
+
`list_directory` · `read_file` · `glob_files` · `grep_files` · `write_file` · `run_shell`
|
|
81
|
+
|
|
82
|
+
## Safety
|
|
83
|
+
|
|
84
|
+
Relay gives the model direct shell access on your machine. It will run commands and modify files without asking. Don't point it at machines you don't own, and consider reviewing the conversation as it runs.
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT
|
cw_relay-0.1.0/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# relay
|
|
2
|
+
|
|
3
|
+
A Claude Code-style terminal chatbot, powered by Google Gemini, with direct file and shell access.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
┌─────────────────────────────────────────┐
|
|
7
|
+
│ relay · terminal chat (Gemini) │
|
|
8
|
+
└─────────────────────────────────────────┘
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install relay-cli
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Python 3.10+ on macOS, Linux, or Windows.
|
|
18
|
+
|
|
19
|
+
## Setup
|
|
20
|
+
|
|
21
|
+
You need a Google Gemini API key — grab a free one at [aistudio.google.com](https://aistudio.google.com/app/apikey).
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
relay --set-key
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
(Paste at the hidden prompt — Cmd+V / Ctrl+V works even though nothing visibly happens.)
|
|
28
|
+
|
|
29
|
+
Or set the `GEMINI_API_KEY` environment variable, which takes precedence over the stored config.
|
|
30
|
+
|
|
31
|
+
## Use
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
relay
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Type to chat. Relay can read files, search your filesystem, run shell commands, and write files on its own when the conversation calls for it.
|
|
38
|
+
|
|
39
|
+
### Commands
|
|
40
|
+
|
|
41
|
+
| command | action |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `/help` | show command list |
|
|
44
|
+
| `/exit`, `/quit` | leave |
|
|
45
|
+
| `/clear` | reset the conversation |
|
|
46
|
+
| `/cls` | clear the screen, keep the conversation |
|
|
47
|
+
| `/model <name>` | switch model (e.g. `gemini-2.5-pro`) |
|
|
48
|
+
| `/system <text>` | replace the system instruction |
|
|
49
|
+
| `/cwd <path>` | change working directory |
|
|
50
|
+
| `/save <path>` | save the transcript to a file |
|
|
51
|
+
|
|
52
|
+
### Tools the assistant uses automatically
|
|
53
|
+
|
|
54
|
+
`list_directory` · `read_file` · `glob_files` · `grep_files` · `write_file` · `run_shell`
|
|
55
|
+
|
|
56
|
+
## Safety
|
|
57
|
+
|
|
58
|
+
Relay gives the model direct shell access on your machine. It will run commands and modify files without asking. Don't point it at machines you don't own, and consider reviewing the conversation as it runs.
|
|
59
|
+
|
|
60
|
+
## License
|
|
61
|
+
|
|
62
|
+
MIT
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cw-relay"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A Claude Code-style terminal chatbot powered by Gemini, with file and shell access."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Casper White" }]
|
|
13
|
+
keywords = ["cli", "chat", "gemini", "ai", "terminal", "assistant"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Utilities",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"google-genai>=0.3.0",
|
|
29
|
+
"rich>=13.0.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
relay = "relay.cli:main"
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/casperwhite-commits/relay"
|
|
37
|
+
Issues = "https://github.com/casperwhite-commits/relay/issues"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["src/relay"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"""relay — a Claude Code-style terminal chatbot, powered by Gemini, with file/shell access."""
|
|
2
|
+
|
|
3
|
+
import glob as glob_mod
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _config_dir() -> Path:
|
|
13
|
+
"""Cross-platform per-user config directory."""
|
|
14
|
+
if sys.platform == "win32":
|
|
15
|
+
base = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
|
|
16
|
+
return Path(base) / "relay"
|
|
17
|
+
return Path.home() / ".config" / "relay"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
CONFIG_PATH = _config_dir() / "config.json"
|
|
21
|
+
HISTORY_PATH = _config_dir() / "history"
|
|
22
|
+
|
|
23
|
+
BANNER = r"""[bold cyan]
|
|
24
|
+
┌─────────────────────────────────────────┐
|
|
25
|
+
│ relay · terminal chat (Gemini) │
|
|
26
|
+
└─────────────────────────────────────────┘[/bold cyan]
|
|
27
|
+
[dim]/help · /exit · file & shell access enabled[/dim]
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
HELP = """[bold]Commands[/bold]
|
|
31
|
+
[cyan]/help[/cyan] show this help
|
|
32
|
+
[cyan]/exit[/cyan], [cyan]/quit[/cyan] leave relay
|
|
33
|
+
[cyan]/clear[/cyan] reset the conversation
|
|
34
|
+
[cyan]/cls[/cyan] clear the screen (keeps conversation)
|
|
35
|
+
[cyan]/model <name>[/cyan] switch model
|
|
36
|
+
[cyan]/system <text>[/cyan] replace the system instruction
|
|
37
|
+
[cyan]/cwd <path>[/cyan] change working directory
|
|
38
|
+
[cyan]/save <path>[/cyan] save the transcript
|
|
39
|
+
|
|
40
|
+
[bold]Tools the assistant can call[/bold]
|
|
41
|
+
list_directory · read_file · glob_files · grep_files · write_file · run_shell
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
DEFAULT_SYSTEM = (
|
|
45
|
+
"You are relay, a terminal assistant on the user's machine. "
|
|
46
|
+
"You can directly access the filesystem and shell through your tools: "
|
|
47
|
+
"list_directory, read_file, glob_files, grep_files, write_file, run_shell. "
|
|
48
|
+
"Use them proactively — never ask permission, never describe what you would do; just do it and show results. "
|
|
49
|
+
"Be concise; prefer concrete output over prose. "
|
|
50
|
+
"When the user asks a question any tool could answer, call the tool instead of guessing."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
MAX_OUTPUT = 16000
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _clear_screen() -> None:
|
|
57
|
+
"""Clear viewport + scrollback. ANSI on Unix/modern Windows, fallback to `cls` on legacy Windows."""
|
|
58
|
+
if sys.platform == "win32" and not os.environ.get("WT_SESSION"):
|
|
59
|
+
os.system("cls")
|
|
60
|
+
else:
|
|
61
|
+
sys.stdout.write("\033[H\033[2J\033[3J")
|
|
62
|
+
sys.stdout.flush()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def set_key() -> None:
|
|
66
|
+
import getpass
|
|
67
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
cfg: dict = {}
|
|
69
|
+
if CONFIG_PATH.exists():
|
|
70
|
+
with CONFIG_PATH.open() as f:
|
|
71
|
+
cfg = json.load(f)
|
|
72
|
+
new_key = getpass.getpass("new api key (hidden): ").strip()
|
|
73
|
+
if not new_key:
|
|
74
|
+
sys.stderr.write("relay: empty key, aborting\n")
|
|
75
|
+
sys.exit(1)
|
|
76
|
+
cfg["api_key"] = new_key
|
|
77
|
+
cfg.setdefault("model", "gemini-2.5-flash")
|
|
78
|
+
with CONFIG_PATH.open("w") as f:
|
|
79
|
+
json.dump(cfg, f, indent=2)
|
|
80
|
+
try:
|
|
81
|
+
CONFIG_PATH.chmod(0o600)
|
|
82
|
+
except OSError:
|
|
83
|
+
pass # Windows doesn't support POSIX perms
|
|
84
|
+
print(f"relay: key updated in {CONFIG_PATH}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def load_config() -> tuple[str, str]:
|
|
88
|
+
env_key = os.environ.get("GEMINI_API_KEY")
|
|
89
|
+
if CONFIG_PATH.exists():
|
|
90
|
+
with CONFIG_PATH.open() as f:
|
|
91
|
+
cfg = json.load(f)
|
|
92
|
+
else:
|
|
93
|
+
cfg = {}
|
|
94
|
+
key = env_key or cfg.get("api_key")
|
|
95
|
+
if not key:
|
|
96
|
+
sys.stderr.write(
|
|
97
|
+
"relay: no API key found.\n"
|
|
98
|
+
f"Run `relay --set-key` to save one, or set GEMINI_API_KEY in your environment.\n"
|
|
99
|
+
)
|
|
100
|
+
sys.exit(1)
|
|
101
|
+
return key, cfg.get("model", "gemini-2.5-flash")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _truncate(s: str, limit: int = MAX_OUTPUT) -> str:
|
|
105
|
+
if len(s) <= limit:
|
|
106
|
+
return s
|
|
107
|
+
return s[:limit] + f"\n\n[... {len(s) - limit} chars truncated ...]"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ─── tools exposed to the model ────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
def list_directory(path: str = ".") -> str:
|
|
113
|
+
"""List files and subdirectories under `path`.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
path: Filesystem path. Relative paths resolve against relay's current working dir.
|
|
117
|
+
"""
|
|
118
|
+
p = Path(path).expanduser()
|
|
119
|
+
if not p.exists():
|
|
120
|
+
return f"error: {p} does not exist"
|
|
121
|
+
if not p.is_dir():
|
|
122
|
+
return f"error: {p} is not a directory"
|
|
123
|
+
rows = []
|
|
124
|
+
for entry in sorted(p.iterdir()):
|
|
125
|
+
kind = "dir " if entry.is_dir() else "file"
|
|
126
|
+
try:
|
|
127
|
+
size = str(entry.stat().st_size) if entry.is_file() else "-"
|
|
128
|
+
except OSError:
|
|
129
|
+
size = "?"
|
|
130
|
+
rows.append(f"{kind} {size:>10} {entry.name}")
|
|
131
|
+
return _truncate("\n".join(rows) if rows else "(empty)")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def read_file(path: str) -> str:
|
|
135
|
+
"""Read a text file's contents (capped at ~16KB). Refuses binary files.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
path: Path to the file to read.
|
|
139
|
+
"""
|
|
140
|
+
p = Path(path).expanduser()
|
|
141
|
+
if not p.exists():
|
|
142
|
+
return f"error: {p} does not exist"
|
|
143
|
+
if not p.is_file():
|
|
144
|
+
return f"error: {p} is not a regular file"
|
|
145
|
+
try:
|
|
146
|
+
data = p.read_bytes()
|
|
147
|
+
if b"\x00" in data[:2048]:
|
|
148
|
+
return f"error: {p} appears to be binary"
|
|
149
|
+
return _truncate(data.decode("utf-8", errors="replace"))
|
|
150
|
+
except OSError as e:
|
|
151
|
+
return f"error: {e}"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def glob_files(pattern: str) -> str:
|
|
155
|
+
"""Find files/dirs matching a glob pattern (supports ** for recursive).
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
pattern: Glob pattern, e.g. '**/*.py' or 'src/*.ts'.
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
matches = sorted(glob_mod.glob(pattern, recursive=True))
|
|
162
|
+
return _truncate("\n".join(matches)) if matches else "(no matches)"
|
|
163
|
+
except Exception as e:
|
|
164
|
+
return f"error: {e}"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def grep_files(pattern: str, path: str = ".") -> str:
|
|
168
|
+
"""Recursively search for a regex pattern in files under `path`. Returns file:line:text.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
pattern: Python regular expression.
|
|
172
|
+
path: Directory or file to search.
|
|
173
|
+
"""
|
|
174
|
+
try:
|
|
175
|
+
regex = re.compile(pattern)
|
|
176
|
+
except re.error as e:
|
|
177
|
+
return f"error: invalid regex: {e}"
|
|
178
|
+
root = Path(path).expanduser()
|
|
179
|
+
if not root.exists():
|
|
180
|
+
return f"error: {root} does not exist"
|
|
181
|
+
files = [root] if root.is_file() else [p for p in root.rglob("*") if p.is_file()]
|
|
182
|
+
matches: list[str] = []
|
|
183
|
+
for f in files:
|
|
184
|
+
try:
|
|
185
|
+
with f.open("r", errors="replace") as fh:
|
|
186
|
+
for i, line in enumerate(fh, 1):
|
|
187
|
+
if regex.search(line):
|
|
188
|
+
matches.append(f"{f}:{i}:{line.rstrip()}")
|
|
189
|
+
if len(matches) >= 200:
|
|
190
|
+
matches.append("[... 200-match limit reached ...]")
|
|
191
|
+
return _truncate("\n".join(matches))
|
|
192
|
+
except (OSError, UnicodeError):
|
|
193
|
+
continue
|
|
194
|
+
return _truncate("\n".join(matches)) if matches else "(no matches)"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def write_file(path: str, content: str) -> str:
|
|
198
|
+
"""Create or overwrite a file at `path` with `content`. Creates parent dirs if needed.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
path: Destination file path.
|
|
202
|
+
content: Text to write.
|
|
203
|
+
"""
|
|
204
|
+
p = Path(path).expanduser()
|
|
205
|
+
try:
|
|
206
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
207
|
+
p.write_text(content)
|
|
208
|
+
return f"wrote {len(content)} chars to {p}"
|
|
209
|
+
except OSError as e:
|
|
210
|
+
return f"error: {e}"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def run_shell(command: str) -> str:
|
|
214
|
+
"""Execute a shell command in the OS default shell. 60-second timeout.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
command: Shell command string.
|
|
218
|
+
"""
|
|
219
|
+
try:
|
|
220
|
+
r = subprocess.run(
|
|
221
|
+
command, shell=True, capture_output=True, text=True, timeout=60,
|
|
222
|
+
)
|
|
223
|
+
out = (r.stdout or "") + (r.stderr or "")
|
|
224
|
+
head = f"[exit {r.returncode}]\n" if r.returncode != 0 else ""
|
|
225
|
+
return _truncate(head + out) if out else f"[exit {r.returncode}] (no output)"
|
|
226
|
+
except subprocess.TimeoutExpired:
|
|
227
|
+
return "error: command timed out after 60s"
|
|
228
|
+
except Exception as e:
|
|
229
|
+
return f"error: {e}"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
TOOLS = [list_directory, read_file, glob_files, grep_files, write_file, run_shell]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _short(v) -> str:
|
|
236
|
+
if isinstance(v, str):
|
|
237
|
+
s = v.replace("\n", "\\n")
|
|
238
|
+
return repr(s if len(s) <= 60 else s[:57] + "...")
|
|
239
|
+
return repr(v)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def main() -> None:
|
|
243
|
+
if len(sys.argv) > 1:
|
|
244
|
+
if sys.argv[1] in ("--set-key", "-k"):
|
|
245
|
+
set_key()
|
|
246
|
+
return
|
|
247
|
+
if sys.argv[1] in ("--version", "-V"):
|
|
248
|
+
from relay import __version__
|
|
249
|
+
print(f"relay {__version__}")
|
|
250
|
+
return
|
|
251
|
+
if sys.argv[1] in ("--help", "-h"):
|
|
252
|
+
print("Usage: relay [--set-key | --version | --help]\n\nRun `relay` with no args to start the chat.")
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
api_key, model = load_config()
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
from google import genai
|
|
259
|
+
from google.genai import types as genai_types
|
|
260
|
+
except ImportError:
|
|
261
|
+
sys.stderr.write("relay: missing `google-genai`. Reinstall: pip install --upgrade relay-cli\n")
|
|
262
|
+
sys.exit(1)
|
|
263
|
+
try:
|
|
264
|
+
from rich.console import Console
|
|
265
|
+
from rich.markdown import Markdown
|
|
266
|
+
from rich.live import Live
|
|
267
|
+
except ImportError:
|
|
268
|
+
sys.stderr.write("relay: missing `rich`. Reinstall: pip install --upgrade relay-cli\n")
|
|
269
|
+
sys.exit(1)
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
import readline
|
|
273
|
+
HISTORY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
274
|
+
if HISTORY_PATH.exists():
|
|
275
|
+
readline.read_history_file(str(HISTORY_PATH))
|
|
276
|
+
readline.set_history_length(1000)
|
|
277
|
+
except Exception:
|
|
278
|
+
readline = None # readline not available on Windows by default; that's fine
|
|
279
|
+
|
|
280
|
+
_clear_screen()
|
|
281
|
+
|
|
282
|
+
console = Console()
|
|
283
|
+
client = genai.Client(api_key=api_key)
|
|
284
|
+
system_instruction = DEFAULT_SYSTEM
|
|
285
|
+
|
|
286
|
+
def make_chat():
|
|
287
|
+
return client.chats.create(
|
|
288
|
+
model=model,
|
|
289
|
+
config=genai_types.GenerateContentConfig(
|
|
290
|
+
tools=TOOLS,
|
|
291
|
+
system_instruction=system_instruction,
|
|
292
|
+
),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
chat = make_chat()
|
|
296
|
+
|
|
297
|
+
def show_banner():
|
|
298
|
+
console.print(BANNER)
|
|
299
|
+
console.print(f"[dim]model:[/dim] [magenta]{model}[/magenta] [dim]cwd:[/dim] [magenta]{os.getcwd()}[/magenta]\n")
|
|
300
|
+
|
|
301
|
+
show_banner()
|
|
302
|
+
|
|
303
|
+
def save_transcript(path_str: str) -> None:
|
|
304
|
+
path = Path(path_str).expanduser()
|
|
305
|
+
try:
|
|
306
|
+
with path.open("w") as out:
|
|
307
|
+
out.write(f"# system\n{system_instruction}\n\n")
|
|
308
|
+
for msg in chat.get_history():
|
|
309
|
+
role = msg.role
|
|
310
|
+
text = "".join((p.text or "") for p in (msg.parts or []) if hasattr(p, "text"))
|
|
311
|
+
if text:
|
|
312
|
+
out.write(f"# {role}\n{text}\n\n")
|
|
313
|
+
console.print(f"[dim]saved transcript to {path}[/dim]\n")
|
|
314
|
+
except OSError as e:
|
|
315
|
+
console.print(f"[red]save failed:[/red] {e}\n")
|
|
316
|
+
|
|
317
|
+
while True:
|
|
318
|
+
try:
|
|
319
|
+
user_input = console.input("[bold green]›[/bold green] ")
|
|
320
|
+
except (EOFError, KeyboardInterrupt):
|
|
321
|
+
console.print("\n[dim]bye.[/dim]")
|
|
322
|
+
break
|
|
323
|
+
if not user_input.strip():
|
|
324
|
+
continue
|
|
325
|
+
|
|
326
|
+
if user_input.startswith("/"):
|
|
327
|
+
parts = user_input.strip().split(maxsplit=1)
|
|
328
|
+
cmd, arg = parts[0].lower(), (parts[1] if len(parts) > 1 else "")
|
|
329
|
+
if cmd in ("/exit", "/quit"):
|
|
330
|
+
console.print("[dim]bye.[/dim]")
|
|
331
|
+
break
|
|
332
|
+
elif cmd == "/help":
|
|
333
|
+
console.print(HELP)
|
|
334
|
+
elif cmd == "/clear":
|
|
335
|
+
chat = make_chat()
|
|
336
|
+
_clear_screen()
|
|
337
|
+
show_banner()
|
|
338
|
+
elif cmd == "/cls":
|
|
339
|
+
_clear_screen()
|
|
340
|
+
elif cmd == "/model":
|
|
341
|
+
if not arg:
|
|
342
|
+
console.print(f"[dim]current model:[/dim] [magenta]{model}[/magenta]\n")
|
|
343
|
+
else:
|
|
344
|
+
model = arg.strip()
|
|
345
|
+
chat = make_chat()
|
|
346
|
+
console.print(f"[dim]model →[/dim] [magenta]{model}[/magenta] [dim](conversation reset)[/dim]\n")
|
|
347
|
+
elif cmd == "/system":
|
|
348
|
+
system_instruction = arg or DEFAULT_SYSTEM
|
|
349
|
+
chat = make_chat()
|
|
350
|
+
console.print("[dim]system instruction updated (conversation reset)[/dim]\n")
|
|
351
|
+
elif cmd == "/cwd":
|
|
352
|
+
target = Path(arg or str(Path.home())).expanduser()
|
|
353
|
+
try:
|
|
354
|
+
os.chdir(target)
|
|
355
|
+
console.print(f"[dim]cwd →[/dim] [magenta]{os.getcwd()}[/magenta]\n")
|
|
356
|
+
except OSError as e:
|
|
357
|
+
console.print(f"[red]cd failed:[/red] {e}\n")
|
|
358
|
+
elif cmd == "/save":
|
|
359
|
+
if not arg:
|
|
360
|
+
console.print("[red]usage:[/red] /save <path>\n")
|
|
361
|
+
else:
|
|
362
|
+
save_transcript(arg)
|
|
363
|
+
else:
|
|
364
|
+
console.print(f"[red]unknown command:[/red] {cmd} (try /help)\n")
|
|
365
|
+
continue
|
|
366
|
+
|
|
367
|
+
buf = ""
|
|
368
|
+
try:
|
|
369
|
+
stream = chat.send_message_stream(user_input)
|
|
370
|
+
with Live(Markdown(""), console=console, refresh_per_second=20, vertical_overflow="visible") as live:
|
|
371
|
+
for chunk in stream:
|
|
372
|
+
for cand in (chunk.candidates or []):
|
|
373
|
+
content = getattr(cand, "content", None)
|
|
374
|
+
if not content or not getattr(content, "parts", None):
|
|
375
|
+
continue
|
|
376
|
+
for part in content.parts:
|
|
377
|
+
text = getattr(part, "text", None)
|
|
378
|
+
if text:
|
|
379
|
+
buf += text
|
|
380
|
+
live.update(Markdown(buf))
|
|
381
|
+
fc = getattr(part, "function_call", None)
|
|
382
|
+
if fc and fc.name:
|
|
383
|
+
args = dict(fc.args or {})
|
|
384
|
+
args_str = ", ".join(f"{k}={_short(v)}" for k, v in args.items())
|
|
385
|
+
live.console.print(f"[dim cyan] ↳ {fc.name}({args_str})[/dim cyan]")
|
|
386
|
+
except KeyboardInterrupt:
|
|
387
|
+
console.print("\n[dim]— interrupted —[/dim]\n")
|
|
388
|
+
except Exception as e:
|
|
389
|
+
console.print(f"[red]error:[/red] {e}\n")
|
|
390
|
+
continue
|
|
391
|
+
|
|
392
|
+
console.print()
|
|
393
|
+
if readline:
|
|
394
|
+
try:
|
|
395
|
+
readline.write_history_file(str(HISTORY_PATH))
|
|
396
|
+
except Exception:
|
|
397
|
+
pass
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
if __name__ == "__main__":
|
|
401
|
+
main()
|