sede 0.1.4__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.
- sede/__init__.py +10 -0
- sede/__main__.py +6 -0
- sede/cli.py +637 -0
- sede/discovery.py +255 -0
- sede/models.py +18 -0
- sede-0.1.4.dist-info/METADATA +110 -0
- sede-0.1.4.dist-info/RECORD +11 -0
- sede-0.1.4.dist-info/WHEEL +5 -0
- sede-0.1.4.dist-info/entry_points.txt +2 -0
- sede-0.1.4.dist-info/licenses/LICENSE +21 -0
- sede-0.1.4.dist-info/top_level.txt +1 -0
sede/__init__.py
ADDED
sede/__main__.py
ADDED
sede/cli.py
ADDED
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import timezone
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union, cast
|
|
6
|
+
|
|
7
|
+
import questionary
|
|
8
|
+
import typer
|
|
9
|
+
from prompt_toolkit.application import Application
|
|
10
|
+
from prompt_toolkit.filters import Always, IsDone
|
|
11
|
+
from prompt_toolkit.formatted_text import FormattedText
|
|
12
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
13
|
+
from prompt_toolkit.keys import Keys
|
|
14
|
+
from prompt_toolkit.layout import ConditionalContainer, HSplit, Layout, Window
|
|
15
|
+
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
16
|
+
from prompt_toolkit.layout.dimension import LayoutDimension
|
|
17
|
+
from questionary import Choice
|
|
18
|
+
from questionary.constants import INVALID_INPUT
|
|
19
|
+
from questionary.prompts import common as questionary_common
|
|
20
|
+
from questionary.prompts.common import InquirerControl, Separator
|
|
21
|
+
from rich.console import Console
|
|
22
|
+
|
|
23
|
+
from . import __version__
|
|
24
|
+
from .discovery import delete_session, discover_sessions
|
|
25
|
+
from .models import SessionRecord
|
|
26
|
+
|
|
27
|
+
app = typer.Typer(
|
|
28
|
+
add_completion=False,
|
|
29
|
+
add_help_option=False,
|
|
30
|
+
no_args_is_help=False,
|
|
31
|
+
help="Session deleter for coding assistants",
|
|
32
|
+
)
|
|
33
|
+
console = Console()
|
|
34
|
+
|
|
35
|
+
ValidateSelectionFn = Callable[[List[str]], Union[bool, str]]
|
|
36
|
+
FormattedChoiceTitle = List[Tuple[str, str]]
|
|
37
|
+
|
|
38
|
+
_PROVIDER_LABELS = {
|
|
39
|
+
"claude": "Claude Code",
|
|
40
|
+
"copilot": "GitHub Copilot",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
_BACK_SENTINEL = "__sede_back__"
|
|
44
|
+
|
|
45
|
+
_APP_BANNER = r"""
|
|
46
|
+
_
|
|
47
|
+
___ ___ __| | ___
|
|
48
|
+
/ __|/ _ \/ _` |/ _ \
|
|
49
|
+
\__ \ __/ (_| | __/
|
|
50
|
+
|___/\___|\__,_|\___|
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
_HELP_COMMANDS = [
|
|
54
|
+
("sede", "Main menu"),
|
|
55
|
+
("sede --help", "Show help"),
|
|
56
|
+
("sede --version", "Show version"),
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
_HELP_OPTIONS = [
|
|
60
|
+
("--assistant, -a TEXT", "Assistant to manage: claude or copilot"),
|
|
61
|
+
("--yes, -y", "Skip confirmation prompt before deletion"),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
_HELP_COL_WIDTH = 28
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _print_help_screen() -> None:
|
|
68
|
+
"""Prints the branded help screen with command reference."""
|
|
69
|
+
console.print(f"[bold cyan]{_APP_BANNER}[/bold cyan]")
|
|
70
|
+
console.print(f"[bold]Session Deleter v{__version__}[/bold]")
|
|
71
|
+
console.print("[blue]https://github.com/ilypopv/sede/[/blue]")
|
|
72
|
+
console.print()
|
|
73
|
+
console.print("[bold]COMMANDS[/bold]")
|
|
74
|
+
for cmd, desc in _HELP_COMMANDS:
|
|
75
|
+
console.print(f" [cyan]{cmd:<{_HELP_COL_WIDTH}}[/cyan]{desc}")
|
|
76
|
+
console.print()
|
|
77
|
+
console.print("[bold]OPTIONS[/bold]")
|
|
78
|
+
for opt, desc in _HELP_OPTIONS:
|
|
79
|
+
console.print(f" [cyan]{opt:<{_HELP_COL_WIDTH}}[/cyan]{desc}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _create_inquirer_layout_with_footer(
|
|
83
|
+
control: InquirerControl,
|
|
84
|
+
get_prompt_tokens: Callable[[], List[Tuple[str, str]]],
|
|
85
|
+
footer: str,
|
|
86
|
+
) -> Layout:
|
|
87
|
+
"""Creates the default questionary layout with an external footer row."""
|
|
88
|
+
|
|
89
|
+
layout = questionary_common.create_inquirer_layout(control, get_prompt_tokens)
|
|
90
|
+
if not isinstance(layout.container, HSplit):
|
|
91
|
+
return layout
|
|
92
|
+
|
|
93
|
+
for child in layout.container.children:
|
|
94
|
+
if (
|
|
95
|
+
isinstance(child, ConditionalContainer)
|
|
96
|
+
and isinstance(child.content, Window)
|
|
97
|
+
and child.content.content is control
|
|
98
|
+
):
|
|
99
|
+
child.content.dont_extend_height = Always()
|
|
100
|
+
break
|
|
101
|
+
|
|
102
|
+
footer_control = FormattedTextControl(
|
|
103
|
+
text=lambda: [("", "\n"), ("class:text", footer)],
|
|
104
|
+
)
|
|
105
|
+
layout.container.children.append(
|
|
106
|
+
ConditionalContainer(
|
|
107
|
+
Window(
|
|
108
|
+
height=LayoutDimension.exact(2),
|
|
109
|
+
content=footer_control,
|
|
110
|
+
),
|
|
111
|
+
filter=~IsDone(),
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
return layout
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@app.command()
|
|
118
|
+
def main(
|
|
119
|
+
assistant: Optional[str] = typer.Option(
|
|
120
|
+
None,
|
|
121
|
+
"--assistant",
|
|
122
|
+
"-a",
|
|
123
|
+
help="Assistant to manage: claude or copilot",
|
|
124
|
+
),
|
|
125
|
+
yes: bool = typer.Option(
|
|
126
|
+
False,
|
|
127
|
+
"--yes",
|
|
128
|
+
"-y",
|
|
129
|
+
help="Skip confirmation prompt before deletion",
|
|
130
|
+
),
|
|
131
|
+
show_help: bool = typer.Option(
|
|
132
|
+
False,
|
|
133
|
+
"--help",
|
|
134
|
+
"-h",
|
|
135
|
+
is_eager=True,
|
|
136
|
+
help="Show help and exit",
|
|
137
|
+
),
|
|
138
|
+
show_version: bool = typer.Option(
|
|
139
|
+
False,
|
|
140
|
+
"--version",
|
|
141
|
+
is_eager=True,
|
|
142
|
+
help="Show version and exit",
|
|
143
|
+
),
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Application entrypoint.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
help: Whether to show the help screen and exit.
|
|
149
|
+
version: Whether to show the version and exit.
|
|
150
|
+
assistant: Optional fixed assistant provider from CLI flags.
|
|
151
|
+
yes: Whether to skip the deletion confirmation prompt.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
if show_help:
|
|
155
|
+
_print_help_screen()
|
|
156
|
+
raise typer.Exit()
|
|
157
|
+
|
|
158
|
+
if show_version:
|
|
159
|
+
console.print(f"sede v{__version__}")
|
|
160
|
+
raise typer.Exit()
|
|
161
|
+
|
|
162
|
+
if assistant:
|
|
163
|
+
provider = _pick_provider(assistant)
|
|
164
|
+
if provider is None:
|
|
165
|
+
raise typer.Exit(code=1)
|
|
166
|
+
_run_provider_flow(provider, yes)
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
while True:
|
|
170
|
+
provider = _pick_provider(None)
|
|
171
|
+
if provider is None:
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
should_back = _run_provider_flow(provider, yes)
|
|
175
|
+
if not should_back:
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _pick_provider(cli_provider: Optional[str]) -> Optional[str]:
|
|
180
|
+
"""Resolves provider from CLI option or interactive menu selection."""
|
|
181
|
+
|
|
182
|
+
if cli_provider:
|
|
183
|
+
normalized = cli_provider.strip().lower()
|
|
184
|
+
if normalized in _PROVIDER_LABELS:
|
|
185
|
+
return normalized
|
|
186
|
+
console.print("[red]Unknown assistant. Use claude or copilot.[/red]")
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
console.clear()
|
|
190
|
+
_print_home_screen()
|
|
191
|
+
return _provider_menu_with_quit()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _print_home_screen() -> None:
|
|
195
|
+
"""Renders the branded home screen banner and project info."""
|
|
196
|
+
|
|
197
|
+
console.print(f"[bold cyan]{_APP_BANNER}[/bold cyan]")
|
|
198
|
+
console.print(f"[bold]Session Deleter v{__version__}[/bold]")
|
|
199
|
+
console.print("[blue]https://github.com/ilypopv/sede/[/blue]")
|
|
200
|
+
console.print(
|
|
201
|
+
"[dim]Deep clean archived coding assistant sessions from your device.[/dim]"
|
|
202
|
+
)
|
|
203
|
+
console.print()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _run_provider_flow(provider: str, yes: bool) -> bool:
|
|
207
|
+
"""Runs one provider-specific selection and deletion flow.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
provider: Provider key selected by user.
|
|
211
|
+
yes: Whether confirmation is skipped.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
True when caller should navigate back to provider menu, else False.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
sessions = discover_sessions(provider)
|
|
218
|
+
_print_provider_header(provider, sessions)
|
|
219
|
+
|
|
220
|
+
if not sessions:
|
|
221
|
+
console.print(
|
|
222
|
+
f"[yellow] No sessions found for {_PROVIDER_LABELS[provider]}.[/yellow]"
|
|
223
|
+
)
|
|
224
|
+
console.print()
|
|
225
|
+
_wait_for_any_key(" Press any key to go back... ")
|
|
226
|
+
return True
|
|
227
|
+
|
|
228
|
+
selected = _pick_sessions(sessions)
|
|
229
|
+
if selected == _BACK_SENTINEL:
|
|
230
|
+
return True
|
|
231
|
+
|
|
232
|
+
if not selected:
|
|
233
|
+
console.print("[yellow]Nothing selected. Exit.[/yellow]")
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
chosen_sessions = cast(List[SessionRecord], selected)
|
|
237
|
+
_print_selected_summary(chosen_sessions)
|
|
238
|
+
|
|
239
|
+
if not yes:
|
|
240
|
+
confirmed = questionary.confirm(
|
|
241
|
+
f"Delete {len(selected)} session(s)? This operation cannot be undone.",
|
|
242
|
+
default=False,
|
|
243
|
+
).ask()
|
|
244
|
+
if not confirmed:
|
|
245
|
+
console.print("[yellow]Deletion cancelled.[/yellow]")
|
|
246
|
+
return False
|
|
247
|
+
|
|
248
|
+
deleted = 0
|
|
249
|
+
failed: List[str] = []
|
|
250
|
+
for session in chosen_sessions:
|
|
251
|
+
try:
|
|
252
|
+
delete_session(session)
|
|
253
|
+
deleted += 1
|
|
254
|
+
except Exception as exc: # noqa: BLE001
|
|
255
|
+
failed.append(f"{session.session_id}: {exc}")
|
|
256
|
+
|
|
257
|
+
if deleted:
|
|
258
|
+
console.print(f"[green]Deleted {deleted} session(s).[/green]")
|
|
259
|
+
if failed:
|
|
260
|
+
console.print("[red]Failed to delete:[/red]")
|
|
261
|
+
for row in failed:
|
|
262
|
+
console.print(f" - {row}")
|
|
263
|
+
|
|
264
|
+
return False
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _print_provider_header(provider: str, sessions: List[SessionRecord]) -> None:
|
|
268
|
+
"""Renders the secondary screen header shared by empty and loaded states.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
provider: Provider key whose sessions are being displayed.
|
|
272
|
+
sessions: Discovered sessions for the provider (may be empty).
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
total_size = sum(session.size_bytes for session in sessions)
|
|
276
|
+
console.print(f"[bold] Available sessions: {_PROVIDER_LABELS[provider]}[/bold]")
|
|
277
|
+
console.print(
|
|
278
|
+
f"[dim] {len(sessions)} session(s) loaded. "
|
|
279
|
+
f"Total size: {_human_size(total_size)}.[/dim]"
|
|
280
|
+
)
|
|
281
|
+
console.print()
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _pick_sessions(sessions: List[SessionRecord]) -> Union[List[SessionRecord], str]:
|
|
285
|
+
"""Prompts user to choose one or more sessions for deletion."""
|
|
286
|
+
|
|
287
|
+
mapping: Dict[str, SessionRecord] = {
|
|
288
|
+
session.session_id: session for session in sessions
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
choices: List[Union[Choice, Separator]] = [
|
|
292
|
+
Choice(
|
|
293
|
+
title=_session_choice_title(session),
|
|
294
|
+
value=session.session_id,
|
|
295
|
+
)
|
|
296
|
+
for session in sessions
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
selected_ids = _checkbox_with_back(
|
|
300
|
+
"Choose sessions to delete",
|
|
301
|
+
choices=choices,
|
|
302
|
+
footer=(
|
|
303
|
+
"↑↓ Navigate | ← Back | Space Select | A Toggle All | Enter Delete | Ctrl+C / Q Quit"
|
|
304
|
+
),
|
|
305
|
+
validate=lambda selected: True if selected else "Select at least one session",
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
if selected_ids == _BACK_SENTINEL:
|
|
309
|
+
return _BACK_SENTINEL
|
|
310
|
+
|
|
311
|
+
if not selected_ids:
|
|
312
|
+
return []
|
|
313
|
+
|
|
314
|
+
return [mapping[item] for item in selected_ids if item in mapping]
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _print_selected_summary(sessions: List[SessionRecord]) -> None:
|
|
318
|
+
"""Prints a compact summary of selected sessions before deletion."""
|
|
319
|
+
|
|
320
|
+
console.print("[bold]Selected for deletion:[/bold]")
|
|
321
|
+
for session in sessions:
|
|
322
|
+
console.print(
|
|
323
|
+
f"- {session.title}\n"
|
|
324
|
+
f" {session.project_path}\n"
|
|
325
|
+
f" {_human_size(session.size_bytes)} | "
|
|
326
|
+
f"{session.updated_at.astimezone(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}"
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _session_choice_title(session: SessionRecord) -> FormattedChoiceTitle:
|
|
331
|
+
"""Builds a formatted multi-line title row for a session choice item."""
|
|
332
|
+
|
|
333
|
+
storage_hint = _session_storage_hint(session)
|
|
334
|
+
return [
|
|
335
|
+
("", f"{session.title}\n"),
|
|
336
|
+
("", f" {session.project_path}\n"),
|
|
337
|
+
("fg:#7a7a7a", f" {storage_hint}\n"),
|
|
338
|
+
(
|
|
339
|
+
"",
|
|
340
|
+
f" {_human_size(session.size_bytes)} | "
|
|
341
|
+
f"{session.updated_at.astimezone(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}",
|
|
342
|
+
),
|
|
343
|
+
]
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _session_storage_hint(session: SessionRecord) -> str:
|
|
347
|
+
"""Returns a display-friendly storage path for a session."""
|
|
348
|
+
|
|
349
|
+
path_for_display = session.storage_path
|
|
350
|
+
if session.provider == "claude":
|
|
351
|
+
path_for_display = session.storage_path.parent
|
|
352
|
+
|
|
353
|
+
full_path = str(path_for_display)
|
|
354
|
+
home_path = str(Path.home())
|
|
355
|
+
if full_path.startswith(home_path):
|
|
356
|
+
return full_path.replace(home_path, "~", 1)
|
|
357
|
+
return full_path
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _wait_for_any_key(message: str) -> None: # pragma: no cover
|
|
361
|
+
"""Blocks until the user presses any key, including arrow keys.
|
|
362
|
+
|
|
363
|
+
Uses a bare prompt_toolkit ``Application`` (no input buffer) so that
|
|
364
|
+
every keypress, arrow keys included, falls through to our ``Keys.Any``
|
|
365
|
+
binding instead of being consumed by default buffer/history bindings.
|
|
366
|
+
|
|
367
|
+
Note: the binding intentionally omits ``eager=True``. Eager bindings
|
|
368
|
+
are matched before prompt_toolkit's built-in cursor-position-response
|
|
369
|
+
(CPR) handler, which would otherwise cause the terminal's automatic CPR
|
|
370
|
+
reply (sent moments after the screen renders) to be misread as a user
|
|
371
|
+
keypress and exit the screen on its own.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
message: Prompt text shown while waiting for input.
|
|
375
|
+
"""
|
|
376
|
+
|
|
377
|
+
control = FormattedTextControl(text=[("class:question", message)])
|
|
378
|
+
layout = Layout(Window(content=control))
|
|
379
|
+
|
|
380
|
+
bindings = KeyBindings()
|
|
381
|
+
|
|
382
|
+
@bindings.add(Keys.Any)
|
|
383
|
+
def _continue(event: Any) -> None:
|
|
384
|
+
event.app.exit(result=None)
|
|
385
|
+
|
|
386
|
+
application = Application(layout=layout, key_bindings=bindings, style=None)
|
|
387
|
+
|
|
388
|
+
try:
|
|
389
|
+
application.run()
|
|
390
|
+
except KeyboardInterrupt:
|
|
391
|
+
return
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _compute_toggled_select_all(
|
|
395
|
+
choices: Sequence[Union[Choice, Separator]],
|
|
396
|
+
selected_options: List[Any],
|
|
397
|
+
) -> List[Any]:
|
|
398
|
+
"""Computes the next selection state for the "select/deselect all" key.
|
|
399
|
+
|
|
400
|
+
Selects every selectable choice when not all of them are currently
|
|
401
|
+
selected; deselects everything when they already are, so a single key
|
|
402
|
+
toggles between "select all" and "clear selection".
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
choices: All choices shown in the checkbox prompt, including
|
|
406
|
+
separators and disabled items.
|
|
407
|
+
selected_options: Currently selected choice values.
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
The new list of selected values.
|
|
411
|
+
"""
|
|
412
|
+
|
|
413
|
+
selectable_values = [
|
|
414
|
+
item.value
|
|
415
|
+
for item in choices
|
|
416
|
+
if not isinstance(item, Separator) and not item.disabled
|
|
417
|
+
]
|
|
418
|
+
all_selected = bool(selectable_values) and all(
|
|
419
|
+
value in selected_options for value in selectable_values
|
|
420
|
+
)
|
|
421
|
+
return [] if all_selected else selectable_values
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _checkbox_with_back(
|
|
425
|
+
message: str,
|
|
426
|
+
choices: Sequence[Union[Choice, Separator]],
|
|
427
|
+
footer: str,
|
|
428
|
+
validate: ValidateSelectionFn,
|
|
429
|
+
) -> Union[List[str], str, None]: # pragma: no cover
|
|
430
|
+
"""Runs custom checkbox prompt with explicit back and quit controls."""
|
|
431
|
+
|
|
432
|
+
if not callable(validate):
|
|
433
|
+
raise ValueError("validate must be callable")
|
|
434
|
+
|
|
435
|
+
control = InquirerControl(choices)
|
|
436
|
+
|
|
437
|
+
def get_prompt_tokens() -> List[Tuple[str, str]]:
|
|
438
|
+
if control.is_answered:
|
|
439
|
+
return [("class:answer", "done")]
|
|
440
|
+
return [("class:question", f" {message} ")]
|
|
441
|
+
|
|
442
|
+
def get_selected_values() -> List[str]:
|
|
443
|
+
selected_values = [choice.value for choice in control.get_selected_values()]
|
|
444
|
+
return [value for value in selected_values if isinstance(value, str)]
|
|
445
|
+
|
|
446
|
+
def perform_validation(selected_values: List[str]) -> bool:
|
|
447
|
+
verdict = validate(selected_values)
|
|
448
|
+
valid = verdict is True
|
|
449
|
+
|
|
450
|
+
if not valid:
|
|
451
|
+
if verdict is False:
|
|
452
|
+
error_text = INVALID_INPUT
|
|
453
|
+
else:
|
|
454
|
+
error_text = str(verdict)
|
|
455
|
+
error_message = FormattedText([("class:validation-toolbar", error_text)])
|
|
456
|
+
control.error_message = (
|
|
457
|
+
error_message if not valid and control.submission_attempted else None
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
return valid
|
|
461
|
+
|
|
462
|
+
layout = _create_inquirer_layout_with_footer(
|
|
463
|
+
control,
|
|
464
|
+
get_prompt_tokens,
|
|
465
|
+
footer,
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
bindings = KeyBindings()
|
|
469
|
+
|
|
470
|
+
@bindings.add(Keys.ControlQ, eager=True)
|
|
471
|
+
@bindings.add(Keys.ControlC, eager=True)
|
|
472
|
+
def _abort(event):
|
|
473
|
+
event.app.exit(exception=KeyboardInterrupt, style="class:aborting")
|
|
474
|
+
|
|
475
|
+
@bindings.add("q", eager=True)
|
|
476
|
+
@bindings.add("Q", eager=True)
|
|
477
|
+
def _quit(event):
|
|
478
|
+
control.is_answered = True
|
|
479
|
+
event.app.exit(result=None)
|
|
480
|
+
|
|
481
|
+
@bindings.add(" ", eager=True)
|
|
482
|
+
def _toggle(_event):
|
|
483
|
+
pointed_choice = control.get_pointed_at().value
|
|
484
|
+
if pointed_choice in control.selected_options:
|
|
485
|
+
control.selected_options.remove(pointed_choice)
|
|
486
|
+
else:
|
|
487
|
+
control.selected_options.append(pointed_choice)
|
|
488
|
+
perform_validation(get_selected_values())
|
|
489
|
+
|
|
490
|
+
@bindings.add("a", eager=True)
|
|
491
|
+
def _toggle_select_all(_event):
|
|
492
|
+
control.selected_options = _compute_toggled_select_all(
|
|
493
|
+
control.choices, control.selected_options
|
|
494
|
+
)
|
|
495
|
+
perform_validation(get_selected_values())
|
|
496
|
+
|
|
497
|
+
def _move_cursor_down(_event):
|
|
498
|
+
control.select_next()
|
|
499
|
+
while not control.is_selection_valid():
|
|
500
|
+
control.select_next()
|
|
501
|
+
|
|
502
|
+
def _move_cursor_up(_event):
|
|
503
|
+
control.select_previous()
|
|
504
|
+
while not control.is_selection_valid():
|
|
505
|
+
control.select_previous()
|
|
506
|
+
|
|
507
|
+
@bindings.add(Keys.Down, eager=True)
|
|
508
|
+
def _down(event: Any) -> None:
|
|
509
|
+
_move_cursor_down(event)
|
|
510
|
+
|
|
511
|
+
@bindings.add(Keys.Up, eager=True)
|
|
512
|
+
def _up(event: Any) -> None:
|
|
513
|
+
_move_cursor_up(event)
|
|
514
|
+
|
|
515
|
+
@bindings.add(Keys.Left, eager=True)
|
|
516
|
+
def _go_back(event: Any) -> None:
|
|
517
|
+
control.is_answered = True
|
|
518
|
+
event.app.exit(result=_BACK_SENTINEL)
|
|
519
|
+
|
|
520
|
+
@bindings.add(Keys.ControlM, eager=True)
|
|
521
|
+
def _submit(event: Any) -> None:
|
|
522
|
+
selected_values = get_selected_values()
|
|
523
|
+
control.submission_attempted = True
|
|
524
|
+
if perform_validation(selected_values):
|
|
525
|
+
control.is_answered = True
|
|
526
|
+
event.app.exit(result=selected_values)
|
|
527
|
+
|
|
528
|
+
@bindings.add(Keys.Any)
|
|
529
|
+
def _other(_event: Any) -> None:
|
|
530
|
+
return None
|
|
531
|
+
|
|
532
|
+
question = Application(
|
|
533
|
+
layout=Layout(layout.container) if isinstance(layout, Layout) else layout,
|
|
534
|
+
key_bindings=bindings,
|
|
535
|
+
style=None,
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
try:
|
|
539
|
+
return question.run()
|
|
540
|
+
except KeyboardInterrupt:
|
|
541
|
+
return None
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _provider_menu_with_quit() -> Optional[str]: # pragma: no cover
|
|
545
|
+
"""Shows provider selection menu with keyboard shortcuts for quit/select."""
|
|
546
|
+
|
|
547
|
+
choices: List[Choice] = [
|
|
548
|
+
Choice(
|
|
549
|
+
"1. Claude Code\n Delete archived Claude Code sessions\n",
|
|
550
|
+
value="claude",
|
|
551
|
+
),
|
|
552
|
+
Choice(
|
|
553
|
+
"2. GitHub Copilot\n Delete archived Copilot sessions",
|
|
554
|
+
value="copilot",
|
|
555
|
+
),
|
|
556
|
+
]
|
|
557
|
+
|
|
558
|
+
control = InquirerControl(choices, pointer="➤")
|
|
559
|
+
|
|
560
|
+
def get_prompt_tokens() -> List[Tuple[str, str]]:
|
|
561
|
+
return [("class:question", " Choose coding assistant ")]
|
|
562
|
+
|
|
563
|
+
layout = _create_inquirer_layout_with_footer(
|
|
564
|
+
control,
|
|
565
|
+
get_prompt_tokens,
|
|
566
|
+
"↑↓ Navigate | Enter / → Select | Ctrl+C / Q Quit",
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
bindings = KeyBindings()
|
|
570
|
+
|
|
571
|
+
@bindings.add(Keys.ControlC, eager=True)
|
|
572
|
+
@bindings.add(Keys.ControlQ, eager=True)
|
|
573
|
+
def _abort(event):
|
|
574
|
+
event.app.exit(result=None)
|
|
575
|
+
|
|
576
|
+
@bindings.add("q", eager=True)
|
|
577
|
+
@bindings.add("Q", eager=True)
|
|
578
|
+
def _quit(event):
|
|
579
|
+
event.app.exit(result=None)
|
|
580
|
+
|
|
581
|
+
def _move_cursor_down(_event):
|
|
582
|
+
control.select_next()
|
|
583
|
+
while not control.is_selection_valid():
|
|
584
|
+
control.select_next()
|
|
585
|
+
|
|
586
|
+
def _move_cursor_up(_event):
|
|
587
|
+
control.select_previous()
|
|
588
|
+
while not control.is_selection_valid():
|
|
589
|
+
control.select_previous()
|
|
590
|
+
|
|
591
|
+
@bindings.add(Keys.Down, eager=True)
|
|
592
|
+
def _down(event: Any) -> None:
|
|
593
|
+
_move_cursor_down(event)
|
|
594
|
+
|
|
595
|
+
@bindings.add(Keys.Up, eager=True)
|
|
596
|
+
def _up(event: Any) -> None:
|
|
597
|
+
_move_cursor_up(event)
|
|
598
|
+
|
|
599
|
+
@bindings.add(Keys.ControlM, eager=True)
|
|
600
|
+
def _submit(event: Any) -> None:
|
|
601
|
+
pointed = control.get_pointed_at()
|
|
602
|
+
control.is_answered = True
|
|
603
|
+
event.app.exit(result=pointed.value if isinstance(pointed.value, str) else None)
|
|
604
|
+
|
|
605
|
+
@bindings.add(Keys.Right, eager=True)
|
|
606
|
+
def _submit_right(event: Any) -> None:
|
|
607
|
+
pointed = control.get_pointed_at()
|
|
608
|
+
control.is_answered = True
|
|
609
|
+
event.app.exit(result=pointed.value if isinstance(pointed.value, str) else None)
|
|
610
|
+
|
|
611
|
+
@bindings.add(Keys.Any)
|
|
612
|
+
def _other(_event: Any) -> None:
|
|
613
|
+
return None
|
|
614
|
+
|
|
615
|
+
question = Application(
|
|
616
|
+
layout=Layout(layout.container) if isinstance(layout, Layout) else layout,
|
|
617
|
+
key_bindings=bindings,
|
|
618
|
+
style=None,
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
return question.run()
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _human_size(size_bytes: int) -> str:
|
|
625
|
+
"""Formats byte count into human-readable units."""
|
|
626
|
+
|
|
627
|
+
value = float(size_bytes)
|
|
628
|
+
units = ["B", "KB", "MB", "GB", "TB"]
|
|
629
|
+
unit_index = 0
|
|
630
|
+
|
|
631
|
+
while value >= 1024 and unit_index < len(units) - 1:
|
|
632
|
+
value /= 1024
|
|
633
|
+
unit_index += 1
|
|
634
|
+
|
|
635
|
+
if unit_index == 0:
|
|
636
|
+
return f"{int(value)} {units[unit_index]}"
|
|
637
|
+
return f"{value:.1f} {units[unit_index]}"
|
sede/discovery.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
from .models import SessionRecord
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def discover_sessions(provider: str) -> List[SessionRecord]:
|
|
13
|
+
"""Discovers stored sessions for the requested assistant provider.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
provider: Assistant provider key, e.g. "claude" or "copilot".
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
A list of discovered sessions sorted by last update time, newest first.
|
|
20
|
+
|
|
21
|
+
Raises:
|
|
22
|
+
ValueError: If the provider is not supported.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
if provider == "claude":
|
|
26
|
+
return _discover_claude_sessions()
|
|
27
|
+
if provider == "copilot":
|
|
28
|
+
return _discover_copilot_sessions()
|
|
29
|
+
raise ValueError(f"Unsupported provider: {provider}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def delete_session(session: SessionRecord) -> None:
|
|
33
|
+
"""Deletes the session from disk.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
session: Session descriptor containing provider and storage path.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
ValueError: If the provider is not supported.
|
|
40
|
+
OSError: If filesystem deletion fails.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
if session.provider == "claude":
|
|
44
|
+
_delete_claude_session_file(session.storage_path)
|
|
45
|
+
return
|
|
46
|
+
if session.provider == "copilot":
|
|
47
|
+
shutil.rmtree(session.storage_path)
|
|
48
|
+
return
|
|
49
|
+
raise ValueError(f"Unsupported provider: {session.provider}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _delete_claude_session_file(session_file: Path) -> None:
|
|
53
|
+
"""Deletes a Claude session file and prunes empty parent directory.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
session_file: Path to the Claude session jsonl file.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
session_file.unlink(missing_ok=False)
|
|
60
|
+
|
|
61
|
+
parent_dir = session_file.parent
|
|
62
|
+
try:
|
|
63
|
+
if parent_dir.is_dir() and not any(parent_dir.iterdir()):
|
|
64
|
+
parent_dir.rmdir()
|
|
65
|
+
except OSError:
|
|
66
|
+
# Best-effort cleanup only; deletion of the selected session file has
|
|
67
|
+
# already succeeded.
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _discover_claude_sessions() -> List[SessionRecord]:
|
|
72
|
+
"""Discovers Claude sessions from ~/.claude/projects."""
|
|
73
|
+
|
|
74
|
+
root = Path.home() / ".claude" / "projects"
|
|
75
|
+
if not root.exists():
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
sessions: List[SessionRecord] = []
|
|
79
|
+
for jsonl_path in root.glob("*/*.jsonl"):
|
|
80
|
+
if not jsonl_path.is_file():
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
metadata = _read_claude_metadata(jsonl_path)
|
|
84
|
+
stat = jsonl_path.stat()
|
|
85
|
+
project_path = metadata.get("cwd") or _decode_claude_project_path(
|
|
86
|
+
jsonl_path.parent.name
|
|
87
|
+
)
|
|
88
|
+
title = metadata.get("title") or metadata.get("prompt") or jsonl_path.stem
|
|
89
|
+
|
|
90
|
+
sessions.append(
|
|
91
|
+
SessionRecord(
|
|
92
|
+
provider="claude",
|
|
93
|
+
session_id=jsonl_path.stem,
|
|
94
|
+
title=_shorten(title, 70),
|
|
95
|
+
project_path=project_path,
|
|
96
|
+
size_bytes=stat.st_size,
|
|
97
|
+
updated_at=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
|
|
98
|
+
storage_path=jsonl_path,
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return sorted(sessions, key=lambda s: s.updated_at, reverse=True)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _discover_copilot_sessions() -> List[SessionRecord]:
|
|
106
|
+
"""Discovers Copilot sessions from ~/.copilot/session-state."""
|
|
107
|
+
|
|
108
|
+
root = Path.home() / ".copilot" / "session-state"
|
|
109
|
+
if not root.exists():
|
|
110
|
+
return []
|
|
111
|
+
|
|
112
|
+
sessions: List[SessionRecord] = []
|
|
113
|
+
for session_dir in root.iterdir():
|
|
114
|
+
if not session_dir.is_dir():
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
workspace_file = session_dir / "workspace.yaml"
|
|
118
|
+
metadata = _read_simple_yaml(workspace_file) if workspace_file.exists() else {}
|
|
119
|
+
|
|
120
|
+
updated_at = _parse_iso_dt(metadata.get("updated_at"))
|
|
121
|
+
if updated_at is None:
|
|
122
|
+
updated_at = datetime.fromtimestamp(
|
|
123
|
+
session_dir.stat().st_mtime, tz=timezone.utc
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
title = metadata.get("name") or session_dir.name
|
|
127
|
+
project_path = metadata.get("cwd") or "Unknown project"
|
|
128
|
+
|
|
129
|
+
sessions.append(
|
|
130
|
+
SessionRecord(
|
|
131
|
+
provider="copilot",
|
|
132
|
+
session_id=session_dir.name,
|
|
133
|
+
title=_shorten(title, 70),
|
|
134
|
+
project_path=project_path,
|
|
135
|
+
size_bytes=_directory_size_bytes(session_dir),
|
|
136
|
+
updated_at=updated_at,
|
|
137
|
+
storage_path=session_dir,
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return sorted(sessions, key=lambda s: s.updated_at, reverse=True)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _read_claude_metadata(jsonl_path: Path) -> Dict[str, str]:
|
|
145
|
+
"""Extracts useful metadata from a Claude jsonl session file.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
jsonl_path: Path to the Claude session jsonl file.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
A dictionary that may contain keys like "cwd" and "prompt".
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
result: Dict[str, str] = {}
|
|
155
|
+
|
|
156
|
+
with jsonl_path.open("r", encoding="utf-8") as f:
|
|
157
|
+
for idx, line in enumerate(f):
|
|
158
|
+
if idx > 250:
|
|
159
|
+
break
|
|
160
|
+
line = line.strip()
|
|
161
|
+
if not line:
|
|
162
|
+
continue
|
|
163
|
+
try:
|
|
164
|
+
payload = json.loads(line)
|
|
165
|
+
except json.JSONDecodeError:
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
cwd = payload.get("cwd")
|
|
169
|
+
if isinstance(cwd, str) and "cwd" not in result:
|
|
170
|
+
result["cwd"] = cwd
|
|
171
|
+
|
|
172
|
+
if payload.get("type") == "user":
|
|
173
|
+
message = payload.get("message", {})
|
|
174
|
+
if isinstance(message, dict):
|
|
175
|
+
content = message.get("content")
|
|
176
|
+
if isinstance(content, str) and content and "prompt" not in result:
|
|
177
|
+
result["prompt"] = content
|
|
178
|
+
|
|
179
|
+
if "cwd" in result and "prompt" in result:
|
|
180
|
+
break
|
|
181
|
+
|
|
182
|
+
return result
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _decode_claude_project_path(encoded: str) -> str:
|
|
186
|
+
"""Decodes Claude's dash-encoded project directory name to a path string."""
|
|
187
|
+
|
|
188
|
+
if not encoded:
|
|
189
|
+
return "Unknown project"
|
|
190
|
+
if encoded.startswith("-"):
|
|
191
|
+
return "/" + encoded[1:].replace("-", "/")
|
|
192
|
+
return encoded.replace("-", "/")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _read_simple_yaml(path: Path) -> Dict[str, str]:
|
|
196
|
+
"""Reads a simple key:value YAML-like file into a dictionary.
|
|
197
|
+
|
|
198
|
+
This parser intentionally handles only flat `key: value` rows used by
|
|
199
|
+
Copilot workspace metadata.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
path: Path to the YAML file.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Parsed key/value pairs.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
values: Dict[str, str] = {}
|
|
209
|
+
|
|
210
|
+
with path.open("r", encoding="utf-8") as f:
|
|
211
|
+
for raw in f:
|
|
212
|
+
line = raw.strip()
|
|
213
|
+
if not line or line.startswith("#"):
|
|
214
|
+
continue
|
|
215
|
+
if ":" not in line:
|
|
216
|
+
continue
|
|
217
|
+
key, value = line.split(":", 1)
|
|
218
|
+
values[key.strip()] = value.strip()
|
|
219
|
+
|
|
220
|
+
return values
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _directory_size_bytes(path: Path) -> int:
|
|
224
|
+
"""Computes total size in bytes for all files under a directory."""
|
|
225
|
+
|
|
226
|
+
total = 0
|
|
227
|
+
for entry in path.rglob("*"):
|
|
228
|
+
if entry.is_file():
|
|
229
|
+
total += entry.stat().st_size
|
|
230
|
+
return total
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _parse_iso_dt(value: Optional[str]) -> Optional[datetime]:
|
|
234
|
+
"""Parses ISO datetime text and ensures timezone-aware UTC values."""
|
|
235
|
+
|
|
236
|
+
if not value:
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
normalized = value.replace("Z", "+00:00")
|
|
240
|
+
try:
|
|
241
|
+
parsed = datetime.fromisoformat(normalized)
|
|
242
|
+
except ValueError:
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
if parsed.tzinfo is None:
|
|
246
|
+
return parsed.replace(tzinfo=timezone.utc)
|
|
247
|
+
return parsed
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _shorten(text: str, limit: int) -> str:
|
|
251
|
+
"""Shortens text with ellipsis when it exceeds the provided limit."""
|
|
252
|
+
|
|
253
|
+
if len(text) <= limit:
|
|
254
|
+
return text
|
|
255
|
+
return text[: limit - 1] + "..."
|
sede/models.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class SessionRecord:
|
|
10
|
+
"""Represents one discoverable assistant session in storage."""
|
|
11
|
+
|
|
12
|
+
provider: str
|
|
13
|
+
session_id: str
|
|
14
|
+
title: str
|
|
15
|
+
project_path: str
|
|
16
|
+
size_bytes: int
|
|
17
|
+
updated_at: datetime
|
|
18
|
+
storage_path: Path
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sede
|
|
3
|
+
Version: 0.1.4
|
|
4
|
+
Summary: Session deleter for coding assistants
|
|
5
|
+
Author-email: Ilya Popov <ilypopv@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ilypopv/sede
|
|
8
|
+
Project-URL: Repository, https://github.com/ilypopv/sede
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: typer<1.0,>=0.12
|
|
13
|
+
Requires-Dist: questionary<3.0,>=2.0
|
|
14
|
+
Requires-Dist: rich<14.0,>=13.7
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest<9.0,>=8.2; extra == "dev"
|
|
17
|
+
Requires-Dist: pytest-cov<6.0,>=5.0; extra == "dev"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# sede: SEssion DEleter
|
|
21
|
+
|
|
22
|
+
_Delete archived coding assistant sessions from terminal, fast and safely._
|
|
23
|
+
|
|
24
|
+
[](https://github.com/ilypopv/sede/actions/workflows/ci.yml)
|
|
25
|
+
[](LICENSE)
|
|
26
|
+
|
|
27
|
+
## Features
|
|
28
|
+
|
|
29
|
+
- Interactive TUI flow for provider selection and session deletion
|
|
30
|
+
- Supports `Claude Code` and `GitHub Copilot`
|
|
31
|
+
- Multi-select deletion with confirmation
|
|
32
|
+
- Storage-aware list: title, project path, storage path, size, updated-at
|
|
33
|
+
- Safe Claude cleanup: removes selected session file and prunes empty project directory
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pipx install sede
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Run
|
|
42
|
+
|
|
43
|
+
After installation, run:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
sede
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## TUI Preview
|
|
50
|
+
|
|
51
|
+
Main screen:
|
|
52
|
+
|
|
53
|
+
```text
|
|
54
|
+
_
|
|
55
|
+
___ ___ __| | ___
|
|
56
|
+
/ __|/ _ \/ _` |/ _ \
|
|
57
|
+
\__ \ __/ (_| | __/
|
|
58
|
+
|___/\___|\__,_|\___|
|
|
59
|
+
|
|
60
|
+
Session Deleter v0.1.4
|
|
61
|
+
https://github.com/ilypopv/sede/
|
|
62
|
+
Deep clean archived coding assistant sessions from your device.
|
|
63
|
+
|
|
64
|
+
Choose coding assistant
|
|
65
|
+
➤ 1. Claude Code
|
|
66
|
+
Delete archived Claude Code sessions
|
|
67
|
+
|
|
68
|
+
2. GitHub Copilot
|
|
69
|
+
Delete archived Copilot sessions
|
|
70
|
+
|
|
71
|
+
↑↓ Navigate | Enter / → Select | Ctrl+C / Q Quit
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Session selection screen:
|
|
75
|
+
|
|
76
|
+
```text
|
|
77
|
+
Available sessions: Claude Code
|
|
78
|
+
1 session(s) loaded. Total size: 1.5 MB.
|
|
79
|
+
|
|
80
|
+
Choose sessions to delete
|
|
81
|
+
» ○ Session title...
|
|
82
|
+
/Users/you/project-path
|
|
83
|
+
~/.claude/projects/-Users-you-project/
|
|
84
|
+
1.5 MB | 2026-07-03 09:44 UTC
|
|
85
|
+
|
|
86
|
+
↑↓ Navigate | ← Back | Space Select | A Toggle All | Enter Delete | Ctrl+C / Q Quit
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
When no sessions are found for a provider, the same screen layout is shown instead:
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
Available sessions: Claude Code
|
|
93
|
+
0 session(s) loaded. Total size: 0 B.
|
|
94
|
+
|
|
95
|
+
No sessions found for Claude Code.
|
|
96
|
+
|
|
97
|
+
Press any key to go back...
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Safety Notes
|
|
101
|
+
|
|
102
|
+
- Deletion is permanent.
|
|
103
|
+
- Claude: deletes selected `.jsonl` session file, then removes parent project dir only if empty.
|
|
104
|
+
- Copilot: deletes the selected session directory recursively.
|
|
105
|
+
- Always review selected entries before confirming.
|
|
106
|
+
|
|
107
|
+
## Session Sources
|
|
108
|
+
|
|
109
|
+
- Claude: `~/.claude/projects/*/*.jsonl`
|
|
110
|
+
- Copilot: `~/.copilot/session-state/<session-id>/`
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
sede/__init__.py,sha256=bX7TGMbpI3XOrpoFCR4oriGNKg1m4cyreIRI9oGdnS0,242
|
|
2
|
+
sede/__main__.py,sha256=qnjDMfga7rCChunbryY8m82a-bW0UxLAarKtn1Wyk8g,122
|
|
3
|
+
sede/cli.py,sha256=ZwjAzv7m38ifQ-9MNAvlqJ_TY05rxNBmuu53vb08pgw,19542
|
|
4
|
+
sede/discovery.py,sha256=9k4HvFjZOp_yqRBTNOxBzr8htRQTxzB7nAsnFphrjIY,7492
|
|
5
|
+
sede/models.py,sha256=NVJgNbcQTZsDtaGWmOqoMZ_suhUbRPRiv2f8THetvg0,384
|
|
6
|
+
sede-0.1.4.dist-info/licenses/LICENSE,sha256=5A7croZysIrv2Yz_tDGFwDPRZ6gCErkYWMsFoy_F4GU,1067
|
|
7
|
+
sede-0.1.4.dist-info/METADATA,sha256=Mgwt2of3GAK_83ysOFkQjNnZ5KCyjJ7aYr9U_YyYCqY,2846
|
|
8
|
+
sede-0.1.4.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
9
|
+
sede-0.1.4.dist-info/entry_points.txt,sha256=Jg2sgmjgi75rz5f4LfjF23EO-rM2BVPeBUol5ABmbmE,38
|
|
10
|
+
sede-0.1.4.dist-info/top_level.txt,sha256=b7sj8DfjbEFSetWeaxWseTjJzuUX27Wn726awdqBnmI,5
|
|
11
|
+
sede-0.1.4.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ilya Popov
|
|
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
|
+
sede
|