handoff-cli 0.3.0__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.
cli/commands/resume.py ADDED
@@ -0,0 +1,179 @@
1
+ """handoff resume command.
2
+
3
+ Unifies "reopen a past conversation" into one verb, keyed by seq (or run-id):
4
+
5
+ handoff resume <seq> — interactive: drop into `claude --resume`
6
+ handoff resume <seq> - <<'EOF' ... — non-interactive: dispatch a new task to
7
+ handoff resume <seq> --text "..." that same conversation (claude -p --resume),
8
+ running through the normal run pipeline.
9
+
10
+ The seq → session mapping comes from the runs table: the selected row's
11
+ `session_id` is the underlying claude conversation. `--resume` does not fork, so
12
+ the original seq stays a stable handle — keep using it to add more turns.
13
+ """
14
+
15
+ import os
16
+ import sys
17
+ import shlex
18
+
19
+ from ..core import get_db, find_run, short_path, row_value
20
+ from ..backend import set_backend_env, build_resume_args, resolve_backend_model
21
+ from ..config import Config
22
+
23
+
24
+ def cmd_resume(argv: list[str], config: Config):
25
+ """handoff resume [<run-id|seq>] [--backend <name>] [--pro] [--cwd <dir>]
26
+ [(<input-file|-> | --text <prompt...>)]."""
27
+ pro = False
28
+ cwd = ""
29
+ backend_arg = ""
30
+ selector = ""
31
+ input_src = ""
32
+ text_mode = False
33
+ text_parts = []
34
+ have_selector = False
35
+
36
+ i = 0
37
+ while i < len(argv):
38
+ a = argv[i]
39
+ if a == "-":
40
+ input_src = "-"
41
+ elif a == "--cwd":
42
+ i += 1
43
+ if i >= len(argv):
44
+ print("handoff resume: --cwd requires a value", file=sys.stderr)
45
+ sys.exit(2)
46
+ cwd = argv[i]
47
+ elif a == "--backend":
48
+ i += 1
49
+ if i >= len(argv):
50
+ print("handoff resume: --backend requires a value", file=sys.stderr)
51
+ sys.exit(2)
52
+ backend_arg = argv[i]
53
+ elif a.startswith("--backend="):
54
+ backend_arg = a.split("=", 1)[1]
55
+ elif a == "--text":
56
+ text_mode = True
57
+ if input_src:
58
+ print("handoff resume: --text cannot be combined with an input file", file=sys.stderr)
59
+ sys.exit(2)
60
+ if i + 1 >= len(argv):
61
+ print("handoff resume: --text requires a value", file=sys.stderr)
62
+ sys.exit(2)
63
+ if argv[i + 1] == "--":
64
+ text_parts.extend(argv[i + 2:])
65
+ else:
66
+ text_parts.extend(argv[i + 1:])
67
+ break
68
+ elif a.startswith("--text="):
69
+ text_mode = True
70
+ if input_src:
71
+ print("handoff resume: --text cannot be combined with an input file", file=sys.stderr)
72
+ sys.exit(2)
73
+ text_parts.append(a.split("=", 1)[1])
74
+ text_parts.extend(argv[i + 1:])
75
+ break
76
+ elif a == "--pro":
77
+ pro = True
78
+ elif a in ("-h", "--help"):
79
+ from ..main import usage
80
+ usage()
81
+ sys.exit(0)
82
+ elif a.startswith("-") and a != "-":
83
+ print(f"handoff resume: unknown option {a}", file=sys.stderr)
84
+ sys.exit(2)
85
+ else:
86
+ # First bare positional is the selector (seq/run-id); a second one is
87
+ # an input file (prompt source).
88
+ if not have_selector:
89
+ selector = a
90
+ have_selector = True
91
+ elif text_mode:
92
+ print("handoff resume: --text cannot be combined with an input file", file=sys.stderr)
93
+ sys.exit(2)
94
+ else:
95
+ input_src = a
96
+ i += 1
97
+
98
+ # Resolve the target conversation.
99
+ conn = get_db()
100
+ row = find_run(conn, selector or None)
101
+
102
+ if not row:
103
+ conn.close()
104
+ print("handoff resume: no run found", file=sys.stderr)
105
+ sys.exit(1)
106
+
107
+ session_id = row_value(row, "session_id", "") or row["uuid"]
108
+ row_cwd = row["cwd"]
109
+ saved_backend = row_value(row, "backend", "") or ""
110
+
111
+ # Decide prompt source → interactive vs continuation.
112
+ prompt_text = None
113
+ if text_mode:
114
+ prompt_text = " ".join(text_parts)
115
+ if not prompt_text:
116
+ print("handoff resume: --text requires a non-empty value", file=sys.stderr)
117
+ sys.exit(2)
118
+ elif input_src == "-" or (not input_src and not sys.stdin.isatty()):
119
+ prompt_text = sys.stdin.read()
120
+ elif input_src:
121
+ if not os.path.isfile(input_src):
122
+ print(f"handoff resume: input file not found: {input_src}", file=sys.stderr)
123
+ sys.exit(2)
124
+ with open(input_src) as f:
125
+ prompt_text = f.read()
126
+
127
+ if not cwd:
128
+ cwd = row_cwd
129
+ if not os.path.isdir(cwd):
130
+ print(f"handoff resume: cwd not found: {cwd}", file=sys.stderr)
131
+ sys.exit(2)
132
+
133
+ # A continuation must stay on the conversation's original backend — the
134
+ # session id only means something to the CLI that created it.
135
+ if backend_arg and saved_backend and backend_arg != saved_backend:
136
+ print(
137
+ f"handoff resume: this conversation belongs to backend '{saved_backend}'; "
138
+ f"it cannot be resumed with --backend {backend_arg}. "
139
+ f"Use `handoff run --backend {backend_arg}` to start a new conversation.",
140
+ file=sys.stderr,
141
+ )
142
+ sys.exit(2)
143
+ backend_name = saved_backend or backend_arg or config.default_backend
144
+
145
+ if prompt_text is None:
146
+ # Interactive: reopen the conversation in claude (replaces this process).
147
+ conn.close()
148
+ _resume_interactive(config, backend_name, session_id, cwd, pro)
149
+ else:
150
+ # Non-interactive: dispatch a new turn through the run pipeline.
151
+ conn.close()
152
+ from .run import _execute
153
+ _execute(cwd, prompt_text, backend_name, pro, config, resume_session_id=session_id)
154
+
155
+
156
+ def _resume_interactive(config: Config, backend_name: str, session_id: str, cwd: str, pro: bool):
157
+ backend_cfg = config.get_backend(backend_name)
158
+ if not backend_cfg:
159
+ print(
160
+ f"handoff: unknown backend '{backend_name}'. "
161
+ f"Available: {', '.join(sorted(config.backends.keys()))}",
162
+ file=sys.stderr,
163
+ )
164
+ sys.exit(2)
165
+
166
+ model = resolve_backend_model(backend_cfg, pro)
167
+ backend_cfg["_resolved_model"] = model
168
+ backend_cfg["_system_prompt"] = config.system_prompt
169
+
170
+ set_backend_env(backend_cfg, model, backend_cfg.get("pro_model", ""))
171
+
172
+ args = build_resume_args(
173
+ backend_cfg, session_id,
174
+ pro_model=backend_cfg.get("pro_model", ""),
175
+ )
176
+
177
+ print(f"cd {short_path(cwd)}; {' '.join(shlex.quote(p) for p in args)}", file=sys.stderr)
178
+ os.chdir(cwd)
179
+ os.execvp(args[0], args)
cli/commands/run.py ADDED
@@ -0,0 +1,211 @@
1
+ """handoff run command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ import datetime
8
+
9
+ from ..core import get_db, create_run, task_paths, UUID_RE
10
+ from ..backend import (
11
+ set_backend_env,
12
+ build_args,
13
+ backend_type,
14
+ ensure_backend_token_ready,
15
+ resolve_backend_model,
16
+ wrap_with_pty,
17
+ )
18
+ from ..stream import execute_run
19
+ from ..config import Config
20
+
21
+
22
+ def cmd_run(argv: list[str], config: Config):
23
+ """handoff run [--backend <name>] [--cwd <dir>] [--pro] (<input-file|-> | --text <prompt...>)."""
24
+ pro = False
25
+ cwd = ""
26
+ backend_arg = ""
27
+ input_src = ""
28
+ text_mode = False
29
+ text_parts = []
30
+
31
+ i = 0
32
+ while i < len(argv):
33
+ a = argv[i]
34
+ if a == "-":
35
+ input_src = "-"
36
+ elif a == "--cwd":
37
+ i += 1
38
+ if i >= len(argv):
39
+ print("handoff run: --cwd requires a value", file=sys.stderr)
40
+ sys.exit(2)
41
+ cwd = argv[i]
42
+ elif a == "--backend":
43
+ i += 1
44
+ if i >= len(argv):
45
+ print("handoff run: --backend requires a value", file=sys.stderr)
46
+ sys.exit(2)
47
+ backend_arg = argv[i]
48
+ elif a.startswith("--backend="):
49
+ backend_arg = a.split("=", 1)[1]
50
+ elif a == "--text":
51
+ text_mode = True
52
+ if input_src:
53
+ print("handoff run: --text cannot be combined with an input file", file=sys.stderr)
54
+ sys.exit(2)
55
+ if i + 1 >= len(argv):
56
+ print("handoff run: --text requires a value", file=sys.stderr)
57
+ sys.exit(2)
58
+ if argv[i + 1] == "--":
59
+ text_parts.extend(argv[i + 2:])
60
+ else:
61
+ text_parts.extend(argv[i + 1:])
62
+ break
63
+ elif a.startswith("--text="):
64
+ text_mode = True
65
+ if input_src:
66
+ print("handoff run: --text cannot be combined with an input file", file=sys.stderr)
67
+ sys.exit(2)
68
+ text_parts.append(a.split("=", 1)[1])
69
+ text_parts.extend(argv[i + 1:])
70
+ break
71
+ elif a == "--pro":
72
+ pro = True
73
+ elif a in ("-h", "--help"):
74
+ from ..main import usage
75
+ usage()
76
+ sys.exit(0)
77
+ elif a == "--":
78
+ i += 1
79
+ if i < len(argv):
80
+ input_src = argv[i]
81
+ break
82
+ elif a.startswith("-"):
83
+ print(f"handoff run: unknown option {a}", file=sys.stderr)
84
+ sys.exit(2)
85
+ else:
86
+ if text_mode:
87
+ print("handoff run: --text cannot be combined with an input file", file=sys.stderr)
88
+ sys.exit(2)
89
+ input_src = a
90
+ i += 1
91
+
92
+ if not cwd:
93
+ cwd = os.getcwd()
94
+ if not os.path.isdir(cwd):
95
+ print(f"handoff run: cwd not found: {cwd}", file=sys.stderr)
96
+ sys.exit(2)
97
+
98
+ if text_mode:
99
+ if not text_parts:
100
+ print("handoff run: --text requires a value", file=sys.stderr)
101
+ sys.exit(2)
102
+ prompt_text = " ".join(text_parts)
103
+ if not prompt_text:
104
+ print("handoff run: --text requires a non-empty value", file=sys.stderr)
105
+ sys.exit(2)
106
+ elif input_src == "-" or (not input_src and not sys.stdin.isatty()):
107
+ prompt_text = sys.stdin.read()
108
+ elif input_src:
109
+ if not os.path.isfile(input_src):
110
+ print(f"handoff run: input file not found: {input_src}", file=sys.stderr)
111
+ sys.exit(2)
112
+ with open(input_src) as f:
113
+ prompt_text = f.read()
114
+ else:
115
+ print("handoff run: input file required, or use --text <prompt...> / pipe via '-'", file=sys.stderr)
116
+ sys.exit(2)
117
+
118
+ backend_name = backend_arg or config.default_backend
119
+
120
+ _execute(cwd, prompt_text, backend_name, pro, config)
121
+
122
+
123
+ def _execute(
124
+ cwd: str,
125
+ prompt_text: str,
126
+ backend_name: str,
127
+ pro: bool,
128
+ config: Config,
129
+ resume_session_id: str | None = None,
130
+ ):
131
+ """Shared execution path for file, stdin, and --text run modes.
132
+
133
+ When `resume_session_id` is given, the new run is appended to that existing
134
+ claude conversation (`claude -p ... --resume <id>`) rather than starting a
135
+ fresh session; the new row still gets its own run_id/seq/files but shares the
136
+ session_id. Used by `handoff resume <seq> <prompt>`.
137
+ """
138
+ backend_cfg = config.get_backend(backend_name)
139
+ if not backend_cfg:
140
+ print(
141
+ f"handoff: unknown backend '{backend_name}'. "
142
+ f"Available: {', '.join(sorted(config.backends.keys()))}",
143
+ file=sys.stderr,
144
+ )
145
+ sys.exit(2)
146
+
147
+ ensure_backend_token_ready(backend_name, backend_cfg, config.user_config_path)
148
+
149
+ conn = get_db()
150
+ run_id, uid, jsonl_path = create_run(
151
+ conn, cwd, prompt_text, backend_name, session_id=resume_session_id
152
+ )
153
+ conn.commit()
154
+
155
+ # tasks dir files
156
+ prompt_path, out_path, result_path = task_paths(run_id)
157
+
158
+ with open(prompt_path, "w") as pf:
159
+ pf.write(prompt_text)
160
+
161
+ # Resolve model
162
+ model = resolve_backend_model(backend_cfg, pro)
163
+ if not model:
164
+ print(
165
+ f"handoff: backend '{backend_name}' resolves no model. "
166
+ f"Set backends.{backend_name}.model in {config.user_config_path} "
167
+ f"(pre-0.3 configs carried this in the now-removed top-level default_model).",
168
+ file=sys.stderr,
169
+ )
170
+ sys.exit(2)
171
+ backend_cfg["_resolved_model"] = model
172
+ backend_cfg["_system_prompt"] = config.system_prompt
173
+
174
+ btype = backend_type(backend_cfg)
175
+ set_backend_env(backend_cfg, model, backend_cfg.get("pro_model", ""))
176
+ if resume_session_id:
177
+ session_id = resume_session_id
178
+ elif btype == "claude":
179
+ session_id = uid if UUID_RE.match(uid) else None
180
+ else:
181
+ # codex assigns the thread id itself; it arrives via the
182
+ # thread.started event and is persisted by execute_run
183
+ session_id = None
184
+
185
+ print(f"RESULT={result_path}")
186
+ print(f"RESULT={result_path}", file=sys.stderr)
187
+
188
+ ts = datetime.datetime.now().strftime("%H:%M:%S")
189
+ label = "resume" if resume_session_id else "start"
190
+ print(f"{ts} {label}\tSESSION={session_id or 'pending'}", file=sys.stderr)
191
+
192
+ # build backend command (wrapped in script for pty when the type needs it)
193
+ backend_cmd = build_args(
194
+ backend_cfg, prompt_text, session_id,
195
+ model=model,
196
+ pro_model=backend_cfg.get("pro_model", ""),
197
+ resume=bool(resume_session_id),
198
+ cwd=cwd,
199
+ )
200
+ cmd = wrap_with_pty(backend_cfg, backend_cmd)
201
+
202
+ execute_run(
203
+ cwd,
204
+ prompt_text,
205
+ cmd,
206
+ conn,
207
+ uid,
208
+ jsonl_path,
209
+ (prompt_path, out_path, result_path),
210
+ backend_type=btype,
211
+ )
cli/commands/tail.py ADDED
@@ -0,0 +1,48 @@
1
+ """handoff tail command."""
2
+
3
+ import sys
4
+ import os
5
+
6
+ from ..core import get_db, find_run, short_path, prompt_prefix, task_paths
7
+
8
+
9
+ def cmd_tail(argv: list[str], config=None):
10
+ """handoff tail [<run-id|seq>]"""
11
+ selector = ""
12
+ for a in argv:
13
+ if a in ("-h", "--help"):
14
+ from ..main import usage
15
+ usage()
16
+ sys.exit(0)
17
+ elif a.startswith("-"):
18
+ print(f"handoff tail: unknown option {a}", file=sys.stderr)
19
+ sys.exit(2)
20
+ else:
21
+ selector = a
22
+
23
+ conn = get_db()
24
+ row = find_run(conn, selector or None)
25
+ conn.close()
26
+
27
+ if not row:
28
+ print("handoff tail: no run found", file=sys.stderr)
29
+ sys.exit(1)
30
+
31
+ jsonl_path = row["jsonl_path"]
32
+ if not os.path.exists(jsonl_path):
33
+ print(f"handoff tail: jsonl not found: {jsonl_path}", file=sys.stderr)
34
+ sys.exit(1)
35
+
36
+ run_id = row["run_id"]
37
+ prompt_path, out_path, result_path = task_paths(run_id)
38
+
39
+ run_info = {
40
+ "run_id": run_id,
41
+ "date": row["created_at"],
42
+ "cwd": short_path(row["cwd"]),
43
+ "uuid": row["uuid"],
44
+ "out_path": out_path,
45
+ }
46
+
47
+ from ..jsonl_viewer import run_tail
48
+ run_tail(jsonl_path, prompt_path, result_path, run_info)