ctxttools 0.4.1__tar.gz → 0.4.2__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 (40) hide show
  1. {ctxttools-0.4.1/ctxttools.egg-info → ctxttools-0.4.2}/PKG-INFO +13 -3
  2. ctxttools-0.4.1/PKG-INFO → ctxttools-0.4.2/README.md +11 -20
  3. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/agents.py +122 -1
  4. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/ccat.py +25 -18
  5. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/ccopy.py +24 -4
  6. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/cdir.py +10 -8
  7. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/ctools_mcp.py +304 -106
  8. ctxttools-0.4.2/ctools/resume.py +285 -0
  9. ctxttools-0.4.1/README.md → ctxttools-0.4.2/ctxttools.egg-info/PKG-INFO +30 -2
  10. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctxttools.egg-info/requires.txt +1 -0
  11. {ctxttools-0.4.1 → ctxttools-0.4.2}/pyproject.toml +2 -2
  12. ctxttools-0.4.1/ctools/resume.py +0 -112
  13. {ctxttools-0.4.1 → ctxttools-0.4.2}/LICENSE.MIT +0 -0
  14. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/__init__.py +0 -0
  15. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/__main__.py +0 -0
  16. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/backup.py +0 -0
  17. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/cconnect.py +0 -0
  18. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/cdu.py +0 -0
  19. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/cextract.py +0 -0
  20. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/cgrep.py +0 -0
  21. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/cli.py +0 -0
  22. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/config.py +0 -0
  23. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/crecover.py +0 -0
  24. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/crm.py +0 -0
  25. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/ctools-web.py +0 -0
  26. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/dispatch.py +0 -0
  27. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/filterlib.py +0 -0
  28. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/lib.py +0 -0
  29. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/log.py +0 -0
  30. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/pathfilter.py +0 -0
  31. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/paths.py +0 -0
  32. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/remote_search.py +0 -0
  33. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/strategy.py +0 -0
  34. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/testing.py +0 -0
  35. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctools/webui.py +0 -0
  36. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctxttools.egg-info/SOURCES.txt +0 -0
  37. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctxttools.egg-info/dependency_links.txt +0 -0
  38. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctxttools.egg-info/entry_points.txt +0 -0
  39. {ctxttools-0.4.1 → ctxttools-0.4.2}/ctxttools.egg-info/top_level.txt +0 -0
  40. {ctxttools-0.4.1 → ctxttools-0.4.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ctxttools
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: CLI tools for LLM conversations
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -12,6 +12,7 @@ Requires-Dist: platformdirs>=3.0.0
12
12
  Requires-Dist: tomli>=2.0.0; python_version < "3.11"
13
13
  Provides-Extra: mcp
14
14
  Requires-Dist: mcp<2.0,>=1.2.0; extra == "mcp"
15
+ Requires-Dist: pyyaml>=6.0; extra == "mcp"
15
16
  Provides-Extra: dev
16
17
  Requires-Dist: pytest>=7.0.0; extra == "dev"
17
18
  Dynamic: license-file
@@ -210,7 +211,7 @@ ctools grep --hosts "mcp config" # also the hosts in ctools.toml
210
211
 
211
212
  ```bash
212
213
  ctools cat opencode/ses_abc # JSON list of {role, content}
213
- ctools cat --text opencode/ses_abc # a readable transcript
214
+ ctools cat --text opencode/ses_abc # a markdown transcript (**role**:/content)
214
215
  ctools cat ssh://me@box/pi/<id> # a session on another machine
215
216
  ```
216
217
 
@@ -239,11 +240,20 @@ in, and `exec`s the command (replacing ctools). It's the one verb that
239
240
  "leaves ctools".
240
241
 
241
242
  ```bash
243
+ ctools resume claude-code/55b9c8b7-788b-4f9e-a8b7-cfa0461b357b # a cdir row, pasted
242
244
  ctools resume claude-code a293cac2 # cd + exec `claude --resume a293cac2`
243
245
  ctools resume pi <id> # `pi --session <id>`
244
- ctools dir --resume-command pi <id> # just print the command, don't exec it
246
+ ctools resume . # the most recent session in this directory
247
+ ctools dir --resume-command pi/<id> # just print the command, don't exec it
245
248
  ```
246
249
 
250
+ The session is named the way `cdir` prints it — `agent/session`, the
251
+ two-word `agent session`, or a bare id resolved across the installed
252
+ agents. A directory (`.`, `..`, `/path`, `~/proj`) instead names the most
253
+ recently recorded session in it; `resume <agent> .` scopes that to one
254
+ agent. `resume` says which session a directory resolved to before it
255
+ leaves.
256
+
247
257
  The resume command comes from the agent backend and is overridable per
248
258
  machine in `~/.config/ctools/resume-commands.json`. The `cd` into the
249
259
  recorded directory matters: `claude --resume` only finds a session from the
@@ -1,21 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: ctxttools
3
- Version: 0.4.1
4
- Summary: CLI tools for LLM conversations
5
- Requires-Python: >=3.10
6
- Description-Content-Type: text/markdown
7
- License-File: LICENSE.MIT
8
- Requires-Dist: rich>=13.0.0
9
- Requires-Dist: tiktoken>=0.5.0
10
- Requires-Dist: structlog>=24.0
11
- Requires-Dist: platformdirs>=3.0.0
12
- Requires-Dist: tomli>=2.0.0; python_version < "3.11"
13
- Provides-Extra: mcp
14
- Requires-Dist: mcp<2.0,>=1.2.0; extra == "mcp"
15
- Provides-Extra: dev
16
- Requires-Dist: pytest>=7.0.0; extra == "dev"
17
- Dynamic: license-file
18
-
19
1
  # ctools
20
2
 
21
3
  <p align="center">
@@ -210,7 +192,7 @@ ctools grep --hosts "mcp config" # also the hosts in ctools.toml
210
192
 
211
193
  ```bash
212
194
  ctools cat opencode/ses_abc # JSON list of {role, content}
213
- ctools cat --text opencode/ses_abc # a readable transcript
195
+ ctools cat --text opencode/ses_abc # a markdown transcript (**role**:/content)
214
196
  ctools cat ssh://me@box/pi/<id> # a session on another machine
215
197
  ```
216
198
 
@@ -239,11 +221,20 @@ in, and `exec`s the command (replacing ctools). It's the one verb that
239
221
  "leaves ctools".
240
222
 
241
223
  ```bash
224
+ ctools resume claude-code/55b9c8b7-788b-4f9e-a8b7-cfa0461b357b # a cdir row, pasted
242
225
  ctools resume claude-code a293cac2 # cd + exec `claude --resume a293cac2`
243
226
  ctools resume pi <id> # `pi --session <id>`
244
- ctools dir --resume-command pi <id> # just print the command, don't exec it
227
+ ctools resume . # the most recent session in this directory
228
+ ctools dir --resume-command pi/<id> # just print the command, don't exec it
245
229
  ```
246
230
 
231
+ The session is named the way `cdir` prints it — `agent/session`, the
232
+ two-word `agent session`, or a bare id resolved across the installed
233
+ agents. A directory (`.`, `..`, `/path`, `~/proj`) instead names the most
234
+ recently recorded session in it; `resume <agent> .` scopes that to one
235
+ agent. `resume` says which session a directory resolved to before it
236
+ leaves.
237
+
247
238
  The resume command comes from the agent backend and is overridable per
248
239
  machine in `~/.config/ctools/resume-commands.json`. The `cd` into the
249
240
  recorded directory matters: `claude --resume` only finds a session from the
@@ -510,12 +510,15 @@ class Agent:
510
510
  """
511
511
  return self._session_cwd(session_id)
512
512
 
513
- def from_common(self, doc: Dict[str, object], cwd: Optional[str] = None) -> str:
513
+ def from_common(self, doc: Dict[str, object], cwd: Optional[str] = None,
514
+ notice: bool = True) -> str:
514
515
  """Import a common-format envelope into a NEW session; return its id.
515
516
 
516
517
  ``cwd`` (or ``doc['cwd']``) seeds the session for that working
517
518
  directory when the agent records one, so the copy lands in the
518
519
  destination's matching project dir and resumes from there.
520
+ ``notice`` appends the import notice that tells a resumed model its
521
+ own tool history was left behind (see :meth:`import_notice`).
519
522
  """
520
523
  context = doc.get('context') or []
521
524
  if not isinstance(context, list):
@@ -527,8 +530,74 @@ class Agent:
527
530
  # If the envelope carries verbatim records *for this agent* (a same-
528
531
  # agent round trip), replay them; otherwise project the portable spine.
529
532
  messages = self._from_common_messages(doc, context)
533
+ if notice:
534
+ note = self.import_notice(doc)
535
+ if note is not None:
536
+ messages = messages + [note]
530
537
  return self.create_session(messages, cwd=cwd)
531
538
 
539
+ # Marker opening the import notice, so a copy of a copy is not annotated
540
+ # twice and the notice is recognisable to a reader and to tests.
541
+ IMPORT_NOTICE_TAG = 'ctools-import-notice'
542
+
543
+ def import_notice(self, doc: Dict[str, object]) -> Optional[Message]:
544
+ """A closing message telling a resumed model what its transcript hides.
545
+
546
+ The common format carries the conversation's *text*. Tool calls and
547
+ their results do not survive the projection, so a copied session reads
548
+ as an assistant that edits files by describing the edit ("let me
549
+ validate" / "valid, now reload and test") with nothing to show it ever
550
+ did. Resumed, the model matches that style and keeps narrating work it
551
+ never performs — the "it thought it made the edits but they all failed"
552
+ failure, which is really the tools never being called at all.
553
+
554
+ The notice is appended rather than prepended: on a long imported
555
+ transcript the first message is the first thing context truncation
556
+ drops, and the last message is the one a resuming model is reading from.
557
+ Returns None when there is nothing the transcript could mislead about,
558
+ or nothing to mislead (an empty copy stays empty).
559
+ """
560
+ context = doc.get('context')
561
+ if not isinstance(context, list) or not context:
562
+ return None
563
+ source = str(doc.get('source') or '')
564
+ src_agent = source.split('/', 1)[0]
565
+ tools = doc.get('tool_calls')
566
+ n_tools = len(tools) if isinstance(tools, list) else 0
567
+ # Evidence decides. Either the envelope recorded tool activity that the
568
+ # spine cannot carry, or it names a session in a *different known*
569
+ # agent, which is a cross-agent copy by definition. A bare '-' or a
570
+ # file path names no session, so nothing was demonstrably dropped.
571
+ cross = (src_agent in REGISTRY and src_agent != self.name)
572
+ if not (n_tools or cross):
573
+ return None
574
+ last = context[-1] if isinstance(context[-1], dict) else {}
575
+ if self.IMPORT_NOTICE_TAG in str(last.get('content') or ''):
576
+ return None # already an imported copy; don't stack notices
577
+
578
+ named = source if src_agent in REGISTRY else 'another agent'
579
+ lost = (f"{n_tools} tool call(s) it recorded, and every result they "
580
+ f"produced, were left behind" if n_tools else
581
+ "any tool calls it made, and every result they produced, were "
582
+ "left behind")
583
+ if src_agent == self.name:
584
+ provenance = (f"This session was re-seeded from its own transcript in "
585
+ f"{source} by `ctools ccopy`.")
586
+ else:
587
+ provenance = (f"This session was copied into {self.name} from "
588
+ f"{named} by `ctools ccopy`.")
589
+ text = (
590
+ f"<{self.IMPORT_NOTICE_TAG}>\n"
591
+ f"{provenance} It carries the conversation's text only: {lost}.\n\n"
592
+ f"So nothing above as edited, written, created, deleted, run, printed, or "
593
+ f"tested has happened in this session. Treat those as unverified history, "
594
+ f"not as the current state of the files: an assistant turn that says it "
595
+ f"made a change is evidence that a change was *wanted*, nothing more. "
596
+ f"Re-read the files you need with your own tools before acting, and make "
597
+ f"every change with a real tool call.\n"
598
+ f"</{self.IMPORT_NOTICE_TAG}>")
599
+ return Message(role='user', content=text)
600
+
532
601
  def _from_common_messages(self, doc: Dict[str, object],
533
602
  context: List[dict]) -> List[Message]:
534
603
  """Build the Message list a common import seeds from.
@@ -1811,6 +1880,58 @@ class OpencodeAgent(SqliteAgent):
1811
1880
  return [Message(role=role, content=text)
1812
1881
  for _, role, text in self._rows(session_id) if text]
1813
1882
 
1883
+ def tool_calls(self, session_id: str) -> List[dict]:
1884
+ """opencode's ``tool`` parts, in conversation order.
1885
+
1886
+ The text projection drops these, so they are exactly what a copy loses;
1887
+ exporting name and count is what lets an imported session state how much
1888
+ of its own history is missing. ``msg`` indexes the conversation the way
1889
+ :meth:`messages` numbers it, matching Claude Code's tool_calls.
1890
+ """
1891
+ conn = self.connect()
1892
+ if conn is None:
1893
+ raise SessionNotFound(self.name, session_id)
1894
+ try:
1895
+ cursor = conn.cursor()
1896
+ cursor.execute('''
1897
+ SELECT id FROM message WHERE session_id = ? ORDER BY time_created
1898
+ ''', (session_id,))
1899
+ msg_ids = [row[0] for row in cursor.fetchall()]
1900
+ out: List[dict] = []
1901
+ conv = -1
1902
+ for msg_id in msg_ids:
1903
+ cursor.execute('''
1904
+ SELECT data FROM part WHERE message_id = ? ORDER BY time_created
1905
+ ''', (msg_id,))
1906
+ has_text = False
1907
+ tools: List[dict] = []
1908
+ for (part_data,) in cursor.fetchall():
1909
+ try:
1910
+ part = json.loads(part_data)
1911
+ except (json.JSONDecodeError, TypeError):
1912
+ continue
1913
+ if not isinstance(part, dict):
1914
+ continue
1915
+ if part.get('type') == 'text' and part.get('text'):
1916
+ has_text = True
1917
+ elif part.get('type') == 'tool':
1918
+ tools.append(part)
1919
+ if has_text:
1920
+ conv += 1
1921
+ for part in tools:
1922
+ state = part.get('state')
1923
+ state = state if isinstance(state, dict) else {}
1924
+ out.append({
1925
+ 'name': str(part.get('tool') or 'tool'),
1926
+ 'summary': _summarize_args(state.get('input') or {}),
1927
+ 'msg': max(conv, 0),
1928
+ })
1929
+ return out
1930
+ except sqlite3.Error:
1931
+ return []
1932
+ finally:
1933
+ conn.close()
1934
+
1814
1935
  def raw_records(self, session_id: str) -> List[dict]:
1815
1936
  """Each message's verbatim `message.data` plus its `part` rows, in order."""
1816
1937
  conn = self.connect()
@@ -10,13 +10,13 @@ By default the output is a JSON list of {role, content} objects (one array per
10
10
  session) — the llcat/OpenAI spine — so it is importable and pipeable: feed it to
11
11
  jq, to another tool, or back into a session. Tool calls are interleaved as
12
12
  {role: "tool"} entries after the turn that made them. Use --text for a
13
- human-readable transcript, or --raw for the lossless envelope (the context plus
13
+ model-readable markdown transcript (each message as '**role**:' then content), or --raw for the lossless envelope (the context plus
14
14
  the source agent's verbatim records, its tool calls, and session metadata).
15
15
 
16
16
  Usage:
17
17
  ccat opencode/ses_abc123 # print one conversation as JSON
18
18
  ccat opencode/ses_a opencode/ses_b # several, each a JSON array
19
- ccat --text opencode/ses_abc # human-readable transcript
19
+ ccat --text opencode/ses_abc # markdown transcript (**role**: / content)
20
20
  ccat --raw opencode/ses_abc > session.json # lossless envelope
21
21
  ccat ssh://chris@remote/opencode/ses_a # a conversation on another host
22
22
  ccat opencode/ses_abc | jq . # it is JSON, so pipe it anywhere
@@ -60,24 +60,30 @@ _DEFAULT_ROLE = ('msg', 'bold')
60
60
 
61
61
 
62
62
  def _render_transcript(messages: List[dict], color: bool) -> None:
63
- """Print one conversation as a readable transcript on stdout.
64
-
65
- Each message becomes ` <role> <content>`; the role is a short dimmed
66
- label in a fixed column so the content lines up.
63
+ """Print one conversation as a model-readable markdown transcript.
64
+
65
+ Each message becomes ``**<role>**:\n<content>`` — the role in bold
66
+ markdown (so it is parseable for a model, not just a human) followed by a
67
+ blank line. When ``color`` is set the role line is styled via a rich
68
+ console (``force_terminal`` so ``--color always`` works even when piped).
69
+ Content is printed as-is: markup and syntax highlighting are off so a
70
+ transcript that itself contains ``**`` or ``[red]`` is not mangled.
67
71
  """
68
- width = 9 # len("assistant")
72
+ if not color:
73
+ for m in messages:
74
+ role = m.get('role', '')
75
+ label, _style = _ROLE.get(role, _DEFAULT_ROLE)
76
+ print(f'**{label}**:')
77
+ print(m.get('content', ''))
78
+ print()
79
+ return
80
+ console = Console(force_terminal=True, highlight=False, markup=False)
69
81
  for m in messages:
70
82
  role = m.get('role', '')
71
83
  label, style = _ROLE.get(role, _DEFAULT_ROLE)
72
- if color:
73
- line = Text()
74
- line.append(' ')
75
- line.append(f'{label:<{width}}', style=style)
76
- line.append(' ')
77
- line.append(m.get('content', ''))
78
- print(line)
79
- else:
80
- print(f' {label:<{width}} {m.get("content", "")}')
84
+ console.print(Text(f'**{label}**:', style=style))
85
+ console.print(m.get('content', ''))
86
+ console.print()
81
87
 
82
88
 
83
89
  def _merge_tool_calls(messages: List[dict],
@@ -228,7 +234,7 @@ def build_parser() -> argparse.ArgumentParser:
228
234
  "(one array per session) — the llcat/OpenAI spine — so it "
229
235
  "can be imported or piped to another tool. Tool calls are "
230
236
  "interleaved as {role: 'tool'} entries after the turn that "
231
- "made them. Pass --text for a readable transcript instead, "
237
+ "made them. Pass --text for a markdown transcript instead, "
232
238
  "or --raw for the lossless envelope (the context plus the "
233
239
  "source agent's verbatim records, its tool calls, and "
234
240
  "session metadata). Several sessions print in order, "
@@ -239,7 +245,8 @@ def build_parser() -> argparse.ArgumentParser:
239
245
  help="One or more sessions to print (agent/session_id, "
240
246
  "or ssh://[user@]host[:port]/agent/session_id)")
241
247
  parser.add_argument("-t", "--text", action="store_true", dest="text_out",
242
- help="Print a human-readable transcript instead of JSON")
248
+ help=("Print a markdown transcript (each message as "
249
+ "'**role**:' then the content) instead of JSON"))
243
250
  parser.add_argument("-r", "--raw", action="store_true", dest="raw_out",
244
251
  help=("Print the lossless envelope: context plus the "
245
252
  "agent's verbatim records and session metadata "
@@ -86,9 +86,13 @@ def export_common(agent: Agent, session_id: str) -> dict:
86
86
  return agent.to_common(session_id)
87
87
 
88
88
 
89
- def import_common(agent: Agent, doc: dict) -> str:
90
- """Import a common-format envelope into a NEW session in `agent`; return id."""
91
- return agent.from_common(doc)
89
+ def import_common(agent: Agent, doc: dict, notice: bool = True) -> str:
90
+ """Import a common-format envelope into a NEW session in `agent`; return id.
91
+
92
+ ``notice`` keeps the import notice that tells a resumed model its tool
93
+ history was not copied (see Agent.import_notice).
94
+ """
95
+ return agent.from_common(doc, notice=notice)
92
96
 
93
97
 
94
98
  def common_copy(source: Agent, source_id: str, destination: Agent) -> Tuple[str, int]:
@@ -536,6 +540,10 @@ def build_parser() -> argparse.ArgumentParser:
536
540
  help="Seed the new session for the current directory "
537
541
  "instead of the source session's working "
538
542
  "directory")
543
+ parser.add_argument("--no-import-notice", action="store_true",
544
+ dest="no_import_notice",
545
+ help=("Do not append the notice that tells a copied "
546
+ "session its tool calls were not carried over"))
539
547
  parser.add_argument("-v", "--verbose", action="store_true",
540
548
  help="Verbose output")
541
549
  version_option(parser, "ccopy")
@@ -708,7 +716,8 @@ def main(argv=None) -> int:
708
716
  if dst_remote is None:
709
717
  guard = guard_write(dst_agent, note="ccopy")
710
718
  try:
711
- new_id = import_common(dst_agent, common_doc)
719
+ new_id = import_common(dst_agent, common_doc,
720
+ notice=not args.no_import_notice)
712
721
  except BaseException:
713
722
  guard.rollback()
714
723
  raise
@@ -733,11 +742,22 @@ def main(argv=None) -> int:
733
742
  f"write: {backup_path}[/dim]")
734
743
  console.print(f"[dim]Restore it with: crecover restore {dst_ref} "
735
744
  f"{backup_path}[/dim]")
745
+ # Ask the destination whether it actually annotated the copy, so the note
746
+ # below can never drift from what was written.
747
+ noted = (dst_remote is None and not args.no_import_notice
748
+ and dst_agent.import_notice(common_doc) is not None)
736
749
  resume = get_resume_command(dst_ref, new_id)
737
750
  if resume:
738
751
  suffix = f" on {dst_remote.target}" if dst_remote else ""
739
752
  console.print(f"[green]Resume it{suffix} with:[/green]")
740
753
  console.print(f" {resume}")
754
+ if noted:
755
+ # Advisory, on stderr: stdout keeps the id/resume lines parseable.
756
+ err_console.print("[dim]Copied text only — the source's tool calls and "
757
+ "their results are not in the new session, so it opens "
758
+ "with a notice telling the model to re-read files "
759
+ "rather than trust that narration (--no-import-notice "
760
+ "to suppress).[/dim]")
741
761
  return 0
742
762
 
743
763
 
@@ -40,7 +40,7 @@ from ctools.lib import format_datetime, format_size, get_formatter, install_clea
40
40
  from ctools.pathfilter import (canonical_dir as _canonical_dir,
41
41
  looks_like_path as _looks_like_path,
42
42
  session_matches_dir as _session_path_matches)
43
- from ctools.resume import _resolve as _resume_resolve
43
+ from ctools.resume import _resolve as _resume_resolve, parse_ref_args
44
44
 
45
45
  __all__ = ['app']
46
46
 
@@ -908,11 +908,11 @@ def build_parser() -> argparse.ArgumentParser:
908
908
  parser.add_argument("-o", "--output",
909
909
  help=("Select output fields (comma-separated). "
910
910
  "Use 'help' to list available fields."))
911
- parser.add_argument("--resume-command", nargs=2,
912
- metavar=("AGENT", "SESSION"),
913
- help=("Print the resume command for AGENT/SESSION "
914
- "(with the cd into the session's recorded cwd) "
915
- "and exit, without exec-ing it"))
911
+ parser.add_argument("--resume-command", nargs="+", metavar="AGENT/SESSION",
912
+ help=("Print the resume command for a session "
913
+ "(AGENT/SESSION, as listed; or AGENT SESSION) "
914
+ "with the cd into its recorded cwd, and exit "
915
+ "without exec-ing it"))
916
916
  version_option(parser, "cdir")
917
917
  return parser
918
918
 
@@ -925,8 +925,10 @@ def main(argv=None) -> int:
925
925
  return v
926
926
 
927
927
  if args.resume_command:
928
- agent_name, session_id = args.resume_command
929
- cmd, cwd, err = _resume_resolve(agent_name, session_id)
928
+ # Same accepted shapes as `ctools resume`, so a listed row pastes here too.
929
+ agent_name, session_id, err = parse_ref_args(args.resume_command)
930
+ if not err:
931
+ cmd, cwd, err = _resume_resolve(agent_name, session_id)
930
932
  if err:
931
933
  console.print(f"[red]{err}[/red]")
932
934
  return 1