handoff-cli 0.3.5__tar.gz → 0.3.7__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 (48) hide show
  1. handoff_cli-0.3.7/Makefile +33 -0
  2. handoff_cli-0.3.5/README.md → handoff_cli-0.3.7/PKG-INFO +18 -0
  3. handoff_cli-0.3.5/PKG-INFO → handoff_cli-0.3.7/README.md +9 -9
  4. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/cli/commands/list.py +2 -1
  5. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/cli/commands/new.py +15 -3
  6. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/cli/commands/resume.py +20 -2
  7. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/cli/commands/run.py +13 -4
  8. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/cli/commands/tail.py +2 -1
  9. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/cli/config.py +45 -0
  10. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/cli/jsonl_parser.py +42 -0
  11. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/cli/jsonl_viewer.py +217 -105
  12. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/cli/main.py +5 -3
  13. handoff_cli-0.3.7/cli/skills/handoff-codex/SKILL.md +52 -0
  14. handoff_cli-0.3.7/cli/skills/handoff-ds/SKILL.md +52 -0
  15. handoff_cli-0.3.7/cli/skills/handoff-ds.toml +63 -0
  16. handoff_cli-0.3.7/cli/skills/handoff-opus/SKILL.md +52 -0
  17. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/cli/tui.py +51 -6
  18. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/docs/configuration.zh-CN.md +15 -0
  19. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/pyproject.toml +1 -1
  20. handoff_cli-0.3.7/tests/test_markdown_parser.py +158 -0
  21. handoff_cli-0.3.5/Makefile +0 -7
  22. handoff_cli-0.3.5/cli/skills/handoff-codex/SKILL.md +0 -83
  23. handoff_cli-0.3.5/cli/skills/handoff-ds/SKILL.md +0 -83
  24. handoff_cli-0.3.5/cli/skills/handoff-ds.toml +0 -52
  25. handoff_cli-0.3.5/cli/skills/handoff-opus/SKILL.md +0 -83
  26. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/.github/workflows/publish.yml +0 -0
  27. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/.gitignore +0 -0
  28. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/CLAUDE.md +0 -0
  29. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/README.zh-CN.md +0 -0
  30. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/cli/__init__.py +0 -0
  31. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/cli/backend.py +0 -0
  32. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/cli/backend_types.yaml +0 -0
  33. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/cli/commands/__init__.py +0 -0
  34. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/cli/commands/env.py +0 -0
  35. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/cli/commands/init.py +0 -0
  36. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/cli/core.py +0 -0
  37. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/cli/stream.py +0 -0
  38. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/cli/user_config_template.yaml +0 -0
  39. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/docs/TODO.md +0 -0
  40. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/docs/assets/claude-code.jpg +0 -0
  41. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/docs/assets/codex.jpg +0 -0
  42. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/docs/assets/handoff-hero.jpg +0 -0
  43. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/docs/assets/list-tui.jpg +0 -0
  44. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/docs/assets/parallel.jpg +0 -0
  45. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/docs/assets/shell.jpg +0 -0
  46. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/docs/assets/tail.jpg +0 -0
  47. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/docs/cli-reference.zh-CN.md +0 -0
  48. {handoff_cli-0.3.5 → handoff_cli-0.3.7}/docs/design.zh-CN.md +0 -0
@@ -0,0 +1,33 @@
1
+ .PHONY: render skills
2
+
3
+ render: ## Preview PyPI long description rendering
4
+ pip install -q "readme_renderer[md]" 2>/dev/null
5
+ python -m readme_renderer README.md > /tmp/handoff-pypi-preview.html
6
+ @echo "✅ /tmp/handoff-pypi-preview.html ($(shell wc -c < /tmp/handoff-pypi-preview.html | tr -d ' ') bytes)"
7
+ open /tmp/handoff-pypi-preview.html
8
+
9
+ # ── skill 文档同步 ──────────────────────────────────────────────────
10
+ # handoff-ds/SKILL.md 是主文档(master),只改它。
11
+ # 它的 frontmatter(顶部的 `---...---` 块)不同步;其下的正文会被复制到
12
+ # 其它 backend 的 SKILL.md,并把 backend 名/缩写替换成对应值。占位符在
13
+ # 「构建时」由 sed 替换,落盘的都是具体值——LLM 永远读不到占位符。
14
+ SKILLS := cli/skills
15
+ MASTER := $(SKILLS)/handoff-ds/SKILL.md
16
+
17
+ skills: ## 把 handoff-ds/SKILL.md 正文同步到其它 backend 的 SKILL.md
18
+ @$(call sync_skill,handoff-codex,codex,cx)
19
+ @$(call sync_skill,handoff-opus,opus,op)
20
+ @echo "done."
21
+
22
+ # $(1)=目标目录 $(2)=backend 名 $(3)=run_id 缩写
23
+ define sync_skill
24
+ target="$(SKILLS)/$(1)/SKILL.md"; \
25
+ tmp=$$(mktemp); \
26
+ awk '{print} /^---[[:space:]]*$$/{n++; if(n==2) exit}' "$$target" > "$$tmp"; \
27
+ awk 'body{print} /^---[[:space:]]*$$/{n++; if(n==2) body=1}' "$(MASTER)" \
28
+ | sed -e 's/deepseek/$(2)/g' \
29
+ -e 's/handoff-ds/handoff-$(2)/g' \
30
+ -e 's/0613-ds-/0613-$(3)-/g' >> "$$tmp"; \
31
+ mv "$$tmp" "$$target"; \
32
+ echo "synced $$target (backend=$(2))";
33
+ endef
@@ -1,3 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: handoff-cli
3
+ Version: 0.3.7
4
+ Summary: Multi coding-agent task dispatcher — a CLI proxy for claude that sends coding tasks to configurable AI backends
5
+ Requires-Python: >=3.9
6
+ Requires-Dist: pyyaml<7,>=6
7
+ Requires-Dist: textual<3,>=2
8
+ Description-Content-Type: text/markdown
9
+
1
10
  <div align="center">
2
11
  <img src="https://raw.githubusercontent.com/dazuiba/handoff/main/docs/assets/handoff-hero.jpg" width="100%" alt="hero">
3
12
 
@@ -139,6 +148,15 @@ Dispatching and resuming are the AI's job (`handoff run` / `handoff resume` unde
139
148
 
140
149
  </details>
141
150
 
151
+ <details>
152
+ <summary><b>Can I change the TUI theme?</b></summary>
153
+
154
+ <br>
155
+
156
+ Yes. Inside `handoff list` and `handoff tail`, press `D` to toggle between `textual-dark` and `textual-light`. Your choice is saved automatically to `~/.handoff/tui_state.json` and restored next time you run the TUI.
157
+
158
+ </details>
159
+
142
160
  <details>
143
161
  <summary><b>Can I dispatch several tasks at once?</b></summary>
144
162
 
@@ -1,12 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: handoff-cli
3
- Version: 0.3.5
4
- Summary: Multi coding-agent task dispatcher — a CLI proxy for claude that sends coding tasks to configurable AI backends
5
- Requires-Python: >=3.9
6
- Requires-Dist: pyyaml<7,>=6
7
- Requires-Dist: textual<3,>=2
8
- Description-Content-Type: text/markdown
9
-
10
1
  <div align="center">
11
2
  <img src="https://raw.githubusercontent.com/dazuiba/handoff/main/docs/assets/handoff-hero.jpg" width="100%" alt="hero">
12
3
 
@@ -148,6 +139,15 @@ Dispatching and resuming are the AI's job (`handoff run` / `handoff resume` unde
148
139
 
149
140
  </details>
150
141
 
142
+ <details>
143
+ <summary><b>Can I change the TUI theme?</b></summary>
144
+
145
+ <br>
146
+
147
+ Yes. Inside `handoff list` and `handoff tail`, press `D` to toggle between `textual-dark` and `textual-light`. Your choice is saved automatically to `~/.handoff/tui_state.json` and restored next time you run the TUI.
148
+
149
+ </details>
150
+
151
151
  <details>
152
152
  <summary><b>Can I dispatch several tasks at once?</b></summary>
153
153
 
@@ -47,7 +47,8 @@ def cmd_list(argv: list[str], config: Config):
47
47
  "FROM runs ORDER BY created_at DESC LIMIT 50"
48
48
  ).fetchall()
49
49
 
50
- app = RunListApp(rows, full_cwd, refresh_fn=_refresh_rows)
50
+ from ..config import read_tui_theme
51
+ app = RunListApp(rows, full_cwd, refresh_fn=_refresh_rows, theme_name=read_tui_theme())
51
52
  app.run(mouse=False)
52
53
  conn.close()
53
54
 
@@ -4,9 +4,10 @@ Pre-allocates a run_id and returns the canonical .prompt.md path so the caller
4
4
  can write the prompt directly to its final archive location before dispatching.
5
5
 
6
6
  Usage:
7
- handoff new --backend <name> [--slug <slug>]
7
+ handoff new --backend <name> [--slug <slug>] [--write]
8
8
 
9
- Stdout: one line — absolute path to the .prompt.md file (file is NOT created).
9
+ Stdout: one line — absolute path to the .prompt.md file.
10
+ By default the file is not created. With --write, stdin is written to the file.
10
11
  """
11
12
 
12
13
  from __future__ import annotations
@@ -26,9 +27,10 @@ from ..config import Config
26
27
 
27
28
 
28
29
  def cmd_new(argv: list[str], config: Config):
29
- """handoff new --backend <name> [--slug <slug>]"""
30
+ """handoff new --backend <name> [--slug <slug>] [--write]"""
30
31
  backend_arg = ""
31
32
  slug_arg = ""
33
+ write_prompt = False
32
34
 
33
35
  i = 0
34
36
  while i < len(argv):
@@ -49,6 +51,8 @@ def cmd_new(argv: list[str], config: Config):
49
51
  slug_arg = argv[i]
50
52
  elif a.startswith("--slug="):
51
53
  slug_arg = a.split("=", 1)[1]
54
+ elif a == "--write":
55
+ write_prompt = True
52
56
  elif a in ("-h", "--help"):
53
57
  from ..main import usage
54
58
  usage()
@@ -85,4 +89,12 @@ def cmd_new(argv: list[str], config: Config):
85
89
  run_id = f"{mmdd}-{b2}-{seq_code}-{clean_slug}"
86
90
  prompt_path = os.path.join(TASKS_DIR, f"{run_id}.prompt.md")
87
91
 
92
+ if write_prompt:
93
+ if sys.stdin.isatty():
94
+ print("handoff new: --write requires prompt text on stdin", file=sys.stderr)
95
+ sys.exit(2)
96
+ os.makedirs(os.path.dirname(prompt_path), exist_ok=True)
97
+ with open(prompt_path, "w") as f:
98
+ f.write(sys.stdin.read())
99
+
88
100
  print(prompt_path)
@@ -27,7 +27,7 @@ from ..config import Config
27
27
 
28
28
 
29
29
  def cmd_resume(argv: list[str], config: Config):
30
- """handoff resume [<run-id|seq>] [--backend <name>] [--pro] [--cwd <dir>]
30
+ """handoff resume [<run-id|seq>] [--backend <name>] [--slug <slug>] [--pro] [--cwd <dir>]
31
31
  [--verbose] [(<input-file|-> | --text <prompt...>)]."""
32
32
  # Pre-scan --verbose so it works regardless of position (e.g. after --text).
33
33
  verbose = "--verbose" in argv
@@ -36,6 +36,7 @@ def cmd_resume(argv: list[str], config: Config):
36
36
  pro = False
37
37
  cwd = ""
38
38
  backend_arg = ""
39
+ slug_arg = ""
39
40
  selector = ""
40
41
  input_src = ""
41
42
  text_mode = False
@@ -61,6 +62,14 @@ def cmd_resume(argv: list[str], config: Config):
61
62
  backend_arg = filtered[i]
62
63
  elif a.startswith("--backend="):
63
64
  backend_arg = a.split("=", 1)[1]
65
+ elif a == "--slug":
66
+ i += 1
67
+ if i >= len(filtered):
68
+ print("handoff resume: --slug requires a value", file=sys.stderr)
69
+ sys.exit(2)
70
+ slug_arg = filtered[i]
71
+ elif a.startswith("--slug="):
72
+ slug_arg = a.split("=", 1)[1]
64
73
  elif a == "--text":
65
74
  text_mode = True
66
75
  if input_src:
@@ -159,7 +168,16 @@ def cmd_resume(argv: list[str], config: Config):
159
168
  # Non-interactive: dispatch a new turn through the run pipeline.
160
169
  conn.close()
161
170
  from .run import _execute
162
- _execute(cwd, prompt_text, backend_name, pro, config, resume_session_id=session_id, verbose=verbose)
171
+ _execute(
172
+ cwd,
173
+ prompt_text,
174
+ backend_name,
175
+ pro,
176
+ config,
177
+ resume_session_id=session_id,
178
+ slug=slug_arg or "resume",
179
+ verbose=verbose,
180
+ )
163
181
 
164
182
 
165
183
  def _resume_interactive(config: Config, backend_name: str, session_id: str, cwd: str, pro: bool, verbose: bool = False):
@@ -40,7 +40,7 @@ def _is_adopted_path(input_src: str) -> bool:
40
40
 
41
41
 
42
42
  def cmd_run(argv: list[str], config: Config):
43
- """handoff run [--backend <name>] [--cwd <dir>] [--pro] [--verbose] (<input-file|-> | --text <prompt...>)."""
43
+ """handoff run [--backend <name>] [--cwd <dir>] [--slug <slug>] [--pro] [--verbose] (<input-file|-> | --text <prompt...>)."""
44
44
  # Pre-scan --verbose so it works regardless of position (e.g. after --text).
45
45
  verbose = "--verbose" in argv
46
46
  filtered = [a for a in argv if a != "--verbose"]
@@ -48,6 +48,7 @@ def cmd_run(argv: list[str], config: Config):
48
48
  pro = False
49
49
  cwd = ""
50
50
  backend_arg = ""
51
+ slug_arg = ""
51
52
  input_src = ""
52
53
  text_mode = False
53
54
  text_parts = []
@@ -71,6 +72,14 @@ def cmd_run(argv: list[str], config: Config):
71
72
  backend_arg = filtered[i]
72
73
  elif a.startswith("--backend="):
73
74
  backend_arg = a.split("=", 1)[1]
75
+ elif a == "--slug":
76
+ i += 1
77
+ if i >= len(filtered):
78
+ print("handoff run: --slug requires a value", file=sys.stderr)
79
+ sys.exit(2)
80
+ slug_arg = filtered[i]
81
+ elif a.startswith("--slug="):
82
+ slug_arg = a.split("=", 1)[1]
74
83
  elif a == "--text":
75
84
  text_mode = True
76
85
  if input_src:
@@ -130,10 +139,10 @@ def cmd_run(argv: list[str], config: Config):
130
139
  if not prompt_text:
131
140
  print("handoff run: --text requires a non-empty value", file=sys.stderr)
132
141
  sys.exit(2)
133
- slug = "from-text"
142
+ slug = slug_arg or "from-text"
134
143
  elif input_src == "-" or (not input_src and not sys.stdin.isatty()):
135
144
  prompt_text = sys.stdin.read()
136
- slug = "from-stdin"
145
+ slug = slug_arg or "from-stdin"
137
146
  elif input_src:
138
147
  if not os.path.isfile(input_src):
139
148
  print(f"handoff run: input file not found: {input_src}", file=sys.stderr)
@@ -164,7 +173,7 @@ def cmd_run(argv: list[str], config: Config):
164
173
  else:
165
174
  with open(input_src) as f:
166
175
  prompt_text = f.read()
167
- slug = "from-file"
176
+ slug = slug_arg or "from-file"
168
177
  else:
169
178
  print("handoff run: input file required, or use --text <prompt...> / pipe via '-'", file=sys.stderr)
170
179
  sys.exit(2)
@@ -44,5 +44,6 @@ def cmd_tail(argv: list[str], config=None):
44
44
  "out_path": out_path,
45
45
  }
46
46
 
47
+ from ..config import read_tui_theme
47
48
  from ..jsonl_viewer import run_tail
48
- run_tail(jsonl_path, prompt_path, result_path, run_info)
49
+ run_tail(jsonl_path, prompt_path, result_path, run_info, theme_name=read_tui_theme())
@@ -18,6 +18,7 @@ Backend resolution:
18
18
 
19
19
  from __future__ import annotations
20
20
 
21
+ import json
21
22
  import os
22
23
  import re
23
24
  import sys
@@ -35,6 +36,50 @@ except ImportError:
35
36
 
36
37
  _BACKEND_TYPES_PATH = os.path.join(os.path.dirname(__file__), "backend_types.yaml")
37
38
  _USER_CONFIG_TEMPLATE_PATH = os.path.join(os.path.dirname(__file__), "user_config_template.yaml")
39
+ DEFAULT_DARK_THEME = "textual-dark"
40
+ DEFAULT_LIGHT_THEME = "textual-light"
41
+
42
+ # ── TUI state persistence (theme, independent from config.yaml) ──────────
43
+
44
+ _TUI_STATE_FILENAME = "tui_state.json"
45
+
46
+
47
+ def _tui_state_path() -> str:
48
+ return os.path.join(user_config_dir(), _TUI_STATE_FILENAME)
49
+
50
+
51
+ def read_tui_theme() -> str:
52
+ """Read the persisted TUI theme name from ``~/.handoff/tui_state.json``.
53
+
54
+ Falls back to ``textual-dark`` when the file is missing, corrupt, or
55
+ the stored theme name is empty / invalid. Never raises.
56
+ """
57
+ path = _tui_state_path()
58
+ try:
59
+ if os.path.isfile(path):
60
+ with open(path, "r") as f:
61
+ data = json.load(f)
62
+ theme = data.get("theme", "")
63
+ if isinstance(theme, str) and theme.strip():
64
+ return theme.strip()
65
+ except (OSError, json.JSONDecodeError, ValueError):
66
+ pass
67
+ return DEFAULT_DARK_THEME
68
+
69
+
70
+ def write_tui_theme(theme: str) -> None:
71
+ """Persist the TUI theme name to ``~/.handoff/tui_state.json``.
72
+
73
+ Best-effort: failures are silently ignored so the app never crashes
74
+ on a write.
75
+ """
76
+ path = _tui_state_path()
77
+ try:
78
+ os.makedirs(os.path.dirname(path), exist_ok=True)
79
+ with open(path, "w") as f:
80
+ json.dump({"theme": theme}, f)
81
+ except OSError:
82
+ pass
38
83
 
39
84
  # Top-level config keys that belong to the mechanism layer or are otherwise
40
85
  # removed. Warn once and ignore. system_prompt is deliberately absent from
@@ -7,6 +7,8 @@ import json
7
7
  from dataclasses import dataclass
8
8
  from typing import TextIO
9
9
 
10
+ from rich.text import Text
11
+
10
12
 
11
13
  @dataclass
12
14
  class ParsedEvent:
@@ -160,6 +162,46 @@ def format_event_for_viewer(event: ParsedEvent) -> str | None:
160
162
  return f"`{ts}` {kind_mark} {_truncate(event.text)}"
161
163
 
162
164
 
165
+ def format_event_as_rich(event: ParsedEvent) -> Text | None:
166
+ """Format one parsed event as a rich Text with styled spans.
167
+
168
+ Returns None for result_text/error_text events (handled separately by
169
+ the viewer as Markdown content). The returned Text uses colour/emphasis
170
+ per kind: tool=cyan, text=default, result=green, error=red, task=yellow,
171
+ info=dim.
172
+ """
173
+ if event.kind in ("result_text", "error_text"):
174
+ return None
175
+
176
+ ts = event.ts or " " * 8
177
+ mark_map = {
178
+ "tool": "▷",
179
+ "text": "✎",
180
+ "result": "✓",
181
+ "error": "✗",
182
+ "task": "▶",
183
+ "info": "·",
184
+ }
185
+ colour_map = {
186
+ "tool": "cyan",
187
+ "text": "",
188
+ "result": "green",
189
+ "error": "red",
190
+ "task": "yellow",
191
+ "info": "dim",
192
+ }
193
+ mark = mark_map.get(event.kind, " ")
194
+ colour = colour_map.get(event.kind, "")
195
+
196
+ text = Text()
197
+ text.append(f"{ts:8}", style="dim")
198
+ text.append(" │ ", style="dim")
199
+ text.append(mark, style=colour)
200
+ text.append(" ")
201
+ text.append(_truncate(event.text))
202
+ return text
203
+
204
+
163
205
  def format_event_for_stream(event: ParsedEvent) -> str | None:
164
206
  """Return the single-line text stream shown during `handoff run`."""
165
207
  if event.kind != "text":