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 ADDED
@@ -0,0 +1,3 @@
1
+ """handoff CLI package."""
2
+
3
+ __version__ = "0.3.0"
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}"
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))