claude-code-tools 1.22.0__tar.gz → 1.23.1__tar.gz

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.
Files changed (116) hide show
  1. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/PKG-INFO +1 -1
  2. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/__init__.py +1 -1
  3. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/aichat.py +35 -35
  4. claude_code_tools-1.23.1/claude_code_tools/port_render.py +363 -0
  5. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/port_service.py +46 -4
  6. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/session_resolution.py +34 -9
  7. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/pyproject.toml +2 -2
  8. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/.gitignore +0 -0
  9. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/LICENSE +0 -0
  10. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/README.md +0 -0
  11. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/action_rpc.py +0 -0
  12. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/agent_tunnel/__init__.py +0 -0
  13. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/agent_tunnel/backends.py +0 -0
  14. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/agent_tunnel/cli.py +0 -0
  15. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/agent_tunnel/config.py +0 -0
  16. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/agent_tunnel/convert.py +0 -0
  17. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/agent_tunnel/discord_bot.py +0 -0
  18. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/agent_tunnel/locking.py +0 -0
  19. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/agent_tunnel/paths.py +0 -0
  20. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/agent_tunnel/registry.py +0 -0
  21. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/agent_tunnel/session.py +0 -0
  22. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/agent_tunnel/store.py +0 -0
  23. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/agent_tunnel/tmux.py +0 -0
  24. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/agent_tunnel/trust.py +0 -0
  25. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/claude_continue.py +0 -0
  26. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/codex_continue.py +0 -0
  27. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/codex_server.py +0 -0
  28. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/codex_server_cli.py +0 -0
  29. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/codex_server_fingerprint.py +0 -0
  30. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/codex_server_generation.py +0 -0
  31. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/codex_server_legacy.py +0 -0
  32. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/codex_server_models.py +0 -0
  33. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/codex_server_process.py +0 -0
  34. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/codex_server_reservation.py +0 -0
  35. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/codex_server_retry.py +0 -0
  36. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/codex_server_reuse.py +0 -0
  37. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/codex_server_state.py +0 -0
  38. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/codex_server_supervisor.py +0 -0
  39. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/codex_server_worker.py +0 -0
  40. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/config.py +0 -0
  41. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/csv2gsheet.py +0 -0
  42. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/delete_session.py +0 -0
  43. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/dotenv_vault.py +0 -0
  44. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/env_safe.py +0 -0
  45. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/export_all.py +0 -0
  46. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/export_claude_session.py +0 -0
  47. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/export_codex_session.py +0 -0
  48. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/export_session.py +0 -0
  49. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/find_claude_session.py +0 -0
  50. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/find_codex_session.py +0 -0
  51. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/find_original_session.py +0 -0
  52. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/find_session.py +0 -0
  53. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/find_trimmed_sessions.py +0 -0
  54. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/fix_session.py +0 -0
  55. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/gdoc2docx.py +0 -0
  56. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/gdoc2md.py +0 -0
  57. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/gsheet2csv.py +0 -0
  58. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/md2gdoc.py +0 -0
  59. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/msg/__init__.py +0 -0
  60. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/msg/cli.py +0 -0
  61. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/msg/hooks.py +0 -0
  62. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/msg/models.py +0 -0
  63. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/msg/prompt_detect.py +0 -0
  64. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/msg/store.py +0 -0
  65. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/msg/watcher.py +0 -0
  66. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/node_menu_ui.py +0 -0
  67. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/port_claude_noise.py +0 -0
  68. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/port_claude_to_codex.py +0 -0
  69. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/port_codex_flatten.py +0 -0
  70. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/port_codex_to_claude.py +0 -0
  71. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/resolve_session.py +0 -0
  72. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/resolve_session_names.py +0 -0
  73. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/resolve_session_render.py +0 -0
  74. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/search_index.py +0 -0
  75. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/session_lineage.py +0 -0
  76. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/session_menu.py +0 -0
  77. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/session_menu_cli.py +0 -0
  78. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/session_utils.py +0 -0
  79. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/smart_trim.py +0 -0
  80. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/smart_trim_core.py +0 -0
  81. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/tmux_cli_controller.py +0 -0
  82. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/tmux_execution_helpers.py +0 -0
  83. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/tmux_remote_controller.py +0 -0
  84. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/trim_in_place.py +0 -0
  85. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/trim_session.py +0 -0
  86. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/trim_session_claude.py +0 -0
  87. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/trim_session_codex.py +0 -0
  88. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/workflow_cli.py +0 -0
  89. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/workflow_cli_contract.py +0 -0
  90. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/workflow_cli_formatting.py +0 -0
  91. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/workflow_cli_identity_policy.py +0 -0
  92. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/workflow_cli_manifest.py +0 -0
  93. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/workflow_cli_projection.py +0 -0
  94. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/workflow_cli_rendering.py +0 -0
  95. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/workflow_cli_snapshots.py +0 -0
  96. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/workflow_cli_store_backends.py +0 -0
  97. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/workflow_processes.py +0 -0
  98. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/workflow_runs.py +0 -0
  99. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/workflow_store_io.py +0 -0
  100. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/claude_code_tools/workflow_validation.py +0 -0
  101. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/docs/agent-tunnel-spec.md +0 -0
  102. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/docs/claude-code-tmux-tutorials.md +0 -0
  103. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/docs/dot-zshrc.md +0 -0
  104. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/docs/find-claude-session.md +0 -0
  105. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/docs/intercom-spec.md +0 -0
  106. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/docs/lmsh.md +0 -0
  107. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/docs/local-llm-setup.md +0 -0
  108. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/docs/msg-phase-a-plan.md +0 -0
  109. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/docs/msg-plan-v2.md +0 -0
  110. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/docs/tmux-cli-instructions.md +0 -0
  111. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/docs/vault-documentation.md +0 -0
  112. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/hatch_build.py +0 -0
  113. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/node_ui/action_config.js +0 -0
  114. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/node_ui/menu.js +0 -0
  115. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/node_ui/package-lock.json +0 -0
  116. {claude_code_tools-1.22.0 → claude_code_tools-1.23.1}/node_ui/package.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: claude-code-tools
3
- Version: 1.22.0
3
+ Version: 1.23.1
4
4
  Summary: Collection of tools for working with Claude Code
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.11
@@ -1,3 +1,3 @@
1
1
  """Claude Code Tools - Collection of utilities for Claude Code."""
2
2
 
3
- __version__ = "1.22.0"
3
+ __version__ = "1.23.1"
@@ -1768,7 +1768,9 @@ def port_session(
1768
1768
  """
1769
1769
  import sys
1770
1770
 
1771
+ from claude_code_tools.port_render import PortDisplay
1771
1772
  from claude_code_tools.port_service import (
1773
+ PortAmbiguityError,
1772
1774
  PortSessionError,
1773
1775
  port_claude_session,
1774
1776
  port_codex_session,
@@ -1782,56 +1784,54 @@ def port_session(
1782
1784
  claude_home = claude_home or root_obj.get("claude_home")
1783
1785
  codex_home = codex_home or root_obj.get("codex_home")
1784
1786
 
1787
+ display = PortDisplay(session)
1788
+ display.start()
1785
1789
  try:
1786
- resolved = resolve_port_session(
1787
- session, claude_home=claude_home, codex_home=codex_home
1788
- )
1790
+ with display.resolving():
1791
+ resolved = resolve_port_session(
1792
+ session,
1793
+ claude_home=claude_home,
1794
+ codex_home=codex_home,
1795
+ )
1796
+ except PortAmbiguityError as error:
1797
+ try:
1798
+ resolved = display.choose_candidate(error)
1799
+ except (EOFError, KeyboardInterrupt):
1800
+ display.error(str(error))
1801
+ sys.exit(1)
1802
+ if resolved is None:
1803
+ display.cancelled()
1804
+ sys.exit(1)
1789
1805
  except PortSessionError as e:
1790
- print(f"Error: {e}", file=sys.stderr)
1806
+ display.error(str(e))
1791
1807
  sys.exit(1)
1792
1808
 
1809
+ display.resolved(resolved)
1793
1810
  if resolved.agent == "claude":
1794
- print("Detected source agent: claude — porting to Codex")
1795
1811
  try:
1796
- result = port_claude_session(
1797
- resolved.session_file, codex_home=codex_home
1798
- )
1812
+ with display.porting(resolved.agent):
1813
+ result = port_claude_session(
1814
+ resolved.session_file,
1815
+ codex_home=codex_home,
1816
+ )
1799
1817
  except PortSessionError as e:
1800
- print(f"Error: {e}", file=sys.stderr)
1818
+ display.error(str(e))
1801
1819
  sys.exit(1)
1802
-
1803
- print()
1804
- print(f"New Codex session id: {result.new_session_id}")
1805
- print(f"Output file: {result.output_file}")
1806
- print(f"Session cwd: {result.cwd}")
1807
- print()
1808
- print("To resume:")
1809
- print(f" {result.resume_hint}")
1810
- print()
1811
- print(
1812
- "Tip: codex's /import can also import Claude sessions "
1813
- "natively (interactive alternative)."
1814
- )
1820
+ display.complete(result, resolved.agent)
1815
1821
  return
1816
1822
 
1817
- print("Detected source agent: codex — porting to Claude Code")
1818
1823
  try:
1819
- result = port_codex_session(
1820
- resolved.session_file, claude_home=claude_home
1821
- )
1824
+ with display.porting(resolved.agent):
1825
+ result = port_codex_session(
1826
+ resolved.session_file,
1827
+ claude_home=claude_home,
1828
+ )
1822
1829
  except PortSessionError as e:
1823
1830
  # Expected failure modes (missing/empty session, filesystem
1824
1831
  # or encoding errors) become a clean error, not a traceback.
1825
- print(f"Error: {e}", file=sys.stderr)
1832
+ display.error(str(e))
1826
1833
  sys.exit(1)
1827
-
1828
- print()
1829
- print(f"New Claude session id: {result.new_session_id}")
1830
- print(f"Output file: {result.output_file}")
1831
- print(f"Session cwd: {result.cwd}")
1832
- print()
1833
- print("To resume:")
1834
- print(f" {result.resume_hint}")
1834
+ display.complete(result, resolved.agent)
1835
1835
 
1836
1836
 
1837
1837
  @main.command("query")
@@ -0,0 +1,363 @@
1
+ """Rich terminal rendering for the ``aichat port`` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ from rich import box
13
+ from rich.console import Console
14
+ from rich.panel import Panel
15
+ from rich.prompt import Confirm, Prompt
16
+ from rich.status import Status
17
+ from rich.table import Table
18
+ from rich.text import Text
19
+
20
+ if TYPE_CHECKING:
21
+ from claude_code_tools.port_service import (
22
+ PortAmbiguityError,
23
+ PortResult,
24
+ ResolvedSession,
25
+ )
26
+ from claude_code_tools.resolve_session import SessionRecord
27
+
28
+
29
+ _AGENT_LABELS = {"claude": "Claude", "codex": "Codex"}
30
+ _MAX_CANDIDATE_TITLE_CHARS = 120
31
+ _MACOS_CLIPBOARD_COMMANDS = (("pbcopy",),)
32
+ _LINUX_CLIPBOARD_COMMANDS = (
33
+ ("wl-copy",),
34
+ ("xclip", "-selection", "clipboard"),
35
+ ("xsel", "--clipboard", "--input"),
36
+ )
37
+ _POWERSHELL_CLIPBOARD_ARGS = (
38
+ "-NoProfile",
39
+ "-NonInteractive",
40
+ "-Command",
41
+ "Set-Clipboard -Value ([Console]::In.ReadToEnd())",
42
+ )
43
+
44
+
45
+ def _clipboard_commands(
46
+ platform_name: str,
47
+ ) -> tuple[tuple[str, ...], ...]:
48
+ """Return platform-specific clipboard commands in preference order."""
49
+ if platform_name == "darwin":
50
+ return _MACOS_CLIPBOARD_COMMANDS
51
+ if platform_name == "win32":
52
+ system_root = Path(os.environ.get("SystemRoot", r"C:\Windows"))
53
+ system32 = system_root / "System32"
54
+ powershell = (
55
+ system32
56
+ / "WindowsPowerShell"
57
+ / "v1.0"
58
+ / "powershell.exe"
59
+ )
60
+ return (
61
+ (str(system32 / "clip.exe"),),
62
+ (
63
+ str(powershell),
64
+ *_POWERSHELL_CLIPBOARD_ARGS,
65
+ ),
66
+ )
67
+ return _LINUX_CLIPBOARD_COMMANDS
68
+
69
+
70
+ def copy_to_clipboard(
71
+ text: str,
72
+ *,
73
+ platform_name: str | None = None,
74
+ ) -> bool:
75
+ """Copy text with the first available working clipboard command.
76
+
77
+ Windows commands use explicit system paths so the current directory is
78
+ never searched for a clipboard executable.
79
+
80
+ Args:
81
+ text: Text to place on the clipboard.
82
+ platform_name: Platform identifier. Defaults to ``sys.platform``.
83
+
84
+ Returns:
85
+ True when a clipboard command succeeds, otherwise False.
86
+ """
87
+ commands = _clipboard_commands(platform_name or sys.platform)
88
+ for command in commands:
89
+ executable = shutil.which(command[0])
90
+ if executable is None:
91
+ continue
92
+ try:
93
+ completed = subprocess.run(
94
+ [executable, *command[1:]],
95
+ input=text,
96
+ text=True,
97
+ stdout=subprocess.DEVNULL,
98
+ stderr=subprocess.DEVNULL,
99
+ timeout=5,
100
+ check=False,
101
+ )
102
+ except (OSError, subprocess.SubprocessError):
103
+ continue
104
+ if completed.returncode == 0:
105
+ return True
106
+ return False
107
+
108
+
109
+ def _agent_label(agent: str) -> str:
110
+ """Return the human-facing label for an agent identifier."""
111
+ return _AGENT_LABELS.get(agent, agent.title())
112
+
113
+
114
+ def _target_agent(source_agent: str) -> str:
115
+ """Return the destination agent identifier for a source agent."""
116
+ return "codex" if source_agent == "claude" else "claude"
117
+
118
+
119
+ def _target_label(source_agent: str) -> str:
120
+ """Return the product label for a destination agent."""
121
+ target = _target_agent(source_agent)
122
+ return "Claude Code" if target == "claude" else "Codex"
123
+
124
+
125
+ def _match_reason(record: "SessionRecord", query: str) -> str:
126
+ """Explain why one resolver candidate matched the query."""
127
+ matched_by = record.matched_by
128
+ if matched_by == "id":
129
+ return "exact session ID"
130
+ if matched_by == "partial-id":
131
+ return "session ID prefix"
132
+ if matched_by == "id-substring":
133
+ return "session ID contains query"
134
+ if matched_by == "filename":
135
+ return "rollout filename contains query"
136
+ if matched_by == "name":
137
+ name = record.name or ""
138
+ if name.casefold() == query.casefold():
139
+ return "exact name/title"
140
+ return "name/title contains query"
141
+ return "resolver match"
142
+
143
+
144
+ def _candidate_details(record: "SessionRecord") -> Text:
145
+ """Build a readable multi-line summary for one candidate."""
146
+ details = Text()
147
+ title = " ".join((record.name or "Untitled session").split())
148
+ if len(title) > _MAX_CANDIDATE_TITLE_CHARS:
149
+ title = title[: _MAX_CANDIDATE_TITLE_CHARS - 3] + "..."
150
+ details.append(title, style="bold")
151
+ details.append("\nProject ", style="dim")
152
+ details.append(record.directory or "Unknown")
153
+ details.append("\nID ", style="dim")
154
+ details.append(record.session_id)
155
+ details.append("\nModified ", style="dim")
156
+ details.append(record.modified)
157
+ return details
158
+
159
+
160
+ class PortDisplay:
161
+ """Render progress, ambiguity choices, errors, and final results."""
162
+
163
+ def __init__(self, query: str) -> None:
164
+ """Create a display for one port query."""
165
+ self.query = query
166
+ self.console = Console()
167
+ self.error_console = Console(stderr=True)
168
+
169
+ def start(self) -> None:
170
+ """Print the command header and requested session query."""
171
+ content = Text()
172
+ content.append("Query ", style="dim")
173
+ content.append(self.query, style="bold")
174
+ self.console.print(
175
+ Panel.fit(
176
+ content,
177
+ title="[bold cyan]Session port[/bold cyan]",
178
+ border_style="cyan",
179
+ )
180
+ )
181
+
182
+ def resolving(self) -> Status:
183
+ """Start the visible resolution phase and return its spinner."""
184
+ self.console.print("[bold cyan]1/3[/] Resolving session")
185
+ return self.console.status(
186
+ "[dim]Searching Claude and Codex histories...[/dim]",
187
+ spinner="dots",
188
+ )
189
+
190
+ def choose_candidate(
191
+ self, error: "PortAmbiguityError"
192
+ ) -> "ResolvedSession | None":
193
+ """Present matching sessions and return the user's selection."""
194
+ from claude_code_tools.port_service import ResolvedSession
195
+
196
+ heading = Text()
197
+ heading.append(f"{error.match_count} sessions matched ", style="yellow")
198
+ heading.append(repr(error.query), style="bold yellow")
199
+ heading.append(". Choose the source session to port.", style="yellow")
200
+ self.console.print(heading)
201
+
202
+ table = Table(
203
+ box=box.ROUNDED,
204
+ header_style="bold cyan",
205
+ show_lines=True,
206
+ expand=True,
207
+ )
208
+ table.add_column("#", justify="right", no_wrap=True)
209
+ table.add_column("Agent", no_wrap=True)
210
+ table.add_column("Why it matched")
211
+ table.add_column("Session")
212
+ for index, record in enumerate(error.records, start=1):
213
+ table.add_row(
214
+ str(index),
215
+ _agent_label(record.agent),
216
+ _match_reason(record, error.query),
217
+ _candidate_details(record),
218
+ )
219
+ self.console.print(table)
220
+
221
+ if error.match_count > len(error.records):
222
+ hidden = error.match_count - len(error.records)
223
+ self.console.print(
224
+ f"[dim]{hidden} older matches are not shown. "
225
+ "Rerun with a full session ID to select one of them.[/dim]"
226
+ )
227
+
228
+ if not sys.stdin.isatty():
229
+ raise EOFError
230
+
231
+ choices = [str(index) for index in range(1, len(error.records) + 1)]
232
+ choices.append("q")
233
+ choice = Prompt.ask(
234
+ "[bold]Which source session do you want to port?[/bold] "
235
+ "[dim](number or q to cancel)[/dim]",
236
+ choices=choices,
237
+ show_choices=False,
238
+ console=self.console,
239
+ )
240
+ if choice == "q":
241
+ return None
242
+ record = error.records[int(choice) - 1]
243
+ return ResolvedSession(
244
+ agent=record.agent,
245
+ session_file=Path(record.session_file),
246
+ )
247
+
248
+ def resolved(self, session: "ResolvedSession") -> None:
249
+ """Print the chosen source and detected direction."""
250
+ source = _agent_label(session.agent)
251
+ target = _target_label(session.agent)
252
+ self.console.print(
253
+ Text.assemble(
254
+ (" ✓ ", "bold green"),
255
+ (f"Detected source agent: {session.agent}", "bold"),
256
+ f" — porting to {target}",
257
+ )
258
+ )
259
+ self.console.print(
260
+ Text.assemble((" Source file ", "dim"), str(session.session_file))
261
+ )
262
+ self.console.print(f"[bold cyan]2/3[/] Porting {source} → {target}")
263
+
264
+ def porting(self, source_agent: str) -> Status:
265
+ """Return the conversion-phase spinner."""
266
+ target = _target_label(source_agent)
267
+ return self.console.status(
268
+ f"[dim]Converting transcript and creating {target} session...[/dim]",
269
+ spinner="dots",
270
+ )
271
+
272
+ def complete(self, result: "PortResult", source_agent: str) -> None:
273
+ """Render the final destination details and resume command."""
274
+ target_agent = _target_agent(source_agent)
275
+ target = _agent_label(target_agent)
276
+ self.console.print("[bold cyan]3/3[/] Ready")
277
+
278
+ table = Table.grid(padding=(0, 2))
279
+ table.add_column(style="bold cyan", no_wrap=True)
280
+ table.add_column()
281
+ table.add_row(
282
+ f"New {target} session id:",
283
+ Text(result.new_session_id),
284
+ )
285
+ table.add_row("Session cwd:", Text(result.cwd or "Unknown"))
286
+ table.add_row("Output file:", Text(str(result.output_file)))
287
+ self.console.print(
288
+ Panel(
289
+ table,
290
+ title="[bold green]Port complete[/bold green]",
291
+ border_style="green",
292
+ )
293
+ )
294
+ self.console.print("[bold]To resume:[/bold]")
295
+ command = Text(
296
+ f" {result.resume_hint}",
297
+ style="bold cyan",
298
+ no_wrap=True,
299
+ overflow="ignore",
300
+ )
301
+ self.console.print(command, soft_wrap=True)
302
+ if target_agent == "codex":
303
+ self.console.print()
304
+ self.console.print(
305
+ "[dim]Tip: Codex's /import is also available as an "
306
+ "interactive alternative.[/dim]"
307
+ )
308
+ self.offer_session_id_copy(result.new_session_id, target_agent)
309
+
310
+ def offer_session_id_copy(
311
+ self,
312
+ session_id: str,
313
+ target_agent: str,
314
+ ) -> None:
315
+ """Offer to copy a newly created session ID in interactive terminals."""
316
+ if not self.console.is_terminal or not sys.stdin.isatty():
317
+ return
318
+ self.console.print()
319
+ try:
320
+ should_copy = Confirm.ask(
321
+ "[bold]Copy the new session ID to your clipboard?[/bold] "
322
+ "[dim](Y/n)[/dim]",
323
+ default=True,
324
+ show_choices=False,
325
+ show_default=False,
326
+ console=self.console,
327
+ )
328
+ except (EOFError, KeyboardInterrupt):
329
+ should_copy = False
330
+ if not should_copy:
331
+ self.console.print("[dim]Clipboard left unchanged.[/dim]")
332
+ return
333
+ if copy_to_clipboard(session_id):
334
+ self.console.print(
335
+ "[bold green]✓ Session ID copied to clipboard.[/bold green]"
336
+ )
337
+ command = (
338
+ "codex resume"
339
+ if target_agent == "codex"
340
+ else "claude --resume"
341
+ )
342
+ self.console.print("[bold]Now you can run:[/bold]")
343
+ self.console.print(
344
+ Text(
345
+ f" {command} <paste session ID>",
346
+ style="bold cyan",
347
+ )
348
+ )
349
+ return
350
+ self.console.print(
351
+ "[yellow]Could not find a working system clipboard command. "
352
+ "The session ID is still shown above.[/yellow]"
353
+ )
354
+
355
+ def error(self, message: str) -> None:
356
+ """Render an expected failure without a traceback."""
357
+ self.error_console.print(
358
+ Text.assemble(("Error: ", "bold red"), message)
359
+ )
360
+
361
+ def cancelled(self) -> None:
362
+ """Render a user-requested cancellation."""
363
+ self.console.print("[yellow]Port cancelled.[/yellow]")
@@ -25,11 +25,12 @@ from pathlib import Path
25
25
  from typing import TYPE_CHECKING, Optional, Union
26
26
 
27
27
  if TYPE_CHECKING:
28
- from claude_code_tools.resolve_session import ResolveResult
28
+ from claude_code_tools.resolve_session import ResolveResult, SessionRecord
29
29
 
30
30
  from claude_code_tools.session_resolution import (
31
31
  _detect_direct_path_agent,
32
32
  _resolve_query_for_agent,
33
+ ambiguity_candidates,
33
34
  build_ambiguity_message,
34
35
  )
35
36
 
@@ -42,6 +43,39 @@ class PortSessionError(Exception):
42
43
  """
43
44
 
44
45
 
46
+ class PortAmbiguityError(PortSessionError):
47
+ """A port query with more than one valid source session.
48
+
49
+ Carries the candidates alongside the printable message so the CLI
50
+ can offer them interactively instead of only reporting them.
51
+
52
+ Attributes:
53
+ query: Original user query.
54
+ records: Newest matching records available for selection.
55
+ match_count: Total matches, including any omitted by the cap.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ query: str,
61
+ records: "tuple[SessionRecord, ...]",
62
+ match_count: int,
63
+ message: str,
64
+ ) -> None:
65
+ """Initialize an ambiguity with structured candidate data.
66
+
67
+ Args:
68
+ query: Original user query.
69
+ records: Newest matching records available for selection.
70
+ match_count: Total matches before the display cap.
71
+ message: User-facing message listing the candidates.
72
+ """
73
+ super().__init__(message)
74
+ self.query = query
75
+ self.records = records
76
+ self.match_count = match_count
77
+
78
+
45
79
  @dataclass
46
80
  class ResolvedSession:
47
81
  """A session file together with its detected source agent.
@@ -99,8 +133,10 @@ def resolve_port_session(
99
133
  The resolved session file with its detected agent.
100
134
 
101
135
  Raises:
102
- PortSessionError: When the session cannot be found, is
103
- ambiguous, or its agent cannot be detected.
136
+ PortAmbiguityError: When the query matches more than one
137
+ session; carries the candidates for interactive choice.
138
+ PortSessionError: When the session cannot be found or its
139
+ agent cannot be detected.
104
140
  """
105
141
  # Arbitrary session names are legitimate queries here, and some
106
142
  # (e.g. "~nonexistent-user" or names with NUL bytes) make path
@@ -137,7 +173,13 @@ def resolve_port_session(
137
173
  return ResolvedSession(
138
174
  agent=record.agent, session_file=Path(record.session_file)
139
175
  )
140
- raise PortSessionError(build_ambiguity_message(session, results))
176
+ records, match_count = ambiguity_candidates(results)
177
+ raise PortAmbiguityError(
178
+ session,
179
+ records,
180
+ match_count,
181
+ build_ambiguity_message(session, results),
182
+ )
141
183
 
142
184
 
143
185
  def _read_output_cwd(output_file: Path) -> str:
@@ -22,7 +22,11 @@ from pathlib import Path
22
22
  from typing import TYPE_CHECKING, Optional, cast
23
23
 
24
24
  if TYPE_CHECKING:
25
- from claude_code_tools.resolve_session import ResolveResult, ResolverError
25
+ from claude_code_tools.resolve_session import (
26
+ ResolveResult,
27
+ ResolverError,
28
+ SessionRecord,
29
+ )
26
30
 
27
31
  from claude_code_tools.session_utils import (
28
32
  _iter_named_session_files,
@@ -164,17 +168,22 @@ def _resolve_query_for_agent(
164
168
  return None
165
169
 
166
170
 
167
- def build_ambiguity_message(
168
- session: str, results: "list[ResolveResult]"
169
- ) -> str:
170
- """Build the shared multi-candidate ambiguity message.
171
+ def ambiguity_candidates(
172
+ results: "list[ResolveResult]",
173
+ ) -> "tuple[tuple[SessionRecord, ...], int]":
174
+ """Return the displayable candidates for an ambiguous query.
175
+
176
+ Single source of truth for how ambiguous matches are ordered and
177
+ capped, so a caller that RENDERS the candidates (an interactive
178
+ picker) and one that only formats the message agree on which
179
+ sessions they are talking about.
171
180
 
172
181
  Args:
173
- session: Original session query.
174
182
  results: Non-empty resolver results from one or both agents.
175
183
 
176
184
  Returns:
177
- A user-facing message listing the matching candidates.
185
+ Newest-first candidates capped at ``_MAX_AMBIGUITY_LINES``,
186
+ paired with the total match count before capping.
178
187
  """
179
188
  records = [record for result in results for record in result.records]
180
189
  records.sort(key=lambda record: record._modified_timestamp, reverse=True)
@@ -182,8 +191,24 @@ def build_ambiguity_message(
182
191
  1 if result.kind == "single" else result.match_count
183
192
  for result in results
184
193
  )
194
+ return tuple(records[:_MAX_AMBIGUITY_LINES]), total
195
+
196
+
197
+ def build_ambiguity_message(
198
+ session: str, results: "list[ResolveResult]"
199
+ ) -> str:
200
+ """Build the shared multi-candidate ambiguity message.
201
+
202
+ Args:
203
+ session: Original session query.
204
+ results: Non-empty resolver results from one or both agents.
205
+
206
+ Returns:
207
+ A user-facing message listing the matching candidates.
208
+ """
209
+ records, total = ambiguity_candidates(results)
185
210
  lines = []
186
- for record in records[:_MAX_AMBIGUITY_LINES]:
211
+ for record in records:
187
212
  # Codex auto-titles can contain entire prompts. Normalize whitespace
188
213
  # and truncate them to keep every candidate on a readable line.
189
214
  name = " ".join((record.name or "").split())
@@ -194,7 +219,7 @@ def build_ambiguity_message(
194
219
  f" [{record.agent}] {record.session_id}{name_part}"
195
220
  f" modified {record.modified}"
196
221
  )
197
- remaining = total - len(records[:_MAX_AMBIGUITY_LINES])
222
+ remaining = total - len(records)
198
223
  if remaining > 0:
199
224
  lines.append(f" ... and {remaining} more")
200
225
  listing = "\n".join(lines)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "claude-code-tools"
3
- version = "1.22.0"
3
+ version = "1.23.1"
4
4
  description = "Collection of tools for working with Claude Code"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -111,7 +111,7 @@ markers = [
111
111
 
112
112
  [tool.commitizen]
113
113
  name = "cz_conventional_commits"
114
- version = "1.22.0"
114
+ version = "1.23.1"
115
115
  tag_format = "v$version"
116
116
  version_files = [
117
117
  "pyproject.toml:version",