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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ai-session-manager = ai_session_manager.cli:main
@@ -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