ai-cli-toolkit 0.2.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.
- ai_cli/__init__.py +3 -0
- ai_cli/__main__.py +6 -0
- ai_cli/bin/ai-mux-linux-x86_64 +0 -0
- ai_cli/bin/remote-tty-wrapper +153 -0
- ai_cli/ca.py +175 -0
- ai_cli/completion_gen.py +680 -0
- ai_cli/config.py +185 -0
- ai_cli/credentials.py +341 -0
- ai_cli/detached_cleanup.py +135 -0
- ai_cli/housekeeping.py +50 -0
- ai_cli/instructions.py +308 -0
- ai_cli/log.py +53 -0
- ai_cli/main.py +1516 -0
- ai_cli/main_helpers.py +553 -0
- ai_cli/prompt_editor_launcher.py +324 -0
- ai_cli/proxy.py +627 -0
- ai_cli/remote.py +669 -0
- ai_cli/remote_package.py +1111 -0
- ai_cli/session.py +1344 -0
- ai_cli/session_store.py +236 -0
- ai_cli/traffic.py +1510 -0
- ai_cli/traffic_db.py +118 -0
- ai_cli/tui.py +525 -0
- ai_cli/update.py +200 -0
- ai_cli_toolkit-0.2.0.dist-info/METADATA +17 -0
- ai_cli_toolkit-0.2.0.dist-info/RECORD +30 -0
- ai_cli_toolkit-0.2.0.dist-info/WHEEL +5 -0
- ai_cli_toolkit-0.2.0.dist-info/entry_points.txt +2 -0
- ai_cli_toolkit-0.2.0.dist-info/licenses/LICENSE +21 -0
- ai_cli_toolkit-0.2.0.dist-info/top_level.txt +1 -0
ai_cli/remote_package.py
ADDED
|
@@ -0,0 +1,1111 @@
|
|
|
1
|
+
"""Remote package — assemble an isolated $HOME for tool sessions on remote hosts.
|
|
2
|
+
|
|
3
|
+
When launching a tool on a remote host, this module:
|
|
4
|
+
1. Computes a deterministic session directory on the remote.
|
|
5
|
+
2. Builds a manifest of local config/credential/instruction files to include.
|
|
6
|
+
3. Pushes the package via rsync (single invocation from a staging tmpdir).
|
|
7
|
+
4. Provides a probe to detect whether a tmux session is already running.
|
|
8
|
+
5. Pulls session artifacts (logs, projects) back to the local host after exit.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import shlex
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
import tempfile
|
|
21
|
+
import textwrap
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path, PurePosixPath
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
from ai_cli.instructions import ensure_project_instructions_file
|
|
27
|
+
from ai_cli.remote import RemoteSpec, print_sync_status
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Data structures
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
_SSH_OPTS = "ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new"
|
|
34
|
+
_RSYNC_CHMOD = "Du=rwx,Dgo=,Fu=rw,Fgo="
|
|
35
|
+
_SKIP_FILE_NAMES = {".DS_Store"}
|
|
36
|
+
_MUX_SOURCE_FILES = ("Cargo.toml", "Cargo.lock", "src")
|
|
37
|
+
|
|
38
|
+
_TOOL_PACKAGE_FILES: dict[str, list[tuple[str, str]]] = {
|
|
39
|
+
"claude": [
|
|
40
|
+
("~/.claude/.credentials.json", ".claude/.credentials.json"),
|
|
41
|
+
("~/.claude/.credentials.json.enc", ".claude/.credentials.json.enc"),
|
|
42
|
+
("~/.claude/.credentials.key", ".claude/.credentials.key"),
|
|
43
|
+
("~/.claude/CLAUDE.md", ".claude/CLAUDE.md"),
|
|
44
|
+
("~/.claude/settings.json", ".claude/settings.json"),
|
|
45
|
+
("~/.claude/settings.local.json", ".claude/settings.local.json"),
|
|
46
|
+
("~/.claude/developer_instructions.txt", ".claude/developer_instructions.txt"),
|
|
47
|
+
("~/.claude/system_instructions.txt", ".claude/system_instructions.txt"),
|
|
48
|
+
("~/.claude/statusline-command.sh", ".claude/statusline-command.sh"),
|
|
49
|
+
("~/.claude/mcp-needs-auth-cache.json", ".claude/mcp-needs-auth-cache.json"),
|
|
50
|
+
("~/.claude/stats-cache.json", ".claude/stats-cache.json"),
|
|
51
|
+
("~/.claude/plugins/config.json", ".claude/plugins/config.json"),
|
|
52
|
+
("~/.claude/plugins/blocklist.json", ".claude/plugins/blocklist.json"),
|
|
53
|
+
("~/.claude/plugins/known_marketplaces.json", ".claude/plugins/known_marketplaces.json"),
|
|
54
|
+
],
|
|
55
|
+
"codex": [
|
|
56
|
+
("~/.codex/auth.json", ".codex/auth.json"),
|
|
57
|
+
("~/.codex/config.toml", ".codex/config.toml"),
|
|
58
|
+
("~/.codex/config.json", ".codex/config.json"),
|
|
59
|
+
("~/.codex/.codex-global-state.json", ".codex/.codex-global-state.json"),
|
|
60
|
+
("~/.codex/.personality_migration", ".codex/.personality_migration"),
|
|
61
|
+
("~/.codex/AGENTS.md", ".codex/AGENTS.md"),
|
|
62
|
+
("~/.codex/history.jsonl", ".codex/history.jsonl"),
|
|
63
|
+
("~/.codex/instructions.md", ".codex/instructions.md"),
|
|
64
|
+
("~/.codex/internal_storage.json", ".codex/internal_storage.json"),
|
|
65
|
+
("~/.codex/models_cache.json", ".codex/models_cache.json"),
|
|
66
|
+
("~/.codex/update-check.json", ".codex/update-check.json"),
|
|
67
|
+
("~/.codex/version.json", ".codex/version.json"),
|
|
68
|
+
("~/.codex/policy/default.codexpolicy", ".codex/policy/default.codexpolicy"),
|
|
69
|
+
("~/.codex/rules/default.rules", ".codex/rules/default.rules"),
|
|
70
|
+
],
|
|
71
|
+
"copilot": [
|
|
72
|
+
("~/.copilot/config.json", ".copilot/config.json"),
|
|
73
|
+
("~/.copilot/command-history-state.json", ".copilot/command-history-state.json"),
|
|
74
|
+
("~/.copilot/AGENTS.md", ".copilot/AGENTS.md"),
|
|
75
|
+
("~/.copilot/agents/dev-instructions.agent.md", ".copilot/agents/dev-instructions.agent.md"),
|
|
76
|
+
("~/.copilot/.system/.codex-system-skills.marker", ".copilot/.system/.codex-system-skills.marker"),
|
|
77
|
+
("~/.config/github-copilot/apps.json", ".config/github-copilot/apps.json"),
|
|
78
|
+
("~/.config/github-copilot/byok.json", ".config/github-copilot/byok.json"),
|
|
79
|
+
("~/.config/github-copilot/versions.json", ".config/github-copilot/versions.json"),
|
|
80
|
+
("~/.config/github-copilot/xcode/mcp.json", ".config/github-copilot/xcode/mcp.json"),
|
|
81
|
+
],
|
|
82
|
+
"gemini": [
|
|
83
|
+
("~/.gemini/GEMINI.md", ".gemini/GEMINI.md"),
|
|
84
|
+
("~/.gemini/google_accounts.json", ".gemini/google_accounts.json"),
|
|
85
|
+
("~/.gemini/installation_id", ".gemini/installation_id"),
|
|
86
|
+
("~/.gemini/oauth_creds.json", ".gemini/oauth_creds.json"),
|
|
87
|
+
("~/.gemini/projects.json", ".gemini/projects.json"),
|
|
88
|
+
("~/.gemini/settings.json", ".gemini/settings.json"),
|
|
89
|
+
("~/.gemini/state.json", ".gemini/state.json"),
|
|
90
|
+
("~/.gemini/trustedFolders.json", ".gemini/trustedFolders.json"),
|
|
91
|
+
("~/.gemini/antigravity/installation_id", ".gemini/antigravity/installation_id"),
|
|
92
|
+
("~/.gemini/antigravity/mcp_config.json", ".gemini/antigravity/mcp_config.json"),
|
|
93
|
+
("~/.gemini/extensions/extension-enablement.json", ".gemini/extensions/extension-enablement.json"),
|
|
94
|
+
("~/.gemini/policies/auto-saved.toml", ".gemini/policies/auto-saved.toml"),
|
|
95
|
+
],
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class PackageFileEntry:
|
|
101
|
+
"""One file to include in the remote package."""
|
|
102
|
+
|
|
103
|
+
remote_rel_path: str # relative to session dir (the fake $HOME)
|
|
104
|
+
local_path: Optional[Path] = None
|
|
105
|
+
content: Optional[str] = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class RemotePackage:
|
|
110
|
+
"""Complete package definition for a remote tool session."""
|
|
111
|
+
|
|
112
|
+
tool_name: str
|
|
113
|
+
real_home: str # absolute remote home for the actual user account
|
|
114
|
+
session_dir: str # absolute path on remote, e.g. ~/.ai-cli/remote-sessions/claude-a1b2c3d4
|
|
115
|
+
tmux_socket: str # tmux -L socket name, e.g. "ai-cli-claude"
|
|
116
|
+
session_name: str # tmux session name inside the socket
|
|
117
|
+
project_prompt_rel_path: str # relative to session_dir
|
|
118
|
+
entries: list[PackageFileEntry] = field(default_factory=list)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _normalize_target(system_name: str, machine_name: str) -> tuple[str, str]:
|
|
122
|
+
system = system_name.strip().lower()
|
|
123
|
+
machine = machine_name.strip().lower()
|
|
124
|
+
if system == "linux":
|
|
125
|
+
system = "linux"
|
|
126
|
+
elif system == "darwin":
|
|
127
|
+
system = "darwin"
|
|
128
|
+
if machine in {"x86_64", "amd64"}:
|
|
129
|
+
machine = "x86_64"
|
|
130
|
+
elif machine in {"aarch64", "arm64"}:
|
|
131
|
+
machine = "arm64"
|
|
132
|
+
return system, machine
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def local_ai_mux_asset_path(system_name: str, machine_name: str) -> Path:
|
|
136
|
+
system, machine = _normalize_target(system_name, machine_name)
|
|
137
|
+
return Path(__file__).resolve().parent / "bin" / f"ai-mux-{system}-{machine}"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def resolve_remote_target(remote_spec: RemoteSpec) -> tuple[str, str]:
|
|
141
|
+
proc = subprocess.run(
|
|
142
|
+
[*_ssh_base(remote_spec), "uname -s && uname -m"],
|
|
143
|
+
capture_output=True,
|
|
144
|
+
text=True,
|
|
145
|
+
check=False,
|
|
146
|
+
)
|
|
147
|
+
if proc.returncode != 0:
|
|
148
|
+
raise RuntimeError(
|
|
149
|
+
f"Could not resolve remote target for {remote_spec.ssh_target}: "
|
|
150
|
+
f"{proc.stderr.strip() or 'ssh command failed'}"
|
|
151
|
+
)
|
|
152
|
+
parts = [line.strip() for line in proc.stdout.splitlines() if line.strip()]
|
|
153
|
+
if len(parts) < 2:
|
|
154
|
+
raise RuntimeError(
|
|
155
|
+
f"Could not resolve remote target for {remote_spec.ssh_target}: "
|
|
156
|
+
f"unexpected output {proc.stdout!r}"
|
|
157
|
+
)
|
|
158
|
+
return _normalize_target(parts[0], parts[1])
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def ensure_remote_ai_mux_asset(remote_spec: RemoteSpec) -> Path:
|
|
162
|
+
system, machine = resolve_remote_target(remote_spec)
|
|
163
|
+
asset_path = local_ai_mux_asset_path(system, machine)
|
|
164
|
+
|
|
165
|
+
if (system, machine) != ("linux", "x86_64"):
|
|
166
|
+
raise RuntimeError(
|
|
167
|
+
f"No ai-mux asset available for remote target {system}/{machine}"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
rsync_bin = shutil.which("rsync")
|
|
171
|
+
if not rsync_bin:
|
|
172
|
+
raise FileNotFoundError("rsync is required to build remote ai-mux assets.")
|
|
173
|
+
|
|
174
|
+
repo_root = Path(__file__).resolve().parent.parent
|
|
175
|
+
remote_src = "~/.ai-cli/build/ai-mux-src"
|
|
176
|
+
remote_bin = f"{remote_src}/target/release/ai-mux"
|
|
177
|
+
mkdir_proc = subprocess.run(
|
|
178
|
+
[*_ssh_base(remote_spec), f"mkdir -p {remote_src}"],
|
|
179
|
+
capture_output=True,
|
|
180
|
+
text=True,
|
|
181
|
+
check=False,
|
|
182
|
+
)
|
|
183
|
+
if mkdir_proc.returncode != 0:
|
|
184
|
+
raise RuntimeError(
|
|
185
|
+
f"Could not prepare remote ai-mux build dir: {mkdir_proc.stderr.strip()}"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
with tempfile.TemporaryDirectory(prefix="ai-mux-src-") as staging:
|
|
189
|
+
staging_path = Path(staging)
|
|
190
|
+
mux_root = staging_path / "mux"
|
|
191
|
+
mux_root.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
source_root = repo_root / "mux"
|
|
193
|
+
for rel in _MUX_SOURCE_FILES:
|
|
194
|
+
src = source_root / rel
|
|
195
|
+
dest = mux_root / rel
|
|
196
|
+
if src.is_dir():
|
|
197
|
+
shutil.copytree(src, dest, dirs_exist_ok=True)
|
|
198
|
+
else:
|
|
199
|
+
shutil.copy2(src, dest)
|
|
200
|
+
proc = subprocess.run(
|
|
201
|
+
[
|
|
202
|
+
rsync_bin,
|
|
203
|
+
"-az",
|
|
204
|
+
"-e",
|
|
205
|
+
_SSH_OPTS,
|
|
206
|
+
str(mux_root) + "/",
|
|
207
|
+
f"{remote_spec.ssh_target}:{remote_src}/",
|
|
208
|
+
],
|
|
209
|
+
capture_output=True,
|
|
210
|
+
text=True,
|
|
211
|
+
check=False,
|
|
212
|
+
)
|
|
213
|
+
if proc.returncode != 0:
|
|
214
|
+
raise RuntimeError(
|
|
215
|
+
f"Could not sync ai-mux source to {remote_spec.ssh_target}: {proc.stderr.strip()}"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
build_proc = subprocess.run(
|
|
219
|
+
[
|
|
220
|
+
*_ssh_base(remote_spec),
|
|
221
|
+
f"cd {remote_src} && cargo build --release",
|
|
222
|
+
],
|
|
223
|
+
capture_output=True,
|
|
224
|
+
text=True,
|
|
225
|
+
check=False,
|
|
226
|
+
)
|
|
227
|
+
if build_proc.returncode != 0:
|
|
228
|
+
raise RuntimeError(
|
|
229
|
+
f"Could not build remote ai-mux asset on {remote_spec.ssh_target}: "
|
|
230
|
+
f"{build_proc.stderr.strip() or build_proc.stdout.strip()}"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
asset_path.parent.mkdir(parents=True, exist_ok=True)
|
|
234
|
+
fetch_proc = subprocess.run(
|
|
235
|
+
[
|
|
236
|
+
rsync_bin,
|
|
237
|
+
"-az",
|
|
238
|
+
"-e",
|
|
239
|
+
_SSH_OPTS,
|
|
240
|
+
f"{remote_spec.ssh_target}:{remote_bin}",
|
|
241
|
+
str(asset_path),
|
|
242
|
+
],
|
|
243
|
+
capture_output=True,
|
|
244
|
+
text=True,
|
|
245
|
+
check=False,
|
|
246
|
+
)
|
|
247
|
+
if fetch_proc.returncode != 0:
|
|
248
|
+
raise RuntimeError(
|
|
249
|
+
f"Could not fetch remote ai-mux asset from {remote_spec.ssh_target}: "
|
|
250
|
+
f"{fetch_proc.stderr.strip()}"
|
|
251
|
+
)
|
|
252
|
+
asset_path.chmod(0o755)
|
|
253
|
+
return asset_path
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
# Deterministic naming
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def compute_session_dir(tool_name: str, remote_spec: RemoteSpec) -> str:
|
|
262
|
+
"""Return the remote session directory path.
|
|
263
|
+
|
|
264
|
+
Deterministic: same tool + remote spec always gives the same path,
|
|
265
|
+
allowing session reuse across invocations.
|
|
266
|
+
"""
|
|
267
|
+
digest = hashlib.sha256(
|
|
268
|
+
f"{tool_name}:{remote_spec.display}".encode()
|
|
269
|
+
).hexdigest()[:12]
|
|
270
|
+
return f"~/.ai-cli/remote-sessions/{tool_name}-{digest}"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def compute_tmux_socket(tool_name: str) -> str:
|
|
274
|
+
"""Return the tmux socket name for a tool (``tmux -L <name>``)."""
|
|
275
|
+
return f"ai-cli-{tool_name}"
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def compute_session_name(tool_name: str, remote_spec: RemoteSpec) -> str:
|
|
279
|
+
"""Return the tmux session name (matches the directory basename)."""
|
|
280
|
+
digest = hashlib.sha256(
|
|
281
|
+
f"{tool_name}:{remote_spec.display}".encode()
|
|
282
|
+
).hexdigest()[:12]
|
|
283
|
+
return f"{tool_name}-{digest}"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
# Manifest builder
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
def _add_if_exists(entries: list[PackageFileEntry], local: Path, rel: str) -> None:
|
|
291
|
+
if (
|
|
292
|
+
local.is_file()
|
|
293
|
+
and local.name not in _SKIP_FILE_NAMES
|
|
294
|
+
and not local.name.startswith("._")
|
|
295
|
+
and rel not in {entry.remote_rel_path for entry in entries}
|
|
296
|
+
):
|
|
297
|
+
entries.append(PackageFileEntry(local_path=local, remote_rel_path=rel))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _add_generated(entries: list[PackageFileEntry], rel: str, content: str) -> None:
|
|
301
|
+
if rel in {entry.remote_rel_path for entry in entries}:
|
|
302
|
+
return
|
|
303
|
+
entries.append(PackageFileEntry(content=content, remote_rel_path=rel))
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _add_pairs(entries: list[PackageFileEntry], pairs: list[tuple[str, str]]) -> None:
|
|
307
|
+
for local_raw, remote_rel in pairs:
|
|
308
|
+
_add_if_exists(entries, Path(local_raw).expanduser(), remote_rel)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _render_shell_common(
|
|
312
|
+
tool_name: str,
|
|
313
|
+
remote_spec: RemoteSpec,
|
|
314
|
+
session_name: str,
|
|
315
|
+
project_prompt_remote_path: str,
|
|
316
|
+
) -> str:
|
|
317
|
+
tool_q = shlex.quote(tool_name)
|
|
318
|
+
root_q = shlex.quote(remote_spec.path)
|
|
319
|
+
session_q = shlex.quote(session_name)
|
|
320
|
+
return textwrap.dedent(
|
|
321
|
+
f"""\
|
|
322
|
+
# ai-cli remote shell helpers
|
|
323
|
+
[ "${{AI_CLI_REMOTE_SHELL_COMMON_LOADED:-0}}" = 1 ] && return 0 2>/dev/null
|
|
324
|
+
export AI_CLI_REMOTE_SHELL_COMMON_LOADED=1
|
|
325
|
+
|
|
326
|
+
# Ensure real-home tool paths are available (nvm, .local/bin, cargo, etc.)
|
|
327
|
+
_rh="${{REAL_HOME:-$HOME}}"
|
|
328
|
+
[ -s "${{NVM_DIR:-$_rh/.nvm}}/nvm.sh" ] && . "${{NVM_DIR:-$_rh/.nvm}}/nvm.sh" 2>/dev/null
|
|
329
|
+
[ -s "$_rh/.config/fnm/fnm_multishells" ] && eval "$(fnm env 2>/dev/null)" 2>/dev/null
|
|
330
|
+
[ -d "$_rh/.volta" ] && export VOLTA_HOME="$_rh/.volta" && export PATH="$VOLTA_HOME/bin:$PATH"
|
|
331
|
+
case ":$PATH:" in *":$_rh/.local/bin:"*) ;; *) export PATH="$_rh/.local/bin:$PATH" ;; esac
|
|
332
|
+
case ":$PATH:" in *":$_rh/.cargo/bin:"*) ;; *) export PATH="$_rh/.cargo/bin:$PATH" ;; esac
|
|
333
|
+
unset _rh
|
|
334
|
+
|
|
335
|
+
export AI_CLI_REMOTE_TOOL={tool_q}
|
|
336
|
+
export AI_CLI_REMOTE_PROJECT_ROOT={root_q}
|
|
337
|
+
export AI_CLI_REMOTE_SESSION_NAME={session_q}
|
|
338
|
+
export AI_CLI_WORKDIR={root_q}
|
|
339
|
+
export AI_CLI_BASE_PROMPT_FILE="$HOME/.ai-cli/base_instructions.txt"
|
|
340
|
+
export AI_CLI_GLOBAL_PROMPT_FILE="$HOME/.ai-cli/system_instructions.txt"
|
|
341
|
+
export AI_CLI_TOOL_PROMPT_FILE="$HOME/.ai-cli/instructions/{tool_name}.txt"
|
|
342
|
+
export AI_CLI_PROJECT_PROMPT_FILE="{project_prompt_remote_path}"
|
|
343
|
+
export AI_CLI_PYTHON="${{AI_CLI_PYTHON:-python3}}"
|
|
344
|
+
export AI_CLI_PROMPT_EDITOR_LAUNCHER="$HOME/.ai-cli/bin/ai-prompt-editor"
|
|
345
|
+
export AI_CLI_REMOTE_COMMAND_LOG="$HOME/.ai-cli/logs/shell-commands.log"
|
|
346
|
+
export AI_CLI_REMOTE_SESSION_LOG="$HOME/.ai-cli/logs/shell-session.log"
|
|
347
|
+
export AI_CLI_REMOTE_TMUX_CONF="$HOME/.config/ai-cli/tmux.conf"
|
|
348
|
+
|
|
349
|
+
_ai_cli_log_dir() {{
|
|
350
|
+
printf '%s' "$HOME/.ai-cli/logs"
|
|
351
|
+
}}
|
|
352
|
+
|
|
353
|
+
_ai_cli_ensure_logs() {{
|
|
354
|
+
mkdir -p "$(_ai_cli_log_dir)" 2>/dev/null || true
|
|
355
|
+
}}
|
|
356
|
+
|
|
357
|
+
_ai_cli_ts() {{
|
|
358
|
+
date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null || date
|
|
359
|
+
}}
|
|
360
|
+
|
|
361
|
+
_ai_cli_scope_state() {{
|
|
362
|
+
case "$PWD" in
|
|
363
|
+
"$AI_CLI_REMOTE_PROJECT_ROOT"|"$AI_CLI_REMOTE_PROJECT_ROOT"/*) printf '%s' "in-scope" ;;
|
|
364
|
+
*) printf '%s' "OUT-OF-SCOPE" ;;
|
|
365
|
+
esac
|
|
366
|
+
}}
|
|
367
|
+
|
|
368
|
+
_ai_cli_log_command() {{
|
|
369
|
+
[ "${{AI_CLI_REMOTE_LOG_GUARD:-0}}" = 1 ] && return 0
|
|
370
|
+
AI_CLI_REMOTE_LOG_GUARD=1
|
|
371
|
+
_ai_cli_ensure_logs
|
|
372
|
+
_ai_cli_shell_name="${{2:-shell}}"
|
|
373
|
+
_ai_cli_cmd="${{1:-}}"
|
|
374
|
+
[ -n "$_ai_cli_cmd" ] || {{
|
|
375
|
+
AI_CLI_REMOTE_LOG_GUARD=0
|
|
376
|
+
unset _ai_cli_shell_name _ai_cli_cmd
|
|
377
|
+
return 0
|
|
378
|
+
}}
|
|
379
|
+
printf '%s shell=%s scope=%s cwd=%s cmd=%s\\n' \\
|
|
380
|
+
"$(_ai_cli_ts)" "$_ai_cli_shell_name" "$(_ai_cli_scope_state)" "$PWD" "$_ai_cli_cmd" >> "$AI_CLI_REMOTE_COMMAND_LOG"
|
|
381
|
+
AI_CLI_REMOTE_LOG_GUARD=0
|
|
382
|
+
unset _ai_cli_shell_name _ai_cli_cmd
|
|
383
|
+
}}
|
|
384
|
+
|
|
385
|
+
ai_rules() {{
|
|
386
|
+
printf '%s\\n' \\
|
|
387
|
+
"[ai-cli remote rules]" \\
|
|
388
|
+
"1. Default scope root: $AI_CLI_REMOTE_PROJECT_ROOT" \\
|
|
389
|
+
"2. Commands are logged to: $AI_CLI_REMOTE_COMMAND_LOG" \\
|
|
390
|
+
"3. Use ai_scope, ai_root, ai_scope_check, and ai_log_tail to manage session scope." \\
|
|
391
|
+
"4. Treat leaving the scope root as explicit and temporary."
|
|
392
|
+
}}
|
|
393
|
+
|
|
394
|
+
ai_scope() {{
|
|
395
|
+
printf '%s\\n' \\
|
|
396
|
+
"tool=$AI_CLI_REMOTE_TOOL" \\
|
|
397
|
+
"session=$AI_CLI_REMOTE_SESSION_NAME" \\
|
|
398
|
+
"home=$HOME" \\
|
|
399
|
+
"root=$AI_CLI_REMOTE_PROJECT_ROOT" \\
|
|
400
|
+
"cwd=$PWD" \\
|
|
401
|
+
"scope=$(_ai_cli_scope_state)" \\
|
|
402
|
+
"command_log=$AI_CLI_REMOTE_COMMAND_LOG" \\
|
|
403
|
+
"tmux_conf=$AI_CLI_REMOTE_TMUX_CONF"
|
|
404
|
+
}}
|
|
405
|
+
|
|
406
|
+
ai_root() {{
|
|
407
|
+
cd "$AI_CLI_REMOTE_PROJECT_ROOT" || return 1
|
|
408
|
+
}}
|
|
409
|
+
|
|
410
|
+
ai_scope_check() {{
|
|
411
|
+
printf '%s\\n' "scope=$(_ai_cli_scope_state) cwd=$PWD root=$AI_CLI_REMOTE_PROJECT_ROOT"
|
|
412
|
+
}}
|
|
413
|
+
|
|
414
|
+
ai_log_tail() {{
|
|
415
|
+
_ai_cli_ensure_logs
|
|
416
|
+
tail -n "${{1:-40}}" "$AI_CLI_REMOTE_COMMAND_LOG"
|
|
417
|
+
}}
|
|
418
|
+
|
|
419
|
+
_ai_cli_banner() {{
|
|
420
|
+
[ -t 1 ] || return 0
|
|
421
|
+
[ "${{AI_CLI_REMOTE_BANNER_SHOWN:-0}}" = 1 ] && return 0
|
|
422
|
+
export AI_CLI_REMOTE_BANNER_SHOWN=1
|
|
423
|
+
_ai_cli_ensure_logs
|
|
424
|
+
printf '[ai-cli remote] tool=%s root=%s log=%s\\n' \\
|
|
425
|
+
"$AI_CLI_REMOTE_TOOL" "$AI_CLI_REMOTE_PROJECT_ROOT" "$AI_CLI_REMOTE_COMMAND_LOG"
|
|
426
|
+
ai_rules
|
|
427
|
+
_ai_cli_log_command "__shell_start__" "shell"
|
|
428
|
+
}}
|
|
429
|
+
|
|
430
|
+
_ai_cli_scope_notice() {{
|
|
431
|
+
case "$PWD" in
|
|
432
|
+
"$AI_CLI_REMOTE_PROJECT_ROOT"|"$AI_CLI_REMOTE_PROJECT_ROOT"/*) ;;
|
|
433
|
+
*)
|
|
434
|
+
printf '[ai-cli remote] warning: outside scope root %s\\n' "$AI_CLI_REMOTE_PROJECT_ROOT" >&2
|
|
435
|
+
;;
|
|
436
|
+
esac
|
|
437
|
+
}}
|
|
438
|
+
"""
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _shell_escape(value: str) -> str:
|
|
443
|
+
if not value:
|
|
444
|
+
return "''"
|
|
445
|
+
safe = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.:/=+,@")
|
|
446
|
+
if all(ch in safe for ch in value):
|
|
447
|
+
return value
|
|
448
|
+
return "'" + value.replace("'", "'\\''") + "'"
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _render_editor_shell_cmd(
|
|
452
|
+
script_expr: str,
|
|
453
|
+
tmux_socket: str,
|
|
454
|
+
window_name: str,
|
|
455
|
+
target: str,
|
|
456
|
+
) -> str:
|
|
457
|
+
return (
|
|
458
|
+
f"{script_expr} open --tmux-socket {shlex.quote(tmux_socket)} "
|
|
459
|
+
f"--window-name {shlex.quote(window_name)} --target {shlex.quote(target)}"
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _render_tmux_editor_binding(key: str, window_name: str, editor_cmd: str) -> str:
|
|
464
|
+
guard = "#{m:^edit-(global|base|tool|project)$,#{window_name}}"
|
|
465
|
+
return (
|
|
466
|
+
f"bind -n {key} if-shell -F {_shell_escape(guard)} "
|
|
467
|
+
f"{{ run-shell true }} "
|
|
468
|
+
f"{{ run-shell -b {_shell_escape(editor_cmd)} }}"
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _render_tmux_conf(tool_name: str, remote_spec: RemoteSpec) -> str:
|
|
473
|
+
script_expr = "$HOME/.ai-cli/bin/ai-prompt-editor"
|
|
474
|
+
tmux_socket = compute_tmux_socket(tool_name)
|
|
475
|
+
edit_global_cmd = _render_editor_shell_cmd(
|
|
476
|
+
script_expr, tmux_socket, "edit-global", "global"
|
|
477
|
+
)
|
|
478
|
+
edit_base_cmd = _render_editor_shell_cmd(
|
|
479
|
+
script_expr, tmux_socket, "edit-base", "base"
|
|
480
|
+
)
|
|
481
|
+
edit_tool_cmd = _render_editor_shell_cmd(
|
|
482
|
+
script_expr,
|
|
483
|
+
tmux_socket,
|
|
484
|
+
"edit-tool",
|
|
485
|
+
"tool",
|
|
486
|
+
)
|
|
487
|
+
edit_project_cmd = _render_editor_shell_cmd(
|
|
488
|
+
script_expr,
|
|
489
|
+
tmux_socket,
|
|
490
|
+
"edit-project",
|
|
491
|
+
"project",
|
|
492
|
+
)
|
|
493
|
+
lines = [
|
|
494
|
+
"# ai-mux tmux configuration (auto-generated, do not edit)",
|
|
495
|
+
"# User overrides: ~/.config/ai-cli/tmux.conf",
|
|
496
|
+
"",
|
|
497
|
+
"set -g mouse on",
|
|
498
|
+
"set -g status-position top",
|
|
499
|
+
"set -sg escape-time 0",
|
|
500
|
+
"set -g xterm-keys on",
|
|
501
|
+
"set -g default-terminal 'screen-256color'",
|
|
502
|
+
"set -g focus-events on",
|
|
503
|
+
"set -g history-limit 50000",
|
|
504
|
+
"set -g remain-on-exit off",
|
|
505
|
+
"set -g renumber-windows off",
|
|
506
|
+
"set -g base-index 0",
|
|
507
|
+
"set -g allow-rename off",
|
|
508
|
+
"set -g automatic-rename off",
|
|
509
|
+
"",
|
|
510
|
+
"# Status bar",
|
|
511
|
+
"set -g status-style 'bg=#333333,fg=white,dim'",
|
|
512
|
+
"set -g status-left ''",
|
|
513
|
+
"set -g status-right '#[fg=#ffffff,bold] F5:global F6:base F7:tool F8:project │ C-] prefix '",
|
|
514
|
+
"set -g status-left-length 0",
|
|
515
|
+
"set -g status-right-length 60",
|
|
516
|
+
"set -g window-status-format ' #W '",
|
|
517
|
+
"set -g window-status-current-format '#[bold,noreverse] #W '",
|
|
518
|
+
"set -g window-status-current-style 'bg=default,fg=white,bold'",
|
|
519
|
+
"set -g window-status-style 'bg=#333333,fg=#999999'",
|
|
520
|
+
"set -g window-status-separator ''",
|
|
521
|
+
"",
|
|
522
|
+
"# Use C-] as prefix (avoids conflicts with tools)",
|
|
523
|
+
"unbind C-b",
|
|
524
|
+
"set -g prefix C-]",
|
|
525
|
+
"bind C-] send-prefix",
|
|
526
|
+
"",
|
|
527
|
+
"# Key bindings (no prefix)",
|
|
528
|
+
"bind -n F2 select-window -t :0",
|
|
529
|
+
"bind -n F3 select-window -t :1",
|
|
530
|
+
"bind -n F4 select-window -t :2",
|
|
531
|
+
"bind -n F10 select-window -t :5",
|
|
532
|
+
"bind -n F11 select-window -t :6",
|
|
533
|
+
"bind -n F9 select-window -t :7",
|
|
534
|
+
_render_tmux_editor_binding("F5", "edit-global", edit_global_cmd),
|
|
535
|
+
_render_tmux_editor_binding("F6", "edit-base", edit_base_cmd),
|
|
536
|
+
_render_tmux_editor_binding("F7", "edit-tool", edit_tool_cmd),
|
|
537
|
+
_render_tmux_editor_binding("F8", "edit-project", edit_project_cmd),
|
|
538
|
+
"bind -n M-2 select-window -t :0",
|
|
539
|
+
"bind -n M-3 select-window -t :1",
|
|
540
|
+
"bind -n M-4 select-window -t :2",
|
|
541
|
+
"bind -n M-5 select-window -t :3",
|
|
542
|
+
"bind -n M-6 select-window -t :4",
|
|
543
|
+
"bind -n M-7 select-window -t :5",
|
|
544
|
+
"bind -n M-8 select-window -t :6",
|
|
545
|
+
"bind -n M-9 select-window -t :7",
|
|
546
|
+
"bind -n M-1 choose-tree -s",
|
|
547
|
+
"bind -n F1 choose-tree -s",
|
|
548
|
+
"bind -n C-n next-window",
|
|
549
|
+
"bind -n C-p previous-window",
|
|
550
|
+
"bind -n M-Left previous-window",
|
|
551
|
+
"bind -n M-Right next-window",
|
|
552
|
+
"bind q detach-client",
|
|
553
|
+
"",
|
|
554
|
+
"# User overrides",
|
|
555
|
+
"if-shell 'test -f ~/.config/ai-cli/tmux.conf' 'source-file ~/.config/ai-cli/tmux.conf'",
|
|
556
|
+
]
|
|
557
|
+
return "\n".join(lines) + "\n"
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _generated_shell_files(
|
|
561
|
+
tool_name: str,
|
|
562
|
+
remote_spec: RemoteSpec,
|
|
563
|
+
session_name: str,
|
|
564
|
+
project_prompt_remote_path: str,
|
|
565
|
+
) -> list[PackageFileEntry]:
|
|
566
|
+
shell_common = _render_shell_common(
|
|
567
|
+
tool_name,
|
|
568
|
+
remote_spec,
|
|
569
|
+
session_name,
|
|
570
|
+
project_prompt_remote_path,
|
|
571
|
+
)
|
|
572
|
+
tmux_conf = _render_tmux_conf(tool_name, remote_spec)
|
|
573
|
+
profile = textwrap.dedent(
|
|
574
|
+
"""\
|
|
575
|
+
[ -f "$HOME/.ai-cli/shell-common.sh" ] && . "$HOME/.ai-cli/shell-common.sh"
|
|
576
|
+
export BASH_ENV="$HOME/.bash_env"
|
|
577
|
+
export ENV="$HOME/.shrc"
|
|
578
|
+
export KSHRC="$HOME/.kshrc"
|
|
579
|
+
export ZDOTDIR="$HOME"
|
|
580
|
+
_ai_cli_banner
|
|
581
|
+
"""
|
|
582
|
+
)
|
|
583
|
+
bash_rc = textwrap.dedent(
|
|
584
|
+
"""\
|
|
585
|
+
[ -f "$HOME/.ai-cli/shell-common.sh" ] && . "$HOME/.ai-cli/shell-common.sh"
|
|
586
|
+
export BASH_ENV="$HOME/.bash_env"
|
|
587
|
+
export ENV="$HOME/.shrc"
|
|
588
|
+
export KSHRC="$HOME/.kshrc"
|
|
589
|
+
_ai_cli_bash_prompt_hook() {
|
|
590
|
+
_ai_cli_banner
|
|
591
|
+
_ai_cli_scope_notice
|
|
592
|
+
_ai_cli_bash_last="$(history 1 2>/dev/null | sed 's/^ *[0-9][0-9]* *//')"
|
|
593
|
+
if [ -n "$_ai_cli_bash_last" ] && [ "$_ai_cli_bash_last" != "${AI_CLI_REMOTE_LAST_BASH_COMMAND:-}" ]; then
|
|
594
|
+
AI_CLI_REMOTE_LAST_BASH_COMMAND="$_ai_cli_bash_last"
|
|
595
|
+
_ai_cli_log_command "$_ai_cli_bash_last" "bash"
|
|
596
|
+
fi
|
|
597
|
+
unset _ai_cli_bash_last
|
|
598
|
+
}
|
|
599
|
+
case ";${PROMPT_COMMAND:-};" in
|
|
600
|
+
*";_ai_cli_bash_prompt_hook;"*) ;;
|
|
601
|
+
*) PROMPT_COMMAND="_ai_cli_bash_prompt_hook${PROMPT_COMMAND:+;$PROMPT_COMMAND}" ;;
|
|
602
|
+
esac
|
|
603
|
+
"""
|
|
604
|
+
)
|
|
605
|
+
bash_env = textwrap.dedent(
|
|
606
|
+
"""\
|
|
607
|
+
[ -f "$HOME/.ai-cli/shell-common.sh" ] && . "$HOME/.ai-cli/shell-common.sh"
|
|
608
|
+
export BASH_ENV="$HOME/.bash_env"
|
|
609
|
+
export ENV="$HOME/.shrc"
|
|
610
|
+
export KSHRC="$HOME/.kshrc"
|
|
611
|
+
_ai_cli_bash_debug_hook() {
|
|
612
|
+
[ "${AI_CLI_REMOTE_LOG_GUARD:-0}" = 1 ] && return 0
|
|
613
|
+
_ai_cli_bash_debug_cmd="${BASH_COMMAND:-}"
|
|
614
|
+
case "$_ai_cli_bash_debug_cmd" in
|
|
615
|
+
""|_ai_cli_*|history\\ *|trap\\ *|hash\\ -r*|unalias\\ *) return 0 ;;
|
|
616
|
+
esac
|
|
617
|
+
_ai_cli_log_command "$_ai_cli_bash_debug_cmd" "bash"
|
|
618
|
+
}
|
|
619
|
+
trap '_ai_cli_bash_debug_hook' DEBUG
|
|
620
|
+
"""
|
|
621
|
+
)
|
|
622
|
+
bash_profile = textwrap.dedent(
|
|
623
|
+
"""\
|
|
624
|
+
[ -f "$HOME/.profile" ] && . "$HOME/.profile"
|
|
625
|
+
[ -f "$HOME/.bashrc" ] && . "$HOME/.bashrc"
|
|
626
|
+
"""
|
|
627
|
+
)
|
|
628
|
+
zshenv = textwrap.dedent(
|
|
629
|
+
"""\
|
|
630
|
+
[ -f "$HOME/.ai-cli/shell-common.sh" ] && . "$HOME/.ai-cli/shell-common.sh"
|
|
631
|
+
export ENV="$HOME/.shrc"
|
|
632
|
+
export BASH_ENV="$HOME/.bash_env"
|
|
633
|
+
export KSHRC="$HOME/.kshrc"
|
|
634
|
+
export ZDOTDIR="$HOME"
|
|
635
|
+
"""
|
|
636
|
+
)
|
|
637
|
+
zshrc = textwrap.dedent(
|
|
638
|
+
"""\
|
|
639
|
+
[ -f "$HOME/.ai-cli/shell-common.sh" ] && . "$HOME/.ai-cli/shell-common.sh"
|
|
640
|
+
export ENV="$HOME/.shrc"
|
|
641
|
+
export BASH_ENV="$HOME/.bash_env"
|
|
642
|
+
export KSHRC="$HOME/.kshrc"
|
|
643
|
+
autoload -Uz add-zsh-hook 2>/dev/null || true
|
|
644
|
+
if [ "${AI_CLI_REMOTE_ZSH_HOOKS:-0}" != 1 ]; then
|
|
645
|
+
_ai_cli_zsh_preexec() {
|
|
646
|
+
_ai_cli_log_command "$1" "zsh"
|
|
647
|
+
}
|
|
648
|
+
_ai_cli_zsh_precmd() {
|
|
649
|
+
_ai_cli_banner
|
|
650
|
+
_ai_cli_scope_notice
|
|
651
|
+
}
|
|
652
|
+
add-zsh-hook preexec _ai_cli_zsh_preexec 2>/dev/null || true
|
|
653
|
+
add-zsh-hook precmd _ai_cli_zsh_precmd 2>/dev/null || true
|
|
654
|
+
export AI_CLI_REMOTE_ZSH_HOOKS=1
|
|
655
|
+
fi
|
|
656
|
+
"""
|
|
657
|
+
)
|
|
658
|
+
zprofile = textwrap.dedent(
|
|
659
|
+
"""\
|
|
660
|
+
[ -f "$HOME/.profile" ] && . "$HOME/.profile"
|
|
661
|
+
[ -f "$HOME/.zshrc" ] && . "$HOME/.zshrc"
|
|
662
|
+
"""
|
|
663
|
+
)
|
|
664
|
+
shrc = textwrap.dedent(
|
|
665
|
+
"""\
|
|
666
|
+
[ -f "$HOME/.ai-cli/shell-common.sh" ] && . "$HOME/.ai-cli/shell-common.sh"
|
|
667
|
+
export ENV="$HOME/.shrc"
|
|
668
|
+
export KSHRC="$HOME/.kshrc"
|
|
669
|
+
_ai_cli_banner
|
|
670
|
+
_ai_cli_scope_notice
|
|
671
|
+
"""
|
|
672
|
+
)
|
|
673
|
+
return [
|
|
674
|
+
PackageFileEntry(remote_rel_path=".ai-cli/shell-common.sh", content=shell_common),
|
|
675
|
+
PackageFileEntry(remote_rel_path=".profile", content=profile),
|
|
676
|
+
PackageFileEntry(remote_rel_path=".bashrc", content=bash_rc),
|
|
677
|
+
PackageFileEntry(remote_rel_path=".bash_env", content=bash_env),
|
|
678
|
+
PackageFileEntry(remote_rel_path=".bash_profile", content=bash_profile),
|
|
679
|
+
PackageFileEntry(remote_rel_path=".bash_login", content=bash_profile),
|
|
680
|
+
PackageFileEntry(remote_rel_path=".zshenv", content=zshenv),
|
|
681
|
+
PackageFileEntry(remote_rel_path=".zshrc", content=zshrc),
|
|
682
|
+
PackageFileEntry(remote_rel_path=".zprofile", content=zprofile),
|
|
683
|
+
PackageFileEntry(remote_rel_path=".shrc", content=shrc),
|
|
684
|
+
PackageFileEntry(remote_rel_path=".kshrc", content=shrc),
|
|
685
|
+
PackageFileEntry(remote_rel_path=".tmux.conf", content=tmux_conf),
|
|
686
|
+
]
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _ssh_base(remote_spec: RemoteSpec) -> list[str]:
|
|
690
|
+
return ["ssh"] + _SSH_OPTS.split()[1:] + [remote_spec.ssh_target]
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def resolve_remote_home(remote_spec: RemoteSpec) -> str:
|
|
694
|
+
"""Resolve the remote user's absolute home directory via SSH."""
|
|
695
|
+
proc = subprocess.run(
|
|
696
|
+
[*_ssh_base(remote_spec), "printf '%s\\n' \"$HOME\""],
|
|
697
|
+
capture_output=True,
|
|
698
|
+
text=True,
|
|
699
|
+
check=False,
|
|
700
|
+
)
|
|
701
|
+
if proc.returncode != 0:
|
|
702
|
+
raise RuntimeError(
|
|
703
|
+
f"Could not resolve remote home for {remote_spec.ssh_target}: "
|
|
704
|
+
f"{proc.stderr.strip() or 'ssh command failed'}"
|
|
705
|
+
)
|
|
706
|
+
remote_home = proc.stdout.strip()
|
|
707
|
+
if not remote_home.startswith("/"):
|
|
708
|
+
raise RuntimeError(
|
|
709
|
+
f"Could not resolve remote home for {remote_spec.ssh_target}: "
|
|
710
|
+
f"unexpected value {remote_home!r}"
|
|
711
|
+
)
|
|
712
|
+
return remote_home
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def build_package_manifest(
|
|
716
|
+
tool_name: str,
|
|
717
|
+
remote_spec: RemoteSpec,
|
|
718
|
+
*,
|
|
719
|
+
ca_path: Optional[Path] = None,
|
|
720
|
+
ai_mux_binary: Optional[Path] = None,
|
|
721
|
+
) -> RemotePackage:
|
|
722
|
+
"""Build the file manifest for a remote tool session.
|
|
723
|
+
|
|
724
|
+
Only includes entries whose local source actually exists.
|
|
725
|
+
"""
|
|
726
|
+
session_rel_dir = PurePosixPath(compute_session_dir(tool_name, remote_spec).removeprefix("~/"))
|
|
727
|
+
real_home = resolve_remote_home(remote_spec)
|
|
728
|
+
session_dir = str(PurePosixPath(real_home) / session_rel_dir)
|
|
729
|
+
tmux_socket = compute_tmux_socket(tool_name)
|
|
730
|
+
session_name = compute_session_name(tool_name, remote_spec)
|
|
731
|
+
|
|
732
|
+
entries: list[PackageFileEntry] = []
|
|
733
|
+
|
|
734
|
+
# ── Common (all tools) ────────────────────────────────────────────────
|
|
735
|
+
ai_cli_dir = Path("~/.ai-cli").expanduser()
|
|
736
|
+
project_prompt_file = Path(
|
|
737
|
+
ensure_project_instructions_file(
|
|
738
|
+
project_cwd=remote_spec.path,
|
|
739
|
+
remote_spec=remote_spec.display,
|
|
740
|
+
)
|
|
741
|
+
).expanduser()
|
|
742
|
+
project_prompt_meta = project_prompt_file.parent / "meta.json"
|
|
743
|
+
project_prompt_rel = project_prompt_file.relative_to(ai_cli_dir).as_posix()
|
|
744
|
+
project_prompt_rel_path = f".ai-cli/{project_prompt_rel}"
|
|
745
|
+
|
|
746
|
+
_add_if_exists(entries, ai_cli_dir / "config.json", ".ai-cli/config.json")
|
|
747
|
+
_add_if_exists(
|
|
748
|
+
entries, ai_cli_dir / "system_instructions.txt", ".ai-cli/system_instructions.txt"
|
|
749
|
+
)
|
|
750
|
+
_add_if_exists(
|
|
751
|
+
entries,
|
|
752
|
+
Path("~/.config/ai-cli/tmux.conf").expanduser(),
|
|
753
|
+
".config/ai-cli/tmux.conf",
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
# User's base instructions (prefer user copy; fall back to shipped template)
|
|
757
|
+
user_base = ai_cli_dir / "base_instructions.txt"
|
|
758
|
+
shipped_base = Path(__file__).resolve().parent.parent / "templates" / "base_instructions.txt"
|
|
759
|
+
if user_base.is_file():
|
|
760
|
+
entries.append(
|
|
761
|
+
PackageFileEntry(
|
|
762
|
+
remote_rel_path=".ai-cli/base_instructions.txt",
|
|
763
|
+
local_path=user_base,
|
|
764
|
+
)
|
|
765
|
+
)
|
|
766
|
+
elif shipped_base.is_file():
|
|
767
|
+
entries.append(
|
|
768
|
+
PackageFileEntry(
|
|
769
|
+
remote_rel_path=".ai-cli/base_instructions.txt",
|
|
770
|
+
local_path=shipped_base,
|
|
771
|
+
)
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
# Per-tool instructions — push ALL tool files so F7 edits see them all
|
|
775
|
+
instructions_dir = ai_cli_dir / "instructions"
|
|
776
|
+
if instructions_dir.is_dir():
|
|
777
|
+
for txt in sorted(instructions_dir.glob("*.txt")):
|
|
778
|
+
_add_if_exists(
|
|
779
|
+
entries,
|
|
780
|
+
txt,
|
|
781
|
+
f".ai-cli/instructions/{txt.name}",
|
|
782
|
+
)
|
|
783
|
+
_add_if_exists(entries, project_prompt_file, project_prompt_rel_path)
|
|
784
|
+
_add_if_exists(
|
|
785
|
+
entries,
|
|
786
|
+
project_prompt_meta,
|
|
787
|
+
f".ai-cli/{project_prompt_meta.relative_to(ai_cli_dir).as_posix()}",
|
|
788
|
+
)
|
|
789
|
+
_add_if_exists(
|
|
790
|
+
entries,
|
|
791
|
+
Path(__file__).resolve().parent / "prompt_editor_launcher.py",
|
|
792
|
+
".ai-cli/bin/ai-prompt-editor",
|
|
793
|
+
)
|
|
794
|
+
if ai_mux_binary is not None:
|
|
795
|
+
_add_if_exists(
|
|
796
|
+
entries,
|
|
797
|
+
ai_mux_binary.expanduser(),
|
|
798
|
+
".ai-cli/bin/ai-mux",
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
# CA certificate (proxy trust)
|
|
802
|
+
if ca_path is not None:
|
|
803
|
+
ca_resolved = Path(ca_path).expanduser()
|
|
804
|
+
if ca_resolved.is_file():
|
|
805
|
+
entries.append(
|
|
806
|
+
PackageFileEntry(
|
|
807
|
+
remote_rel_path=".ai-cli/remote-ca.pem",
|
|
808
|
+
local_path=ca_resolved,
|
|
809
|
+
)
|
|
810
|
+
)
|
|
811
|
+
entries.append(
|
|
812
|
+
PackageFileEntry(
|
|
813
|
+
remote_rel_path=".mitmproxy/mitmproxy-ca-cert.pem",
|
|
814
|
+
local_path=ca_resolved,
|
|
815
|
+
)
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
# ── Tool-specific startup state ───────────────────────────────────────
|
|
819
|
+
_add_pairs(entries, _TOOL_PACKAGE_FILES.get(tool_name, []))
|
|
820
|
+
for entry in _generated_shell_files(
|
|
821
|
+
tool_name,
|
|
822
|
+
remote_spec,
|
|
823
|
+
session_name,
|
|
824
|
+
f"$HOME/{project_prompt_rel_path}",
|
|
825
|
+
):
|
|
826
|
+
_add_generated(entries, entry.remote_rel_path, entry.content or "")
|
|
827
|
+
|
|
828
|
+
return RemotePackage(
|
|
829
|
+
tool_name=tool_name,
|
|
830
|
+
real_home=real_home,
|
|
831
|
+
session_dir=session_dir,
|
|
832
|
+
tmux_socket=tmux_socket,
|
|
833
|
+
session_name=session_name,
|
|
834
|
+
project_prompt_rel_path=project_prompt_rel_path,
|
|
835
|
+
entries=entries,
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
# ---------------------------------------------------------------------------
|
|
840
|
+
# Push
|
|
841
|
+
# ---------------------------------------------------------------------------
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def push_package(
|
|
845
|
+
package: RemotePackage,
|
|
846
|
+
remote_spec: RemoteSpec,
|
|
847
|
+
) -> None:
|
|
848
|
+
"""Push the package to the remote host via rsync from a staging tmpdir."""
|
|
849
|
+
if not package.entries:
|
|
850
|
+
return
|
|
851
|
+
|
|
852
|
+
rsync_bin = shutil.which("rsync")
|
|
853
|
+
if not rsync_bin:
|
|
854
|
+
raise FileNotFoundError(
|
|
855
|
+
"rsync is required for remote package push but was not found on PATH."
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
with tempfile.TemporaryDirectory(prefix="ai-cli-pkg-") as staging:
|
|
859
|
+
staging_path = Path(staging)
|
|
860
|
+
for entry in package.entries:
|
|
861
|
+
dest = staging_path / entry.remote_rel_path
|
|
862
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
863
|
+
if entry.local_path is not None:
|
|
864
|
+
shutil.copy2(str(entry.local_path), str(dest))
|
|
865
|
+
elif entry.content is not None:
|
|
866
|
+
dest.write_text(entry.content, encoding="utf-8")
|
|
867
|
+
else:
|
|
868
|
+
raise RuntimeError(
|
|
869
|
+
f"Package entry {entry.remote_rel_path} is missing both local_path and content."
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
# Ensure the remote session dir exists
|
|
873
|
+
mkdir_proc = subprocess.run(
|
|
874
|
+
[*_ssh_base(remote_spec), f"mkdir -p {shlex.quote(package.session_dir)}"],
|
|
875
|
+
check=False,
|
|
876
|
+
capture_output=True,
|
|
877
|
+
text=True,
|
|
878
|
+
)
|
|
879
|
+
if mkdir_proc.returncode != 0:
|
|
880
|
+
raise RuntimeError(
|
|
881
|
+
f"Could not create remote session dir {package.session_dir} "
|
|
882
|
+
f"(rc={mkdir_proc.returncode}): {mkdir_proc.stderr.strip()}"
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
# Single rsync push — chmod ensures credentials aren't world-readable
|
|
886
|
+
proc = subprocess.run(
|
|
887
|
+
[
|
|
888
|
+
rsync_bin,
|
|
889
|
+
"-az",
|
|
890
|
+
f"--chmod={_RSYNC_CHMOD}",
|
|
891
|
+
"-e",
|
|
892
|
+
_SSH_OPTS,
|
|
893
|
+
str(staging_path) + "/",
|
|
894
|
+
f"{remote_spec.ssh_target}:{package.session_dir}/",
|
|
895
|
+
],
|
|
896
|
+
capture_output=True,
|
|
897
|
+
text=True,
|
|
898
|
+
check=False,
|
|
899
|
+
)
|
|
900
|
+
if proc.returncode != 0:
|
|
901
|
+
raise RuntimeError(
|
|
902
|
+
f"rsync package push failed (rc={proc.returncode}): {proc.stderr.strip()}"
|
|
903
|
+
)
|
|
904
|
+
subprocess.run(
|
|
905
|
+
[
|
|
906
|
+
*_ssh_base(remote_spec),
|
|
907
|
+
f"chmod 700 {shlex.quote(package.session_dir + '/.ai-cli/bin/ai-prompt-editor')} 2>/dev/null || true",
|
|
908
|
+
],
|
|
909
|
+
capture_output=True,
|
|
910
|
+
text=True,
|
|
911
|
+
check=False,
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
# Also push tool config/auth files to the REAL home on the remote.
|
|
915
|
+
# Some tools (e.g. claude) resolve $HOME via getpwuid/passwd and
|
|
916
|
+
# ignore our HOME override, so credentials must also live there.
|
|
917
|
+
tool_pairs = _TOOL_PACKAGE_FILES.get(package.tool_name, [])
|
|
918
|
+
if tool_pairs and package.real_home:
|
|
919
|
+
real_staging = Path(staging) / "__real_home__"
|
|
920
|
+
real_staging.mkdir(exist_ok=True)
|
|
921
|
+
has_files = False
|
|
922
|
+
for local_raw, remote_rel in tool_pairs:
|
|
923
|
+
src = Path(local_raw).expanduser()
|
|
924
|
+
if src.is_file():
|
|
925
|
+
dest = real_staging / remote_rel
|
|
926
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
927
|
+
shutil.copy2(str(src), str(dest))
|
|
928
|
+
has_files = True
|
|
929
|
+
if has_files:
|
|
930
|
+
subprocess.run(
|
|
931
|
+
[
|
|
932
|
+
rsync_bin,
|
|
933
|
+
"-az",
|
|
934
|
+
f"--chmod={_RSYNC_CHMOD}",
|
|
935
|
+
"-e",
|
|
936
|
+
_SSH_OPTS,
|
|
937
|
+
str(real_staging) + "/",
|
|
938
|
+
f"{remote_spec.ssh_target}:{package.real_home}/",
|
|
939
|
+
],
|
|
940
|
+
capture_output=True,
|
|
941
|
+
text=True,
|
|
942
|
+
check=False,
|
|
943
|
+
)
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
# ---------------------------------------------------------------------------
|
|
947
|
+
# Probe
|
|
948
|
+
# ---------------------------------------------------------------------------
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def probe_remote_session(
|
|
952
|
+
package: RemotePackage,
|
|
953
|
+
remote_spec: RemoteSpec,
|
|
954
|
+
) -> bool:
|
|
955
|
+
"""Check if a tmux session for this package already exists on the remote."""
|
|
956
|
+
proc = subprocess.run(
|
|
957
|
+
[
|
|
958
|
+
*_ssh_base(remote_spec),
|
|
959
|
+
f"tmux -L {shlex.quote(package.tmux_socket)} "
|
|
960
|
+
f"has-session -t {shlex.quote(package.session_name)} 2>/dev/null",
|
|
961
|
+
],
|
|
962
|
+
capture_output=True,
|
|
963
|
+
check=False,
|
|
964
|
+
)
|
|
965
|
+
return proc.returncode == 0
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def reattach_remote_session(
|
|
969
|
+
package: RemotePackage,
|
|
970
|
+
remote_spec: RemoteSpec,
|
|
971
|
+
*,
|
|
972
|
+
proxy_port: int = 0,
|
|
973
|
+
ssh_opts: Optional[list[str]] = None,
|
|
974
|
+
) -> int:
|
|
975
|
+
"""Reattach to an existing remote tmux session.
|
|
976
|
+
|
|
977
|
+
Sets up the SSH reverse tunnel if *proxy_port* is non-zero.
|
|
978
|
+
"""
|
|
979
|
+
sock_q = shlex.quote(package.tmux_socket)
|
|
980
|
+
sess_q = shlex.quote(package.session_name)
|
|
981
|
+
conf_q = shlex.quote(f"{package.session_dir}/.tmux.conf")
|
|
982
|
+
remote_cmd = (
|
|
983
|
+
f"for c in $(tmux -L {sock_q} list-clients -t {sess_q} -F '#{{client_tty}}' 2>/dev/null); do "
|
|
984
|
+
f"tmux -L {sock_q} detach-client -t \"$c\" 2>/dev/null; done; "
|
|
985
|
+
f"tmux -L {sock_q} source-file {conf_q} >/dev/null 2>&1; "
|
|
986
|
+
f"tmux -L {sock_q} attach -t {sess_q}"
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
ssh_cmd = [
|
|
990
|
+
"ssh",
|
|
991
|
+
"-o", "PermitLocalCommand=no",
|
|
992
|
+
"-o", "ServerAliveInterval=30",
|
|
993
|
+
"-o", "ServerAliveCountMax=3",
|
|
994
|
+
"-o", "RequestTTY=force",
|
|
995
|
+
]
|
|
996
|
+
if proxy_port:
|
|
997
|
+
ssh_cmd += ["-R", f"127.0.0.1:{proxy_port}:127.0.0.1:{proxy_port}"]
|
|
998
|
+
for opt in (ssh_opts or []):
|
|
999
|
+
ssh_cmd.append(opt)
|
|
1000
|
+
ssh_cmd += [remote_spec.ssh_target, remote_cmd]
|
|
1001
|
+
|
|
1002
|
+
print_sync_status(f"Reattaching to existing session '{package.session_name}'")
|
|
1003
|
+
proc = subprocess.run(ssh_cmd, check=False)
|
|
1004
|
+
return proc.returncode
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def render_remote_ai_mux_config(
|
|
1008
|
+
*,
|
|
1009
|
+
tool_name: str,
|
|
1010
|
+
session_name: str,
|
|
1011
|
+
command: list[str],
|
|
1012
|
+
cwd: str,
|
|
1013
|
+
env: dict[str, str],
|
|
1014
|
+
) -> str:
|
|
1015
|
+
payload = {
|
|
1016
|
+
"session_name": session_name,
|
|
1017
|
+
"tabs": [
|
|
1018
|
+
{
|
|
1019
|
+
"label": tool_name,
|
|
1020
|
+
"cmd": command,
|
|
1021
|
+
"env": env,
|
|
1022
|
+
"cwd": cwd,
|
|
1023
|
+
"primary": True,
|
|
1024
|
+
}
|
|
1025
|
+
],
|
|
1026
|
+
}
|
|
1027
|
+
return json.dumps(payload)
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
# ---------------------------------------------------------------------------
|
|
1031
|
+
# Pull artifacts
|
|
1032
|
+
# ---------------------------------------------------------------------------
|
|
1033
|
+
|
|
1034
|
+
_ARTIFACT_GLOBS = [
|
|
1035
|
+
".ai-cli/logs/",
|
|
1036
|
+
".ai-cli/system_instructions.txt",
|
|
1037
|
+
".ai-cli/base_instructions.txt",
|
|
1038
|
+
".ai-cli/instructions/",
|
|
1039
|
+
".ai-cli/project-prompts/",
|
|
1040
|
+
".claude/projects/",
|
|
1041
|
+
".codex/sessions/",
|
|
1042
|
+
".codex/projects/",
|
|
1043
|
+
".copilot/sessions/",
|
|
1044
|
+
".gemini/sessions/",
|
|
1045
|
+
]
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def pull_session_artifacts(
|
|
1049
|
+
package: RemotePackage,
|
|
1050
|
+
remote_spec: RemoteSpec,
|
|
1051
|
+
local_dest: Path,
|
|
1052
|
+
) -> None:
|
|
1053
|
+
"""Pull logs and session data from the remote session dir back to local."""
|
|
1054
|
+
local_dest.mkdir(parents=True, exist_ok=True)
|
|
1055
|
+
rsync_bin = shutil.which("rsync")
|
|
1056
|
+
if not rsync_bin:
|
|
1057
|
+
print_sync_status("Warning: rsync not found; skipping artifact pull")
|
|
1058
|
+
return
|
|
1059
|
+
|
|
1060
|
+
safe_host = re.sub(r"[^A-Za-z0-9_-]", "-", remote_spec.host)
|
|
1061
|
+
for rel_glob in _ARTIFACT_GLOBS:
|
|
1062
|
+
src = f"{remote_spec.ssh_target}:{package.session_dir}/{rel_glob}"
|
|
1063
|
+
dest = local_dest / f"remote-{safe_host}" / rel_glob.rstrip("/").replace("/", "-")
|
|
1064
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
1065
|
+
proc = subprocess.run(
|
|
1066
|
+
[rsync_bin, "-az", "--ignore-errors", "-e", _SSH_OPTS, src, str(dest) + "/"],
|
|
1067
|
+
capture_output=True,
|
|
1068
|
+
text=True,
|
|
1069
|
+
check=False,
|
|
1070
|
+
)
|
|
1071
|
+
if proc.returncode == 0:
|
|
1072
|
+
print_sync_status(f"Pulled {rel_glob} → {dest}")
|
|
1073
|
+
# Non-fatal: remote dir may not exist
|
|
1074
|
+
|
|
1075
|
+
# Sync edited prompt layers back to local ~/.ai-cli/
|
|
1076
|
+
_pull_prompt_layers_back(package, remote_spec, rsync_bin)
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
# Prompt layer files to sync back (relative to session dir → local ~/.ai-cli/)
|
|
1080
|
+
_PROMPT_PULL_BACK = [
|
|
1081
|
+
".ai-cli/system_instructions.txt",
|
|
1082
|
+
".ai-cli/base_instructions.txt",
|
|
1083
|
+
".ai-cli/instructions/",
|
|
1084
|
+
".ai-cli/project-prompts/",
|
|
1085
|
+
]
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def _pull_prompt_layers_back(
|
|
1089
|
+
package: RemotePackage,
|
|
1090
|
+
remote_spec: RemoteSpec,
|
|
1091
|
+
rsync_bin: str,
|
|
1092
|
+
) -> None:
|
|
1093
|
+
"""Pull edited prompt files from the remote session dir back to local ~/.ai-cli/."""
|
|
1094
|
+
ai_cli_dir = Path("~/.ai-cli").expanduser()
|
|
1095
|
+
for rel in _PROMPT_PULL_BACK:
|
|
1096
|
+
src = f"{remote_spec.ssh_target}:{package.session_dir}/{rel}"
|
|
1097
|
+
local_target = ai_cli_dir / rel.removeprefix(".ai-cli/")
|
|
1098
|
+
if rel.endswith("/"):
|
|
1099
|
+
local_target.mkdir(parents=True, exist_ok=True)
|
|
1100
|
+
dest_str = str(local_target) + "/"
|
|
1101
|
+
else:
|
|
1102
|
+
local_target.parent.mkdir(parents=True, exist_ok=True)
|
|
1103
|
+
dest_str = str(local_target)
|
|
1104
|
+
proc = subprocess.run(
|
|
1105
|
+
[rsync_bin, "-az", "--update", "--ignore-errors", "-e", _SSH_OPTS, src, dest_str],
|
|
1106
|
+
capture_output=True,
|
|
1107
|
+
text=True,
|
|
1108
|
+
check=False,
|
|
1109
|
+
)
|
|
1110
|
+
if proc.returncode == 0 and proc.stdout.strip():
|
|
1111
|
+
print_sync_status(f"Synced back {rel} → {local_target}")
|