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 ADDED
@@ -0,0 +1,10 @@
1
+ """Top-level package for the sede CLI application."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ __all__ = ["__version__"]
6
+
7
+ try:
8
+ __version__ = version("sede")
9
+ except PackageNotFoundError:
10
+ __version__ = "0+unknown"
sede/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Module entrypoint for running sede as `python -m sede`."""
2
+
3
+ from .cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
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
+ [![CI](https://img.shields.io/github/actions/workflow/status/ilypopv/sede/ci.yml?style=flat-square&label=CI)](https://github.com/ilypopv/sede/actions/workflows/ci.yml)
25
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sede = sede.cli:app
@@ -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