ai-session-manager 0.1.3__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_session_manager/__init__.py +1 -0
- ai_session_manager/cli.py +281 -0
- ai_session_manager/wrapper.py +309 -0
- ai_session_manager-0.1.3.dist-info/METADATA +231 -0
- ai_session_manager-0.1.3.dist-info/RECORD +9 -0
- ai_session_manager-0.1.3.dist-info/WHEEL +5 -0
- ai_session_manager-0.1.3.dist-info/entry_points.txt +2 -0
- ai_session_manager-0.1.3.dist-info/licenses/LICENSE +21 -0
- ai_session_manager-0.1.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""ai-session-manager package."""
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ai-session-manager CLI — manage wrapper-based session persistence for AI CLIs.
|
|
3
|
+
|
|
4
|
+
Commands:
|
|
5
|
+
setup Rename the real tool binary to <tool>-real and install wrappers.
|
|
6
|
+
teardown Restore original tool binaries.
|
|
7
|
+
status Show installation state for supported tools.
|
|
8
|
+
reset Delete persisted wrapper state for supported tools.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import platform
|
|
15
|
+
import shutil
|
|
16
|
+
import stat
|
|
17
|
+
import sys
|
|
18
|
+
import textwrap
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from ai_session_manager.wrapper import (
|
|
22
|
+
LEGACY_FOLDER_SESSION_FILE,
|
|
23
|
+
LEGACY_REPO_SESSION_FILE,
|
|
24
|
+
TOOLS,
|
|
25
|
+
ToolSpec,
|
|
26
|
+
_legacy_copilot_session_file,
|
|
27
|
+
_legacy_repo_state_file,
|
|
28
|
+
_state_file,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
IS_WINDOWS = platform.system() == "Windows"
|
|
33
|
+
ALL_TOOLS = tuple(sorted(TOOLS))
|
|
34
|
+
TOOL_CHOICES = (*ALL_TOOLS, "all")
|
|
35
|
+
|
|
36
|
+
_UNIX_WRAPPER = textwrap.dedent(
|
|
37
|
+
"""\
|
|
38
|
+
#!/usr/bin/env python3
|
|
39
|
+
# Auto-generated by ai-session-manager setup
|
|
40
|
+
from ai_session_manager.wrapper import run
|
|
41
|
+
run({tool_key!r})
|
|
42
|
+
"""
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
_WINDOWS_CMD_WRAPPER = textwrap.dedent(
|
|
46
|
+
"""\
|
|
47
|
+
@echo off
|
|
48
|
+
set "AI_SESSION_MANAGER_REAL_BIN=%~dp0{real_name}"
|
|
49
|
+
set "AI_SESSION_MANAGER_TOOL={tool_key}"
|
|
50
|
+
python -c "from ai_session_manager.wrapper import run; run()" %*
|
|
51
|
+
"""
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _find_binary(name: str) -> Path | None:
|
|
56
|
+
found = shutil.which(name)
|
|
57
|
+
return Path(found) if found else None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _real_bin_path(tool: ToolSpec, binary_path: Path) -> Path:
|
|
61
|
+
"""Return the path where the real binary is stored alongside the wrapper."""
|
|
62
|
+
if IS_WINDOWS:
|
|
63
|
+
return binary_path.parent / (binary_path.stem + "-real" + binary_path.suffix)
|
|
64
|
+
return binary_path.parent / f"{tool.binary_name}-real"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _wrapper_path(binary_path: Path) -> Path:
|
|
68
|
+
"""Return the path of the wrapper file to install."""
|
|
69
|
+
if IS_WINDOWS:
|
|
70
|
+
return binary_path.parent / (binary_path.stem + ".cmd")
|
|
71
|
+
return binary_path
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _is_wrapper(path: Path) -> bool:
|
|
75
|
+
"""Return True if the file at path is our wrapper."""
|
|
76
|
+
try:
|
|
77
|
+
contents = path.read_text(errors="replace")
|
|
78
|
+
except OSError:
|
|
79
|
+
return False
|
|
80
|
+
return "ai_session_manager.wrapper" in contents
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _write_wrapper(tool: ToolSpec, wrapper_path: Path, real_bin: Path) -> None:
|
|
84
|
+
if IS_WINDOWS:
|
|
85
|
+
wrapper_path.write_text(
|
|
86
|
+
_WINDOWS_CMD_WRAPPER.format(real_name=real_bin.name, tool_key=tool.key)
|
|
87
|
+
)
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
wrapper_path.write_text(_UNIX_WRAPPER.format(tool_key=tool.key))
|
|
91
|
+
wrapper_path.chmod(real_bin.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _resolve_tools(requested: list[str]) -> list[ToolSpec]:
|
|
95
|
+
if not requested or "all" in requested:
|
|
96
|
+
return [TOOLS[name] for name in ALL_TOOLS]
|
|
97
|
+
return [TOOLS[name] for name in requested]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _tool_header(tool: ToolSpec) -> None:
|
|
101
|
+
print(f"[{tool.binary_name}]")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cmd_setup(args: argparse.Namespace) -> int:
|
|
105
|
+
explicit = bool(args.tools and "all" not in args.tools)
|
|
106
|
+
exit_code = 0
|
|
107
|
+
processed = 0
|
|
108
|
+
|
|
109
|
+
for tool in _resolve_tools(args.tools):
|
|
110
|
+
_tool_header(tool)
|
|
111
|
+
binary_path = _find_binary(tool.binary_name)
|
|
112
|
+
if binary_path is None:
|
|
113
|
+
print(f"Skipping: '{tool.binary_name}' not found in PATH.")
|
|
114
|
+
if explicit:
|
|
115
|
+
exit_code = 1
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
processed += 1
|
|
119
|
+
binary_path = binary_path.resolve()
|
|
120
|
+
real_bin = _real_bin_path(tool, binary_path)
|
|
121
|
+
wrapper = _wrapper_path(binary_path)
|
|
122
|
+
wrapper_active = _is_wrapper(wrapper)
|
|
123
|
+
|
|
124
|
+
if wrapper_active and real_bin.exists():
|
|
125
|
+
print(f"Already set up: real binary is {real_bin}")
|
|
126
|
+
print(f"Wrapper is at: {wrapper}")
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
if not wrapper_active and real_bin.exists():
|
|
130
|
+
print(f"Detected {tool.binary_name} was reinstalled — updating {real_bin.name}.")
|
|
131
|
+
binary_path.replace(real_bin)
|
|
132
|
+
elif not wrapper_active:
|
|
133
|
+
binary_path.rename(real_bin)
|
|
134
|
+
print(f"Renamed {binary_path.name} -> {real_bin.name}")
|
|
135
|
+
|
|
136
|
+
_write_wrapper(tool, wrapper, real_bin)
|
|
137
|
+
print(f"Wrapper installed at {wrapper}")
|
|
138
|
+
|
|
139
|
+
if processed == 0:
|
|
140
|
+
print("No supported tool binaries were found.")
|
|
141
|
+
return 1
|
|
142
|
+
return exit_code
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def cmd_teardown(args: argparse.Namespace) -> int:
|
|
146
|
+
explicit = bool(args.tools and "all" not in args.tools)
|
|
147
|
+
exit_code = 0
|
|
148
|
+
processed = 0
|
|
149
|
+
|
|
150
|
+
for tool in _resolve_tools(args.tools):
|
|
151
|
+
_tool_header(tool)
|
|
152
|
+
binary_path = _find_binary(tool.binary_name)
|
|
153
|
+
if binary_path is None:
|
|
154
|
+
fallback = _find_binary(f"{tool.binary_name}-real")
|
|
155
|
+
if fallback is None:
|
|
156
|
+
print(f"Skipping: neither '{tool.binary_name}' nor '{tool.binary_name}-real' found.")
|
|
157
|
+
if explicit:
|
|
158
|
+
exit_code = 1
|
|
159
|
+
continue
|
|
160
|
+
binary_path = fallback
|
|
161
|
+
|
|
162
|
+
processed += 1
|
|
163
|
+
binary_path = binary_path.resolve()
|
|
164
|
+
if binary_path.name.startswith(f"{tool.binary_name}-real"):
|
|
165
|
+
real_bin = binary_path
|
|
166
|
+
wrapper = _wrapper_path(binary_path.with_name(tool.binary_name + binary_path.suffix))
|
|
167
|
+
restored_binary = binary_path.with_name(tool.binary_name + binary_path.suffix)
|
|
168
|
+
else:
|
|
169
|
+
restored_binary = binary_path
|
|
170
|
+
real_bin = _real_bin_path(tool, binary_path)
|
|
171
|
+
wrapper = _wrapper_path(binary_path)
|
|
172
|
+
|
|
173
|
+
if not real_bin.exists():
|
|
174
|
+
print("Nothing to undo: real binary not found alongside wrapper.")
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
wrapper.unlink(missing_ok=True)
|
|
178
|
+
real_bin.rename(restored_binary)
|
|
179
|
+
print(f"Restored {real_bin.name} -> {restored_binary.name}")
|
|
180
|
+
|
|
181
|
+
if processed == 0:
|
|
182
|
+
print("No supported tool binaries were found.")
|
|
183
|
+
return 1
|
|
184
|
+
return exit_code
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
188
|
+
for index, tool in enumerate(_resolve_tools(args.tools)):
|
|
189
|
+
if index:
|
|
190
|
+
print()
|
|
191
|
+
|
|
192
|
+
binary_path = _find_binary(tool.binary_name)
|
|
193
|
+
real_bin = _find_binary(f"{tool.binary_name}-real")
|
|
194
|
+
_, state_path, _ = _state_file(tool.key)
|
|
195
|
+
|
|
196
|
+
print(f"Tool : {tool.binary_name}")
|
|
197
|
+
print(f"Platform : {platform.system()}")
|
|
198
|
+
print(f"Binary : {binary_path or 'not found'}")
|
|
199
|
+
print(f"Real binary : {real_bin or 'not found'}")
|
|
200
|
+
print(f"State file : {state_path}")
|
|
201
|
+
print(f"State : {'present' if state_path.exists() else 'missing'}")
|
|
202
|
+
|
|
203
|
+
if binary_path:
|
|
204
|
+
wrapper = _wrapper_path(binary_path.resolve())
|
|
205
|
+
if _is_wrapper(wrapper):
|
|
206
|
+
print("Status : wrapper active")
|
|
207
|
+
else:
|
|
208
|
+
print("Status : real binary")
|
|
209
|
+
else:
|
|
210
|
+
print("Status : not installed")
|
|
211
|
+
|
|
212
|
+
return 0
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def cmd_reset(args: argparse.Namespace) -> int:
|
|
216
|
+
for tool in _resolve_tools(args.tools):
|
|
217
|
+
_tool_header(tool)
|
|
218
|
+
scope_dir, state_path, scope_kind = _state_file(tool.key)
|
|
219
|
+
removed_paths: list[Path] = []
|
|
220
|
+
|
|
221
|
+
if state_path.exists():
|
|
222
|
+
state_path.unlink()
|
|
223
|
+
removed_paths.append(state_path)
|
|
224
|
+
|
|
225
|
+
legacy_repo_state_path = _legacy_repo_state_file(tool.key)
|
|
226
|
+
if legacy_repo_state_path is not None and legacy_repo_state_path.exists():
|
|
227
|
+
legacy_repo_state_path.unlink()
|
|
228
|
+
removed_paths.append(legacy_repo_state_path)
|
|
229
|
+
|
|
230
|
+
if tool.key == "copilot":
|
|
231
|
+
legacy_path = _legacy_copilot_session_file()
|
|
232
|
+
if legacy_path.exists():
|
|
233
|
+
legacy_path.unlink()
|
|
234
|
+
removed_paths.append(legacy_path)
|
|
235
|
+
|
|
236
|
+
if removed_paths:
|
|
237
|
+
for removed_path in removed_paths:
|
|
238
|
+
print(f"Removed: {removed_path}")
|
|
239
|
+
print(f"Next '{tool.binary_name}' in this {scope_kind} will start fresh.")
|
|
240
|
+
else:
|
|
241
|
+
legacy_hint = ""
|
|
242
|
+
if legacy_repo_state_path is not None:
|
|
243
|
+
legacy_hint = f" or migrated legacy {legacy_repo_state_path.name}"
|
|
244
|
+
if tool.key == "copilot":
|
|
245
|
+
legacy_name = LEGACY_REPO_SESSION_FILE if scope_kind == "repo" else LEGACY_FOLDER_SESSION_FILE
|
|
246
|
+
legacy_hint = f"{legacy_hint} or legacy {legacy_name}"
|
|
247
|
+
print(f"No state file found for this {scope_kind}: {scope_dir}{legacy_hint}")
|
|
248
|
+
|
|
249
|
+
return 0
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def main() -> None:
|
|
253
|
+
parser = argparse.ArgumentParser(
|
|
254
|
+
prog="ai-session-manager",
|
|
255
|
+
description="Manage per-project session persistence for AI CLI tools.",
|
|
256
|
+
)
|
|
257
|
+
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
258
|
+
|
|
259
|
+
for name, help_text in (
|
|
260
|
+
("setup", "Install wrappers for supported tools"),
|
|
261
|
+
("teardown", "Remove wrappers and restore original binaries"),
|
|
262
|
+
("status", "Show current installation state"),
|
|
263
|
+
("reset", "Delete persisted wrapper state"),
|
|
264
|
+
):
|
|
265
|
+
subparser = sub.add_parser(name, help=help_text)
|
|
266
|
+
subparser.add_argument("tools", nargs="*", choices=TOOL_CHOICES, help="Tools to target")
|
|
267
|
+
|
|
268
|
+
args = parser.parse_args()
|
|
269
|
+
|
|
270
|
+
dispatch = {
|
|
271
|
+
"setup": cmd_setup,
|
|
272
|
+
"teardown": cmd_teardown,
|
|
273
|
+
"status": cmd_status,
|
|
274
|
+
"reset": cmd_reset,
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if args.command not in dispatch:
|
|
278
|
+
parser.print_help()
|
|
279
|
+
sys.exit(0)
|
|
280
|
+
|
|
281
|
+
sys.exit(dispatch[args.command](args))
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Core wrapper logic for supported AI CLI tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import uuid
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
IS_WINDOWS = platform.system() == "Windows"
|
|
16
|
+
STATE_DIR_NAME = "ai-session-manager"
|
|
17
|
+
LEGACY_REPO_SESSION_FILE = "copilot-session"
|
|
18
|
+
LEGACY_FOLDER_SESSION_FILE = ".copilot-session"
|
|
19
|
+
REAL_BIN_ENV = "AI_SESSION_MANAGER_REAL_BIN"
|
|
20
|
+
TOOL_KEY_ENV = "AI_SESSION_MANAGER_TOOL"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ToolSpec:
|
|
25
|
+
key: str
|
|
26
|
+
binary_name: str
|
|
27
|
+
display_name: str
|
|
28
|
+
session_mode: str
|
|
29
|
+
resume_args: tuple[str, ...] = ()
|
|
30
|
+
bypass_flags: frozenset[str] = frozenset()
|
|
31
|
+
passthrough_commands: frozenset[str] = frozenset()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
TOOLS = {
|
|
35
|
+
"agy": ToolSpec(
|
|
36
|
+
key="agy",
|
|
37
|
+
binary_name="agy",
|
|
38
|
+
display_name="Antigravity CLI",
|
|
39
|
+
session_mode="auto-resume",
|
|
40
|
+
resume_args=("-c",),
|
|
41
|
+
bypass_flags=frozenset({"-c", "--conversation"}),
|
|
42
|
+
passthrough_commands=frozenset({"auth"}),
|
|
43
|
+
),
|
|
44
|
+
"claude": ToolSpec(
|
|
45
|
+
key="claude",
|
|
46
|
+
binary_name="claude",
|
|
47
|
+
display_name="Claude Code",
|
|
48
|
+
session_mode="auto-resume",
|
|
49
|
+
resume_args=("-c",),
|
|
50
|
+
bypass_flags=frozenset({"-c", "--continue", "-r", "--resume"}),
|
|
51
|
+
passthrough_commands=frozenset({"agents", "attach", "auth", "install", "update"}),
|
|
52
|
+
),
|
|
53
|
+
"codex": ToolSpec(
|
|
54
|
+
key="codex",
|
|
55
|
+
binary_name="codex",
|
|
56
|
+
display_name="Codex",
|
|
57
|
+
session_mode="auto-resume",
|
|
58
|
+
resume_args=("resume", "--last"),
|
|
59
|
+
passthrough_commands=frozenset(
|
|
60
|
+
{"app", "debug", "exec", "mcp", "resume", "review", "sandbox"}
|
|
61
|
+
),
|
|
62
|
+
),
|
|
63
|
+
"copilot": ToolSpec(
|
|
64
|
+
key="copilot",
|
|
65
|
+
binary_name="copilot",
|
|
66
|
+
display_name="GitHub Copilot CLI",
|
|
67
|
+
session_mode="managed-id",
|
|
68
|
+
bypass_flags=frozenset(
|
|
69
|
+
{"--session-id", "--resume", "--continue", "--clear", "-p", "--prompt"}
|
|
70
|
+
),
|
|
71
|
+
passthrough_commands=frozenset({"update"}),
|
|
72
|
+
),
|
|
73
|
+
"gemini": ToolSpec(
|
|
74
|
+
key="gemini",
|
|
75
|
+
binary_name="gemini",
|
|
76
|
+
display_name="Gemini CLI",
|
|
77
|
+
session_mode="auto-resume",
|
|
78
|
+
resume_args=("--resume",),
|
|
79
|
+
bypass_flags=frozenset({"-r", "--resume", "--list-sessions", "--delete-session"}),
|
|
80
|
+
),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
UNIVERSAL_BYPASS_FLAGS = frozenset({"-h", "--help", "-V", "--version"})
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def get_tool(tool_key: str) -> ToolSpec:
|
|
87
|
+
try:
|
|
88
|
+
return TOOLS[tool_key]
|
|
89
|
+
except KeyError as exc:
|
|
90
|
+
raise ValueError(f"Unsupported tool: {tool_key}") from exc
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _git_root(cwd: Path | None = None) -> Path | None:
|
|
94
|
+
"""Return the root of the current git repo, or None if not in one."""
|
|
95
|
+
try:
|
|
96
|
+
result = subprocess.run(
|
|
97
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
98
|
+
capture_output=True,
|
|
99
|
+
text=True,
|
|
100
|
+
cwd=cwd,
|
|
101
|
+
)
|
|
102
|
+
except FileNotFoundError:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
if result.returncode == 0:
|
|
106
|
+
return Path(result.stdout.strip())
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _state_root(cwd: Path | None = None) -> tuple[Path, Path, str]:
|
|
111
|
+
"""Return the current scope directory, state root, and scope kind."""
|
|
112
|
+
scope_dir = (cwd or Path.cwd()).resolve()
|
|
113
|
+
git_root = _git_root(scope_dir)
|
|
114
|
+
if git_root is not None:
|
|
115
|
+
return git_root, git_root / f".{STATE_DIR_NAME}", "repo"
|
|
116
|
+
return scope_dir, scope_dir / f".{STATE_DIR_NAME}", "folder"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _state_file(tool_key: str, cwd: Path | None = None) -> tuple[Path, Path, str]:
|
|
120
|
+
"""Return the scope directory, tool state path, and scope kind."""
|
|
121
|
+
scope_dir, state_root, scope_kind = _state_root(cwd)
|
|
122
|
+
return scope_dir, state_root / f"{tool_key}.json", scope_kind
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _legacy_repo_state_file(tool_key: str, cwd: Path | None = None) -> Path | None:
|
|
126
|
+
"""Return the previous repo-only state file path, if applicable."""
|
|
127
|
+
scope_dir = (cwd or Path.cwd()).resolve()
|
|
128
|
+
git_root = _git_root(scope_dir)
|
|
129
|
+
if git_root is None:
|
|
130
|
+
return None
|
|
131
|
+
return git_root / ".git" / STATE_DIR_NAME / f"{tool_key}.json"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _legacy_copilot_session_file(cwd: Path | None = None) -> Path:
|
|
135
|
+
"""Return the pre-refactor Copilot state file path."""
|
|
136
|
+
scope_dir = (cwd or Path.cwd()).resolve()
|
|
137
|
+
git_root = _git_root(scope_dir)
|
|
138
|
+
if git_root is not None:
|
|
139
|
+
return git_root / ".git" / LEGACY_REPO_SESSION_FILE
|
|
140
|
+
return scope_dir / LEGACY_FOLDER_SESSION_FILE
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _state_payload(spec: ToolSpec, resume_target: str | None = None) -> dict[str, object]:
|
|
144
|
+
payload: dict[str, object] = {
|
|
145
|
+
"version": 1,
|
|
146
|
+
"tool": spec.key,
|
|
147
|
+
"session_mode": spec.session_mode,
|
|
148
|
+
}
|
|
149
|
+
if resume_target is not None:
|
|
150
|
+
payload["resume_target"] = resume_target
|
|
151
|
+
return payload
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _write_state(path: Path, payload: dict[str, object]) -> None:
|
|
155
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
path.write_text(json.dumps(payload, indent=2) + "\n")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _load_state(spec: ToolSpec, cwd: Path | None = None) -> dict[str, object] | None:
|
|
160
|
+
_, path, _ = _state_file(spec.key, cwd)
|
|
161
|
+
if path.exists():
|
|
162
|
+
return json.loads(path.read_text())
|
|
163
|
+
|
|
164
|
+
legacy_repo_path = _legacy_repo_state_file(spec.key, cwd)
|
|
165
|
+
if legacy_repo_path is not None and legacy_repo_path.exists():
|
|
166
|
+
payload = json.loads(legacy_repo_path.read_text())
|
|
167
|
+
_write_state(path, payload)
|
|
168
|
+
return payload
|
|
169
|
+
|
|
170
|
+
if spec.key != "copilot":
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
legacy_path = _legacy_copilot_session_file(cwd)
|
|
174
|
+
if not legacy_path.exists():
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
session_id = legacy_path.read_text().strip()
|
|
178
|
+
if not session_id:
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
payload = _state_payload(spec, session_id)
|
|
182
|
+
_write_state(path, payload)
|
|
183
|
+
return payload
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _create_state(spec: ToolSpec, cwd: Path | None = None) -> dict[str, object]:
|
|
187
|
+
_, path, _ = _state_file(spec.key, cwd)
|
|
188
|
+
resume_target = str(uuid.uuid4()) if spec.session_mode == "managed-id" else None
|
|
189
|
+
payload = _state_payload(spec, resume_target)
|
|
190
|
+
_write_state(path, payload)
|
|
191
|
+
return payload
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _real_bin_name(spec: ToolSpec, wrapper_path: Path) -> Path:
|
|
195
|
+
"""Return the expected path of the real binary alongside the wrapper."""
|
|
196
|
+
if IS_WINDOWS:
|
|
197
|
+
return wrapper_path.parent / (wrapper_path.stem + "-real" + wrapper_path.suffix)
|
|
198
|
+
return wrapper_path.parent / f"{spec.binary_name}-real"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _find_real_binary(spec: ToolSpec) -> str:
|
|
202
|
+
"""
|
|
203
|
+
Find the real tool binary by checking a sibling '<tool>-real' first, then PATH.
|
|
204
|
+
"""
|
|
205
|
+
script_path = Path(sys.argv[0]).resolve()
|
|
206
|
+
sibling = _real_bin_name(spec, script_path)
|
|
207
|
+
if sibling.exists() and os.access(sibling, os.X_OK):
|
|
208
|
+
return str(sibling)
|
|
209
|
+
|
|
210
|
+
candidates = [f"{spec.binary_name}-real.exe", f"{spec.binary_name}-real"]
|
|
211
|
+
if not IS_WINDOWS:
|
|
212
|
+
candidates = [f"{spec.binary_name}-real"]
|
|
213
|
+
|
|
214
|
+
for directory in os.environ.get("PATH", "").split(os.pathsep):
|
|
215
|
+
for name in candidates:
|
|
216
|
+
candidate = Path(directory) / name
|
|
217
|
+
if candidate.exists() and os.access(candidate, os.X_OK):
|
|
218
|
+
return str(candidate)
|
|
219
|
+
|
|
220
|
+
raise FileNotFoundError(
|
|
221
|
+
f"Cannot find '{spec.binary_name}-real'. Run 'ai-session-manager setup {spec.key}' first."
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _first_positional_arg(args: list[str]) -> str | None:
|
|
226
|
+
for arg in args:
|
|
227
|
+
if arg == "--":
|
|
228
|
+
return None
|
|
229
|
+
if not arg.startswith("-") or arg == "-":
|
|
230
|
+
return arg
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _should_bypass(spec: ToolSpec, args: list[str]) -> bool:
|
|
235
|
+
for arg in args:
|
|
236
|
+
base = arg.split("=", 1)[0]
|
|
237
|
+
if base in UNIVERSAL_BYPASS_FLAGS or base in spec.bypass_flags:
|
|
238
|
+
return True
|
|
239
|
+
|
|
240
|
+
first_positional = _first_positional_arg(args)
|
|
241
|
+
return first_positional in spec.passthrough_commands
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _resume_invocation(spec: ToolSpec, state: dict[str, object], user_args: list[str]) -> list[str]:
|
|
245
|
+
if spec.session_mode == "managed-id":
|
|
246
|
+
resume_target = state["resume_target"]
|
|
247
|
+
return ["--session-id", str(resume_target), *user_args]
|
|
248
|
+
return [*spec.resume_args, *user_args]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _exec(real: str, args: list[str]) -> None:
|
|
252
|
+
"""Replace the current process with the real tool (Unix) or spawn it (Windows)."""
|
|
253
|
+
if IS_WINDOWS:
|
|
254
|
+
result = subprocess.run([real] + args)
|
|
255
|
+
sys.exit(result.returncode)
|
|
256
|
+
os.execv(real, [real] + args)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _tool_from_invocation(tool_key: str | None) -> ToolSpec:
|
|
260
|
+
if tool_key is not None:
|
|
261
|
+
return get_tool(tool_key)
|
|
262
|
+
|
|
263
|
+
env_tool = os.environ.get(TOOL_KEY_ENV)
|
|
264
|
+
if env_tool:
|
|
265
|
+
return get_tool(env_tool)
|
|
266
|
+
|
|
267
|
+
script_name = Path(sys.argv[0]).stem
|
|
268
|
+
return get_tool(script_name)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def run(tool_key: str | None = None) -> None:
|
|
272
|
+
"""Entry point for installed tool wrappers."""
|
|
273
|
+
spec = _tool_from_invocation(tool_key)
|
|
274
|
+
real = os.environ.get(REAL_BIN_ENV) or _find_real_binary(spec)
|
|
275
|
+
user_args = sys.argv[1:]
|
|
276
|
+
|
|
277
|
+
if _should_bypass(spec, user_args):
|
|
278
|
+
_exec(real, user_args)
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
scope_dir, _, _scope_kind = _state_file(spec.key)
|
|
282
|
+
state = _load_state(spec)
|
|
283
|
+
|
|
284
|
+
if state is None:
|
|
285
|
+
state = _create_state(spec)
|
|
286
|
+
if spec.session_mode == "managed-id":
|
|
287
|
+
session_id = str(state["resume_target"])
|
|
288
|
+
print(f"[ai-session-manager] New session {session_id} ({scope_dir.name})", flush=True)
|
|
289
|
+
_exec(real, _resume_invocation(spec, state, user_args))
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
print(
|
|
293
|
+
f"[ai-session-manager] Starting new {spec.display_name} session ({scope_dir.name})",
|
|
294
|
+
flush=True,
|
|
295
|
+
)
|
|
296
|
+
_exec(real, user_args)
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
if spec.session_mode == "managed-id":
|
|
300
|
+
session_id = str(state["resume_target"])
|
|
301
|
+
print(f"[ai-session-manager] Resuming session {session_id} ({scope_dir.name})", flush=True)
|
|
302
|
+
else:
|
|
303
|
+
print(
|
|
304
|
+
f"[ai-session-manager] Resuming latest {spec.display_name} session ({scope_dir.name})",
|
|
305
|
+
flush=True,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
_exec(real, _resume_invocation(spec, state, user_args))
|
|
309
|
+
return
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ai-session-manager
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: Per-project session persistence for AI CLI tools
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/DrFatihTekin/ai-session-manager
|
|
7
|
+
Project-URL: Repository, https://github.com/DrFatihTekin/ai-session-manager
|
|
8
|
+
Keywords: ai,cli,session,wrapper,copilot,claude,codex,gemini
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Requires-Python: >=3.8
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# ai-session-manager
|
|
19
|
+
|
|
20
|
+
> Per-project session persistence for AI CLI tools.
|
|
21
|
+
|
|
22
|
+
`ai-session-manager` adds automatic project-scoped resume behavior to supported AI CLIs so you can leave a project and come back without manually reopening the right conversation.
|
|
23
|
+
|
|
24
|
+
Works on **Linux**, **macOS**, and **Windows**.
|
|
25
|
+
|
|
26
|
+
> [!WARNING]
|
|
27
|
+
> This tool renames installed CLI binaries and replaces them with wrapper scripts. Use it at your own risk, and make sure you understand how to restore the original binaries with `ai-session-manager teardown`.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Why
|
|
32
|
+
|
|
33
|
+
Most AI CLIs support sessions, but they do not all resume the same way and they do not all make project-scoped resume automatic. This project adds one consistent wrapper layer across tools.
|
|
34
|
+
|
|
35
|
+
Today it supports:
|
|
36
|
+
|
|
37
|
+
| Tool | Auto-resume behavior |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `agy` | After first run, launches `agy -c` in the same project |
|
|
40
|
+
| `copilot` | Stores a stable session UUID and launches `copilot --session-id <uuid>` |
|
|
41
|
+
| `claude` | After first run, launches `claude -c` in the same project |
|
|
42
|
+
| `gemini` | After first run, launches `gemini --resume` in the same project |
|
|
43
|
+
| `codex` | After first run, launches `codex resume --last` in the same project |
|
|
44
|
+
|
|
45
|
+
Project-scoped state lives in:
|
|
46
|
+
|
|
47
|
+
- `.ai-session-manager/` inside the project root, whether it is a git repo or a plain folder
|
|
48
|
+
|
|
49
|
+
Existing state is still recognized and migrated from:
|
|
50
|
+
|
|
51
|
+
- `.git/ai-session-manager/`
|
|
52
|
+
|
|
53
|
+
Copilot legacy state is also recognized and migrated from:
|
|
54
|
+
|
|
55
|
+
- `.git/copilot-session`
|
|
56
|
+
- `.copilot-session`
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Requirements
|
|
61
|
+
|
|
62
|
+
- Python 3.8+
|
|
63
|
+
- One or more supported CLIs installed: Antigravity, Copilot, Claude Code, Gemini CLI, or Codex CLI
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Installation
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pip install --editable ~/ai-session-manager
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Once published:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install ai-session-manager
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Setup
|
|
82
|
+
|
|
83
|
+
Install wrappers for every supported tool found in `PATH`:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
ai-session-manager setup
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Or target specific tools:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
ai-session-manager setup agy copilot claude gemini codex
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Each selected binary is renamed to `<tool>-real` and replaced with a thin Python wrapper. From that point on, keep using the original command name.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## Usage
|
|
100
|
+
|
|
101
|
+
### Copilot
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
cd ~/my-project
|
|
105
|
+
copilot
|
|
106
|
+
# [ai-session-manager] New session 4f1a2b3c-... (my-project)
|
|
107
|
+
|
|
108
|
+
cd ~/my-project
|
|
109
|
+
copilot
|
|
110
|
+
# [ai-session-manager] Resuming session 4f1a2b3c-... (my-project)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### AGY / Claude / Gemini / Codex
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
agy
|
|
117
|
+
# [ai-session-manager] Starting new Antigravity CLI session (my-project)
|
|
118
|
+
agy
|
|
119
|
+
# [ai-session-manager] Resuming latest Antigravity CLI session (my-project)
|
|
120
|
+
|
|
121
|
+
claude
|
|
122
|
+
# [ai-session-manager] Starting new Claude Code session (my-project)
|
|
123
|
+
claude
|
|
124
|
+
# [ai-session-manager] Resuming latest Claude Code session (my-project)
|
|
125
|
+
|
|
126
|
+
gemini
|
|
127
|
+
# [ai-session-manager] Starting new Gemini CLI session (my-project)
|
|
128
|
+
gemini
|
|
129
|
+
# [ai-session-manager] Resuming latest Gemini CLI session (my-project)
|
|
130
|
+
|
|
131
|
+
codex
|
|
132
|
+
# [ai-session-manager] Starting new Codex session (my-project)
|
|
133
|
+
codex
|
|
134
|
+
# [ai-session-manager] Resuming latest Codex session (my-project)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Supported tools are still fully usable with their own native session commands. If you pass an explicit resume or session-management flag/subcommand, the wrapper gets out of the way.
|
|
138
|
+
|
|
139
|
+
For example:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
agy --conversation 123e4567-e89b-12d3-a456-426614174000
|
|
143
|
+
copilot --resume
|
|
144
|
+
claude -r my-session
|
|
145
|
+
gemini --list-sessions
|
|
146
|
+
codex resume --last
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Start fresh in the current project
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
ai-session-manager reset
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Or reset one tool only:
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
ai-session-manager reset claude
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Commands
|
|
164
|
+
|
|
165
|
+
| Command | Description |
|
|
166
|
+
|---|---|
|
|
167
|
+
| `ai-session-manager setup [tools...]` | Install wrappers for all detected or selected tools |
|
|
168
|
+
| `ai-session-manager teardown [tools...]` | Remove wrappers and restore original binaries |
|
|
169
|
+
| `ai-session-manager status [tools...]` | Show platform, binary paths, and state files |
|
|
170
|
+
| `ai-session-manager reset [tools...]` | Delete persisted wrapper state for the current project |
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## State layout
|
|
175
|
+
|
|
176
|
+
```text
|
|
177
|
+
project root:
|
|
178
|
+
.ai-session-manager/
|
|
179
|
+
copilot.json
|
|
180
|
+
claude.json
|
|
181
|
+
codex.json
|
|
182
|
+
gemini.json
|
|
183
|
+
agy.json
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Copilot stores a generated UUID in its state file. The other tools use the file as an on/off marker that tells the wrapper to invoke the tool's native resume-latest behavior on future launches.
|
|
187
|
+
|
|
188
|
+
### Platform details
|
|
189
|
+
|
|
190
|
+
| | Linux | macOS | Windows |
|
|
191
|
+
|---|---|---|---|
|
|
192
|
+
| Wrapper file | tool name (shebang script) | tool name (shebang script) | tool `.cmd` wrapper |
|
|
193
|
+
| Real binary | `<tool>-real` | `<tool>-real` | `<tool>-real.exe` or `<tool>-real.cmd` |
|
|
194
|
+
| Process launch | `os.execv` (true replace) | `os.execv` (true replace) | `subprocess` + exit code |
|
|
195
|
+
|
|
196
|
+
### Project structure
|
|
197
|
+
|
|
198
|
+
```text
|
|
199
|
+
ai-session-manager/
|
|
200
|
+
├── pyproject.toml
|
|
201
|
+
├── README.md
|
|
202
|
+
└── src/
|
|
203
|
+
├── ai_session_manager/
|
|
204
|
+
│ ├── wrapper.py
|
|
205
|
+
│ └── cli.py
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## Reinstalling a tool CLI
|
|
211
|
+
|
|
212
|
+
If a wrapped tool is manually reinstalled and overwrites the wrapper, run setup again:
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
ai-session-manager setup copilot
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## Uninstall
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
ai-session-manager teardown
|
|
224
|
+
pip uninstall ai-session-manager
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
## License
|
|
230
|
+
|
|
231
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
ai_session_manager/__init__.py,sha256=yO7GoVFttbdGZxf5qcy1BGl-6re28L3aWEpLHpH9GFo,34
|
|
2
|
+
ai_session_manager/cli.py,sha256=0wRXtO3QHKXm5jjz5wDVa6TAr6LmdKPIJXkTg__lXMg,9279
|
|
3
|
+
ai_session_manager/wrapper.py,sha256=6z4cUJobhpt3kQLYPBF1m0Q8qpnxt1kUuqUUy6BXBlg,9978
|
|
4
|
+
ai_session_manager-0.1.3.dist-info/licenses/LICENSE,sha256=uzvGcd0cdmgZarGW6VSFAeXEGuZpgCOzYE_dZtaM9oo,1068
|
|
5
|
+
ai_session_manager-0.1.3.dist-info/METADATA,sha256=y9xrk5Nxyzgtg5zPo7jc1dNsY_BnvAs_5ru5I0PFRdU,5782
|
|
6
|
+
ai_session_manager-0.1.3.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
+
ai_session_manager-0.1.3.dist-info/entry_points.txt,sha256=9uYte5zEfbPt8tyDg2rDHyu7SwalIGad2XaNgwUgUfM,67
|
|
8
|
+
ai_session_manager-0.1.3.dist-info/top_level.txt,sha256=KghIz3mtpyywCdG0DdTwU97zI_A_YbI43mSE9uQRlHI,19
|
|
9
|
+
ai_session_manager-0.1.3.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fatih Tekin
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ai_session_manager
|