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/__init__.py +3 -0
- cli/backend.py +224 -0
- cli/backend_types.yaml +91 -0
- cli/commands/__init__.py +0 -0
- cli/commands/env.py +30 -0
- cli/commands/init.py +129 -0
- cli/commands/list.py +81 -0
- cli/commands/resume.py +179 -0
- cli/commands/run.py +211 -0
- cli/commands/tail.py +48 -0
- cli/config.py +351 -0
- cli/core.py +302 -0
- cli/jsonl_parser.py +182 -0
- cli/jsonl_viewer.py +440 -0
- cli/main.py +98 -0
- cli/skills/handoff-codex/SKILL.md +77 -0
- cli/skills/handoff-ds/SKILL.md +77 -0
- cli/skills/handoff-ds.toml +52 -0
- cli/skills/handoff-opus/SKILL.md +77 -0
- cli/stream.py +286 -0
- cli/tui.py +317 -0
- cli/user_config_template.yaml +31 -0
- handoff_cli-0.3.0.dist-info/METADATA +7 -0
- handoff_cli-0.3.0.dist-info/RECORD +26 -0
- handoff_cli-0.3.0.dist-info/WHEEL +4 -0
- handoff_cli-0.3.0.dist-info/entry_points.txt +2 -0
cli/__init__.py
ADDED
cli/backend.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Backend resolution and command building for handoff.
|
|
2
|
+
|
|
3
|
+
Given a resolved backend configuration (merged from type_defaults[<type>] + the
|
|
4
|
+
backend's own fields in YAML), this module provides:
|
|
5
|
+
|
|
6
|
+
- set_backend_env(backend, ...): Set environment variables for the backend CLI
|
|
7
|
+
- build_args(backend, ...): Build the CLI argument list (claude or codex)
|
|
8
|
+
|
|
9
|
+
Backend types:
|
|
10
|
+
claude — `claude -p ... --output-format stream-json` against any
|
|
11
|
+
anthropic-compatible endpoint; identity flags combine with
|
|
12
|
+
session_flags (`--session-id` fresh / `--resume` continuation).
|
|
13
|
+
codex — `codex exec --json ...`; a fresh run takes session_flags alone
|
|
14
|
+
(codex assigns the thread id itself), a continuation uses
|
|
15
|
+
continue_id_flags *instead of* session_flags because
|
|
16
|
+
`codex exec resume` accepts a different flag set.
|
|
17
|
+
|
|
18
|
+
Placeholder substitution:
|
|
19
|
+
{prompt} — the prompt text
|
|
20
|
+
{session_id} — session UUID / codex thread id
|
|
21
|
+
{system_prompt} — configured system prompt
|
|
22
|
+
{model} — resolved model name (backend's model or pro_model)
|
|
23
|
+
{pro_model} — backend's pro_model
|
|
24
|
+
{cwd} — working directory of the run
|
|
25
|
+
{home} — $HOME
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import os
|
|
31
|
+
import sys
|
|
32
|
+
from typing import Optional
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _substitute(text: str, ctx: dict) -> str:
|
|
36
|
+
"""Replace {placeholders} in a string using ctx dict."""
|
|
37
|
+
return text.format(**ctx)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _resolve_env_val(val, ctx: dict):
|
|
41
|
+
"""Resolve a config value, handling strings with placeholders."""
|
|
42
|
+
if isinstance(val, str):
|
|
43
|
+
resolved = _substitute(val, ctx)
|
|
44
|
+
return os.path.expanduser(resolved)
|
|
45
|
+
return val
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _base_ctx(backend: dict, model: str = "", pro_model: str = "", cwd: str = "") -> dict:
|
|
49
|
+
return {
|
|
50
|
+
"prompt": "",
|
|
51
|
+
"session_id": "",
|
|
52
|
+
"system_prompt": backend.get("_system_prompt", ""),
|
|
53
|
+
"model": model or backend.get("_resolved_model", ""),
|
|
54
|
+
"pro_model": pro_model or backend.get("pro_model", ""),
|
|
55
|
+
# legacy alias kept so old user configs with {default_model} don't crash
|
|
56
|
+
"default_model": model or backend.get("_resolved_model", ""),
|
|
57
|
+
"cwd": cwd,
|
|
58
|
+
"home": os.path.expanduser("~"),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def backend_type(backend: dict) -> str:
|
|
63
|
+
return backend.get("type", "claude")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Inherited values of these would silently redirect a claude-type backend to
|
|
67
|
+
# whatever endpoint/model the *calling* session uses. handoff is routinely
|
|
68
|
+
# invoked from inside another claude session (e.g. dispatching opus from a
|
|
69
|
+
# DeepSeek-proxied session), so that environment is always polluted — a
|
|
70
|
+
# claude-type run must see only what its backend declares.
|
|
71
|
+
_CLAUDE_HERMETIC_VARS = (
|
|
72
|
+
"ANTHROPIC_BASE_URL",
|
|
73
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
74
|
+
"ANTHROPIC_API_KEY",
|
|
75
|
+
"ANTHROPIC_MODEL",
|
|
76
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
77
|
+
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
78
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
79
|
+
"CLAUDE_CODE_SUBAGENT_MODEL",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def set_backend_env(backend: dict, model: str, pro_model: str = ""):
|
|
84
|
+
"""Set environment variables for the backend CLI.
|
|
85
|
+
|
|
86
|
+
Iterates the backend's 'env' mapping, substitutes placeholders,
|
|
87
|
+
and sets each key=value in os.environ. claude-type backends are hermetic:
|
|
88
|
+
known ANTHROPIC_*/model vars are cleared first, so only the backend's own
|
|
89
|
+
env block takes effect.
|
|
90
|
+
"""
|
|
91
|
+
ctx = _base_ctx(backend, model=model, pro_model=pro_model)
|
|
92
|
+
|
|
93
|
+
if backend_type(backend) == "claude":
|
|
94
|
+
for key in _CLAUDE_HERMETIC_VARS:
|
|
95
|
+
os.environ.pop(key, None)
|
|
96
|
+
|
|
97
|
+
env_map = backend.get("env", {})
|
|
98
|
+
for key, val in env_map.items():
|
|
99
|
+
resolved = _resolve_env_val(val, ctx)
|
|
100
|
+
# an empty value (e.g. a model-less legacy backend resolving
|
|
101
|
+
# ANTHROPIC_MODEL="{model}") must not be exported — an empty env var
|
|
102
|
+
# breaks the CLI where an unset one would not
|
|
103
|
+
if resolved == "":
|
|
104
|
+
continue
|
|
105
|
+
os.environ[key] = resolved
|
|
106
|
+
|
|
107
|
+
# claude needs a config dir; codex backends have no ANTHROPIC_*/CLAUDE_* env
|
|
108
|
+
if backend_type(backend) == "claude":
|
|
109
|
+
if not os.environ.get("CLAUDE_CONFIG_DIR"):
|
|
110
|
+
os.environ["CLAUDE_CONFIG_DIR"] = os.path.expanduser("~/.claude")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def build_args(
|
|
114
|
+
backend: dict,
|
|
115
|
+
prompt: str,
|
|
116
|
+
session_id: Optional[str] = None,
|
|
117
|
+
model: Optional[str] = None,
|
|
118
|
+
pro_model: Optional[str] = None,
|
|
119
|
+
resume: bool = False,
|
|
120
|
+
cwd: str = "",
|
|
121
|
+
) -> list[str]:
|
|
122
|
+
"""Build the backend CLI argument list from a resolved backend config.
|
|
123
|
+
|
|
124
|
+
claude type: session_flags carry -p/{prompt}/stream-json/etc.; when a
|
|
125
|
+
session_id is present, `continue_id_flags` (--resume, continuation) or
|
|
126
|
+
`session_id_flags` (--session-id, fresh) are appended on top.
|
|
127
|
+
|
|
128
|
+
codex type: a fresh run is session_flags alone (`codex exec --json ...`;
|
|
129
|
+
codex assigns the thread id itself, reported via the thread.started event).
|
|
130
|
+
A continuation is continue_id_flags alone (`codex exec resume --json <id>
|
|
131
|
+
<prompt>`) because `codex exec resume` rejects --sandbox/-C.
|
|
132
|
+
"""
|
|
133
|
+
ctx = _base_ctx(backend, model=model or "", pro_model=pro_model or "", cwd=cwd)
|
|
134
|
+
ctx["prompt"] = prompt
|
|
135
|
+
ctx["session_id"] = session_id or ""
|
|
136
|
+
|
|
137
|
+
command = _resolve_env_val(backend.get("command") or backend.get("claude_command", "claude"), ctx)
|
|
138
|
+
args = [command]
|
|
139
|
+
|
|
140
|
+
if backend_type(backend) == "codex" and resume and session_id:
|
|
141
|
+
for flag in backend.get("continue_id_flags", []):
|
|
142
|
+
resolved = _resolve_env_val(flag, ctx)
|
|
143
|
+
if resolved:
|
|
144
|
+
args.append(resolved)
|
|
145
|
+
return args
|
|
146
|
+
|
|
147
|
+
for flag in backend.get("session_flags", []):
|
|
148
|
+
resolved = _resolve_env_val(flag, ctx)
|
|
149
|
+
if resolved:
|
|
150
|
+
args.append(resolved)
|
|
151
|
+
|
|
152
|
+
if session_id:
|
|
153
|
+
id_flags_key = "continue_id_flags" if resume else "session_id_flags"
|
|
154
|
+
for flag in backend.get(id_flags_key, []):
|
|
155
|
+
resolved = _resolve_env_val(flag, ctx)
|
|
156
|
+
if resolved:
|
|
157
|
+
args.append(resolved)
|
|
158
|
+
|
|
159
|
+
return args
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def wrap_with_pty(backend: dict, args: list[str]) -> list[str]:
|
|
163
|
+
"""Prefix args with the configured PTY wrapper, if any."""
|
|
164
|
+
pty = backend.get("pty", [])
|
|
165
|
+
if not pty:
|
|
166
|
+
return args
|
|
167
|
+
ctx = _base_ctx(backend)
|
|
168
|
+
return [_resolve_env_val(part, ctx) for part in pty] + args
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def build_resume_args(
|
|
172
|
+
backend: dict,
|
|
173
|
+
session_id: str,
|
|
174
|
+
pro_model: Optional[str] = None,
|
|
175
|
+
) -> list[str]:
|
|
176
|
+
"""Build the interactive resume argument list (`claude --resume` / `codex resume`)."""
|
|
177
|
+
ctx = _base_ctx(backend, pro_model=pro_model or "")
|
|
178
|
+
ctx["session_id"] = session_id or ""
|
|
179
|
+
|
|
180
|
+
command = _resolve_env_val(backend.get("command") or backend.get("claude_command", "claude"), ctx)
|
|
181
|
+
args = [command]
|
|
182
|
+
|
|
183
|
+
for flag in backend.get("resume_flags", []):
|
|
184
|
+
resolved = _resolve_env_val(flag, ctx)
|
|
185
|
+
if resolved:
|
|
186
|
+
args.append(resolved)
|
|
187
|
+
|
|
188
|
+
return args
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def resolve_backend_model(backend: dict, is_pro: bool = False) -> str:
|
|
192
|
+
"""Return the model name for this backend (its own model / pro_model field)."""
|
|
193
|
+
model = backend.get("pro_model" if is_pro else "model") or backend.get("model") or ""
|
|
194
|
+
ctx = {"home": os.path.expanduser("~")}
|
|
195
|
+
return _resolve_env_val(model, ctx) if model else ""
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def ensure_backend_token_ready(backend_name: str, backend: dict, user_config_path: str):
|
|
199
|
+
"""Fail fast when the selected backend declares a token that isn't usable.
|
|
200
|
+
|
|
201
|
+
Only applies to backends whose env carries ANTHROPIC_AUTH_TOKEN (e.g. the
|
|
202
|
+
bundled deepseek). Backends that ride on local login state (opus, codex)
|
|
203
|
+
declare no token and are skipped. An empty value typically means an
|
|
204
|
+
unexpanded ${ENV_VAR} reference (variable not set in the environment).
|
|
205
|
+
"""
|
|
206
|
+
env_map = backend.get("env", {})
|
|
207
|
+
if "ANTHROPIC_AUTH_TOKEN" not in env_map:
|
|
208
|
+
return
|
|
209
|
+
token = env_map.get("ANTHROPIC_AUTH_TOKEN")
|
|
210
|
+
if isinstance(token, str) and token.startswith("<"):
|
|
211
|
+
print(
|
|
212
|
+
f"handoff: backend '{backend_name}' still uses placeholder token {token}. "
|
|
213
|
+
f"Edit {user_config_path} and set a real ANTHROPIC_AUTH_TOKEN.",
|
|
214
|
+
file=sys.stderr,
|
|
215
|
+
)
|
|
216
|
+
sys.exit(2)
|
|
217
|
+
if not token:
|
|
218
|
+
print(
|
|
219
|
+
f"handoff: backend '{backend_name}' has an empty ANTHROPIC_AUTH_TOKEN. "
|
|
220
|
+
f"Set it in {user_config_path}, or export the environment variable it "
|
|
221
|
+
f"references (e.g. DEEPSEEK_API_KEY).",
|
|
222
|
+
file=sys.stderr,
|
|
223
|
+
)
|
|
224
|
+
sys.exit(2)
|
cli/backend_types.yaml
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# handoff backend types — this file defines HOW each backend type is launched.
|
|
2
|
+
# It is part of the program's behaviour and CANNOT be overridden by user config.
|
|
3
|
+
# To understand the complete configuration model, start here.
|
|
4
|
+
#
|
|
5
|
+
# Types:
|
|
6
|
+
# claude — PTY-wrapped `claude -p --output-format stream-json` against any
|
|
7
|
+
# Anthropic-compatible endpoint.
|
|
8
|
+
# codex — `codex exec --json` against the local codex login state.
|
|
9
|
+
#
|
|
10
|
+
# Each type defines the CLI command, PTY wrapper, and flag templates.
|
|
11
|
+
# Placeholders ({model}, {prompt}, {session_id}, {system_prompt}, {cwd}, {home})
|
|
12
|
+
# are substituted at runtime.
|
|
13
|
+
#
|
|
14
|
+
# User-owned configuration (backends, env, models) lives in ~/.handoff/config.yaml
|
|
15
|
+
# (generated by `handoff init`). Run `handoff env` to locate both files.
|
|
16
|
+
|
|
17
|
+
# System prompt appended to every claude invocation (claude-type backends only).
|
|
18
|
+
# This is the one built-in value users CAN override — set `system_prompt:` in
|
|
19
|
+
# ~/.handoff/config.yaml to replace it.
|
|
20
|
+
system_prompt: |
|
|
21
|
+
重要:你收到的是一个完整需求,必须直接执行到底。禁止以下行为:
|
|
22
|
+
1. 不要分析完问题后问用户"要开始改吗?""倾向于哪个方案?"等确认
|
|
23
|
+
2. 不要提出多个方案让用户选择
|
|
24
|
+
3. 不要输出分析报告后停下来等指示
|
|
25
|
+
你需要:理解需求 → 直接修改代码 → 输出结果。需求模糊时按最合理的默认方式执行,不要问。
|
|
26
|
+
|
|
27
|
+
types:
|
|
28
|
+
# ── claude type: PTY-wrapped `claude -p --output-format stream-json` ─────────
|
|
29
|
+
claude:
|
|
30
|
+
command: "claude"
|
|
31
|
+
pty:
|
|
32
|
+
- "script"
|
|
33
|
+
- "-q"
|
|
34
|
+
- "/dev/null"
|
|
35
|
+
session_flags:
|
|
36
|
+
- "-p"
|
|
37
|
+
- "{prompt}"
|
|
38
|
+
- "--dangerously-skip-permissions"
|
|
39
|
+
- "--append-system-prompt"
|
|
40
|
+
- "{system_prompt}"
|
|
41
|
+
- "--output-format"
|
|
42
|
+
- "stream-json"
|
|
43
|
+
- "--verbose"
|
|
44
|
+
- "--include-partial-messages"
|
|
45
|
+
session_id_flags:
|
|
46
|
+
- "--session-id"
|
|
47
|
+
- "{session_id}"
|
|
48
|
+
# Used by `resume <seq> <prompt>` (non-interactive continuation): append a new
|
|
49
|
+
# turn to an existing claude session in print mode. Combined with session_flags
|
|
50
|
+
# (which carry -p/{prompt}/stream-json/etc.) this becomes `claude -p ... --resume <id>`.
|
|
51
|
+
continue_id_flags:
|
|
52
|
+
- "--resume"
|
|
53
|
+
- "{session_id}"
|
|
54
|
+
resume_flags:
|
|
55
|
+
- "--resume"
|
|
56
|
+
- "{session_id}"
|
|
57
|
+
- "--dangerously-skip-permissions"
|
|
58
|
+
|
|
59
|
+
# ── codex type: `codex exec --json` against local codex login ─────────────────
|
|
60
|
+
codex:
|
|
61
|
+
command: "codex"
|
|
62
|
+
pty: []
|
|
63
|
+
# Fresh run: codex exec --json <flags> {prompt}
|
|
64
|
+
session_flags:
|
|
65
|
+
- "exec"
|
|
66
|
+
- "--json"
|
|
67
|
+
- "--skip-git-repo-check"
|
|
68
|
+
- "--sandbox"
|
|
69
|
+
- "workspace-write"
|
|
70
|
+
- "-m"
|
|
71
|
+
- "{model}"
|
|
72
|
+
- "-C"
|
|
73
|
+
- "{cwd}"
|
|
74
|
+
- "{prompt}"
|
|
75
|
+
# codex has no separate "set session id" mode for a fresh run; the id is
|
|
76
|
+
# assigned by codex and reported in the thread.started event.
|
|
77
|
+
session_id_flags: []
|
|
78
|
+
# Non-interactive continuation: codex exec resume --json <session_id> {prompt}.
|
|
79
|
+
# NOTE: `codex exec resume` does not accept --sandbox/-C; it inherits the
|
|
80
|
+
# original session's settings, so this list deliberately omits them.
|
|
81
|
+
continue_id_flags:
|
|
82
|
+
- "exec"
|
|
83
|
+
- "resume"
|
|
84
|
+
- "--json"
|
|
85
|
+
- "--skip-git-repo-check"
|
|
86
|
+
- "{session_id}"
|
|
87
|
+
- "{prompt}"
|
|
88
|
+
# Interactive reopen: `codex resume <session_id>`.
|
|
89
|
+
resume_flags:
|
|
90
|
+
- "resume"
|
|
91
|
+
- "{session_id}"
|
cli/commands/__init__.py
ADDED
|
File without changes
|
cli/commands/env.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""handoff env command — print configuration paths for humans and scripts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def cmd_env(_args):
|
|
10
|
+
"""handoff env — print key configuration paths (no Config init needed)."""
|
|
11
|
+
if _args and _args[0] in ("-h", "--help"):
|
|
12
|
+
print("usage: handoff env")
|
|
13
|
+
print(" Print key configuration paths for use by humans and scripts.")
|
|
14
|
+
return
|
|
15
|
+
|
|
16
|
+
from ..config import user_config_dir, user_config_path
|
|
17
|
+
|
|
18
|
+
# backend_types.yaml lives alongside this file in the package
|
|
19
|
+
backend_types_path = os.path.join(os.path.dirname(__file__), "..", "backend_types.yaml")
|
|
20
|
+
backend_types_path = os.path.abspath(backend_types_path)
|
|
21
|
+
|
|
22
|
+
config = user_config_path()
|
|
23
|
+
tasks = os.path.join(user_config_dir(), "tasks")
|
|
24
|
+
runs = os.path.join(user_config_dir(), "runs")
|
|
25
|
+
|
|
26
|
+
# Print paths unconditionally — env must work even with a broken config.
|
|
27
|
+
print(f"config={config}")
|
|
28
|
+
print(f"backend_types={backend_types_path}")
|
|
29
|
+
print(f"tasks={tasks} # prompt / .out / .result files")
|
|
30
|
+
print(f"runs={runs} # raw jsonl streams")
|
cli/commands/init.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Interactive initializer for handoff."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _pkg_root() -> str:
|
|
10
|
+
"""Absolute path to the cli/ package directory."""
|
|
11
|
+
return os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
12
|
+
|
|
13
|
+
def _repo_root() -> str:
|
|
14
|
+
"""Absolute path to the repo root (for README hint only; may not exist in a wheel install)."""
|
|
15
|
+
return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _home_path(*parts: str) -> str:
|
|
19
|
+
return os.path.join(os.path.expanduser("~"), *parts)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _color(code: str, text: str) -> str:
|
|
23
|
+
if not sys.stdout.isatty() or os.environ.get("NO_COLOR"):
|
|
24
|
+
return text
|
|
25
|
+
return f"\033[{code}m{text}\033[0m"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _planned_writes() -> list[tuple[str, str, str]]:
|
|
29
|
+
skills_dir = os.path.join(_pkg_root(), "skills")
|
|
30
|
+
from ..config import user_config_path
|
|
31
|
+
|
|
32
|
+
plans: list[tuple[str, str, str]] = [
|
|
33
|
+
("config", "write if missing", user_config_path()),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
plans += [
|
|
37
|
+
("hard link", os.path.join(skills_dir, "handoff-ds.toml"), _home_path(".codex", "agents", "handoff-ds.toml")),
|
|
38
|
+
("soft link", os.path.join(skills_dir, "handoff-ds", "SKILL.md"), _home_path(".claude", "skills", "handoff-ds", "SKILL.md")),
|
|
39
|
+
("soft link", os.path.join(skills_dir, "handoff-codex", "SKILL.md"), _home_path(".claude", "skills", "handoff-codex", "SKILL.md")),
|
|
40
|
+
("soft link", os.path.join(skills_dir, "handoff-opus", "SKILL.md"), _home_path(".claude", "skills", "handoff-opus", "SKILL.md")),
|
|
41
|
+
]
|
|
42
|
+
return plans
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _print_plan():
|
|
46
|
+
print(_color("1", "handoff initialization"))
|
|
47
|
+
print("")
|
|
48
|
+
print("The following files and links will be written:")
|
|
49
|
+
for kind, src, dest in _planned_writes():
|
|
50
|
+
if kind == "config":
|
|
51
|
+
if os.path.isfile(dest):
|
|
52
|
+
print(f" config: keep existing {dest}")
|
|
53
|
+
else:
|
|
54
|
+
print(f" config: write {dest}")
|
|
55
|
+
else:
|
|
56
|
+
print(f" {kind}: {dest} -> {src}")
|
|
57
|
+
print("")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _confirm() -> bool:
|
|
61
|
+
_print_plan()
|
|
62
|
+
try:
|
|
63
|
+
answer = input("Type Y to continue, anything else to exit: ").strip()
|
|
64
|
+
except EOFError:
|
|
65
|
+
answer = ""
|
|
66
|
+
return answer.lower() == "y"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _create_links():
|
|
70
|
+
"""Create hard/soft links for agent and skill files from cli/skills/."""
|
|
71
|
+
skills_dir = os.path.join(_pkg_root(), "skills")
|
|
72
|
+
|
|
73
|
+
# Hard link for Codex agent
|
|
74
|
+
src_agent = os.path.join(skills_dir, "handoff-ds.toml")
|
|
75
|
+
dest_agent = _home_path(".codex", "agents", "handoff-ds.toml")
|
|
76
|
+
os.makedirs(os.path.dirname(dest_agent), exist_ok=True)
|
|
77
|
+
if os.path.exists(dest_agent):
|
|
78
|
+
os.remove(dest_agent)
|
|
79
|
+
os.link(src_agent, dest_agent)
|
|
80
|
+
print(f"hard link: {dest_agent} <=> {src_agent}")
|
|
81
|
+
|
|
82
|
+
# Soft links for Claude Code skills (3 backends)
|
|
83
|
+
for skill_name in ("handoff-ds", "handoff-codex", "handoff-opus"):
|
|
84
|
+
src_skill = os.path.join(skills_dir, skill_name, "SKILL.md")
|
|
85
|
+
dest_skill_dir = _home_path(".claude", "skills", skill_name)
|
|
86
|
+
dest_skill = os.path.join(dest_skill_dir, "SKILL.md")
|
|
87
|
+
os.makedirs(dest_skill_dir, exist_ok=True)
|
|
88
|
+
if os.path.exists(dest_skill):
|
|
89
|
+
os.remove(dest_skill)
|
|
90
|
+
os.symlink(src_skill, dest_skill)
|
|
91
|
+
print(f"soft link: {dest_skill} -> {src_skill}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def run_init(assume_yes: bool = False):
|
|
95
|
+
if not assume_yes and not _confirm():
|
|
96
|
+
print("handoff: initialization cancelled")
|
|
97
|
+
sys.exit(1)
|
|
98
|
+
|
|
99
|
+
print("")
|
|
100
|
+
from ..config import user_config_path, write_default_user_config
|
|
101
|
+
|
|
102
|
+
wrote_config = write_default_user_config()
|
|
103
|
+
if wrote_config:
|
|
104
|
+
print(f"config: wrote {user_config_path()}")
|
|
105
|
+
else:
|
|
106
|
+
print(f"config: kept existing {user_config_path()}")
|
|
107
|
+
|
|
108
|
+
_create_links()
|
|
109
|
+
|
|
110
|
+
readme = os.path.join(_repo_root(), "README.md")
|
|
111
|
+
|
|
112
|
+
print("")
|
|
113
|
+
print("Next:")
|
|
114
|
+
print(f" 1. Set DEEPSEEK_API_KEY in your shell, or edit {user_config_path()} and replace ${{DEEPSEEK_API_KEY}} with your token.")
|
|
115
|
+
print(f" 2. Read {readme} for Codex and Claude Code usage.")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def cmd_init(args):
|
|
119
|
+
if args and args[0] in ("-h", "--help"):
|
|
120
|
+
print("usage: handoff init [-y|--yes]")
|
|
121
|
+
return
|
|
122
|
+
assume_yes = False
|
|
123
|
+
for arg in args:
|
|
124
|
+
if arg in ("-y", "--yes"):
|
|
125
|
+
assume_yes = True
|
|
126
|
+
else:
|
|
127
|
+
print(f"handoff: init: unexpected argument '{arg}'", file=sys.stderr)
|
|
128
|
+
sys.exit(2)
|
|
129
|
+
run_init(assume_yes=assume_yes)
|
cli/commands/list.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""handoff list command."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from ..core import get_db, format_run_row
|
|
7
|
+
from ..config import Config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def cmd_list(argv: list[str], config: Config):
|
|
11
|
+
"""handoff list [--uuid] [--cwd]"""
|
|
12
|
+
show_uuid = False
|
|
13
|
+
full_cwd = False
|
|
14
|
+
|
|
15
|
+
for a in argv:
|
|
16
|
+
if a == "--uuid":
|
|
17
|
+
show_uuid = True
|
|
18
|
+
elif a == "--cwd":
|
|
19
|
+
full_cwd = True
|
|
20
|
+
elif a in ("-h", "--help"):
|
|
21
|
+
from ..main import usage
|
|
22
|
+
usage()
|
|
23
|
+
sys.exit(0)
|
|
24
|
+
else:
|
|
25
|
+
print(f"handoff list: unknown argument {a}", file=sys.stderr)
|
|
26
|
+
sys.exit(2)
|
|
27
|
+
|
|
28
|
+
conn = get_db()
|
|
29
|
+
rows = conn.execute(
|
|
30
|
+
"SELECT seq, run_id, uuid, cwd, prompt, created_at, jsonl_path, status, backend "
|
|
31
|
+
"FROM runs ORDER BY created_at DESC LIMIT 50"
|
|
32
|
+
).fetchall()
|
|
33
|
+
|
|
34
|
+
if not rows:
|
|
35
|
+
conn.close()
|
|
36
|
+
print("(no runs)")
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
if sys.stdin.isatty() and sys.stdout.isatty():
|
|
40
|
+
# Launch the TUI directly (textual is a package dependency now).
|
|
41
|
+
from ..tui import RunListApp
|
|
42
|
+
|
|
43
|
+
def _refresh_rows():
|
|
44
|
+
"""Re-query the DB for the latest 50 runs. Called by the TUI timer."""
|
|
45
|
+
return conn.execute(
|
|
46
|
+
"SELECT seq, run_id, uuid, cwd, prompt, created_at, jsonl_path, status, backend "
|
|
47
|
+
"FROM runs ORDER BY created_at DESC LIMIT 50"
|
|
48
|
+
).fetchall()
|
|
49
|
+
|
|
50
|
+
app = RunListApp(rows, full_cwd, refresh_fn=_refresh_rows)
|
|
51
|
+
app.run(mouse=False)
|
|
52
|
+
conn.close()
|
|
53
|
+
|
|
54
|
+
# If the user pressed G (resume), handle it in this process so that
|
|
55
|
+
# _resume_interactive's os.execvp replaces us — the tty is inherited.
|
|
56
|
+
if app.action_result and app.action_result.startswith("resume:"):
|
|
57
|
+
run_id = app.action_result[len("resume:"):]
|
|
58
|
+
from .resume import cmd_resume
|
|
59
|
+
cmd_resume([run_id], config)
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
conn.close()
|
|
63
|
+
|
|
64
|
+
header = ["RUN", "DATE", "PROMPT", "CWD"]
|
|
65
|
+
if show_uuid:
|
|
66
|
+
header.append("UUID")
|
|
67
|
+
|
|
68
|
+
lines = [" ".join(header)]
|
|
69
|
+
for r in rows:
|
|
70
|
+
fmt = format_run_row(r, full_cwd)
|
|
71
|
+
cols = [
|
|
72
|
+
fmt["id"].ljust(13),
|
|
73
|
+
fmt["date"].ljust(11),
|
|
74
|
+
fmt["prompt"].ljust(30),
|
|
75
|
+
fmt["cwd"],
|
|
76
|
+
]
|
|
77
|
+
if show_uuid:
|
|
78
|
+
cols.append(fmt["uuid"])
|
|
79
|
+
lines.append(" ".join(cols))
|
|
80
|
+
|
|
81
|
+
print("\n".join(lines))
|