tloop-cli 0.4.0__tar.gz → 0.6.0__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.
- {tloop_cli-0.4.0 → tloop_cli-0.6.0}/PKG-INFO +36 -3
- {tloop_cli-0.4.0 → tloop_cli-0.6.0}/README.md +35 -2
- {tloop_cli-0.4.0 → tloop_cli-0.6.0}/pyproject.toml +1 -1
- {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/claude_runner.py +15 -6
- tloop_cli-0.6.0/src/cmd_edit.py +110 -0
- {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/cmd_run.py +1 -2
- {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/config.py +15 -30
- {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/runner/__init__.py +1 -1
- tloop_cli-0.6.0/src/runner/claude.py +89 -0
- tloop_cli-0.6.0/src/runner/cybervisor.py +35 -0
- {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/task.py +24 -11
- {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/tloop_cli.egg-info/PKG-INFO +36 -3
- {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/tloop_cli.egg-info/SOURCES.txt +1 -0
- tloop_cli-0.6.0/test/test_claude_runner.py +89 -0
- {tloop_cli-0.4.0 → tloop_cli-0.6.0}/test/test_cli.py +180 -5
- tloop_cli-0.4.0/src/cmd_edit.py +0 -45
- tloop_cli-0.4.0/src/runner/claude.py +0 -10
- tloop_cli-0.4.0/src/runner/cybervisor.py +0 -28
- {tloop_cli-0.4.0 → tloop_cli-0.6.0}/LICENSE +0 -0
- {tloop_cli-0.4.0 → tloop_cli-0.6.0}/setup.cfg +0 -0
- {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/cmd_archive.py +0 -0
- {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/cmd_migrate.py +0 -0
- {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/git_ops.py +0 -0
- {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/main.py +0 -0
- {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/state.py +0 -0
- {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/tloop_cli.egg-info/dependency_links.txt +0 -0
- {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/tloop_cli.egg-info/entry_points.txt +0 -0
- {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/tloop_cli.egg-info/requires.txt +0 -0
- {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/tloop_cli.egg-info/top_level.txt +0 -0
- {tloop_cli-0.4.0 → tloop_cli-0.6.0}/test/test_git_ops.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tloop-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Automated Claude Code task runner
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Requires-Python: >=3.9
|
|
@@ -16,8 +16,8 @@ Dynamic: license-file
|
|
|
16
16
|
## 安装
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
|
-
pip install
|
|
20
|
-
pip install -e .
|
|
19
|
+
pip install tloop-cli # 从 PyPI 安装
|
|
20
|
+
pip install -e . # 本地开发安装
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
需要 Python >=3.9,唯一外部依赖:`pyyaml`。
|
|
@@ -60,6 +60,8 @@ tasks:
|
|
|
60
60
|
| `prompt_file` | prompt 文件路径(解析顺序:绝对路径 → `~/.tloop/` 相对 → 任务目录相对) |
|
|
61
61
|
| `model` | 覆盖该任务的模型 |
|
|
62
62
|
| `branch` | `true`(自动 `feature-YYYYMMDD-NNN`)、`"custom/name"`、或 `false`(跳过分支) |
|
|
63
|
+
| `use` | 任务执行器:`cybervisor`(默认,多阶段复杂任务)或 `claude`(定向任务,支持循环) |
|
|
64
|
+
| `max_rounds` | `use: claude` 时生效,最大迭代次数(默认 5);到达上限前未收到 `<promise>COMPLETE</promise>` 信号则任务失败 |
|
|
63
65
|
|
|
64
66
|
## 用法
|
|
65
67
|
|
|
@@ -76,6 +78,37 @@ tloop archive --latest # 显示最近一次归档详情
|
|
|
76
78
|
tloop migrate # 迁移旧的项目本地数据到 ~/.tloop/
|
|
77
79
|
```
|
|
78
80
|
|
|
81
|
+
## 执行器
|
|
82
|
+
|
|
83
|
+
t-loop 支持两种任务执行器,通过 `use` 字段选择:
|
|
84
|
+
|
|
85
|
+
| 执行器 | 说明 | 适用场景 |
|
|
86
|
+
|--------|------|----------|
|
|
87
|
+
| `cybervisor` | 默认,执行 `cybervisor run`,适合复杂多阶段任务 | 大型重构、多步骤分析 |
|
|
88
|
+
| `claude` | 执行 `claude -p --dangerously-skip-permissions`,支持循环迭代 | 定向 bug 修复、明确目标的任务 |
|
|
89
|
+
|
|
90
|
+
### ClaudeRunner 循环机制
|
|
91
|
+
|
|
92
|
+
`use: claude` 时,任务以多轮迭代方式执行:
|
|
93
|
+
|
|
94
|
+
1. 每轮启动一次 `claude -p --dangerously-skip-permissions`,传入 prompt
|
|
95
|
+
2. 如果输出包含 `<promise>COMPLETE</promise>`,立即结束并标记成功
|
|
96
|
+
3. 否则等待 2 秒,继续下一轮,直到达到 `max_rounds`(默认 5)
|
|
97
|
+
4. `max_rounds` 用尽仍未检测到完成信号,任务标记失败
|
|
98
|
+
|
|
99
|
+
使用示例:
|
|
100
|
+
|
|
101
|
+
```yaml
|
|
102
|
+
tasks:
|
|
103
|
+
- name: 修复空指针 bug
|
|
104
|
+
dir: ~/proj
|
|
105
|
+
use: claude
|
|
106
|
+
max_rounds: 3
|
|
107
|
+
prompt_file: bugfix.md
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
在 prompt 文件中加入 `<promise>COMPLETE</promise>` 即可让 Claude 主动退出循环。
|
|
111
|
+
|
|
79
112
|
## 工作原理
|
|
80
113
|
|
|
81
114
|
每个任务的执行流程:
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
## 安装
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
pip install
|
|
9
|
-
pip install -e .
|
|
8
|
+
pip install tloop-cli # 从 PyPI 安装
|
|
9
|
+
pip install -e . # 本地开发安装
|
|
10
10
|
```
|
|
11
11
|
|
|
12
12
|
需要 Python >=3.9,唯一外部依赖:`pyyaml`。
|
|
@@ -49,6 +49,8 @@ tasks:
|
|
|
49
49
|
| `prompt_file` | prompt 文件路径(解析顺序:绝对路径 → `~/.tloop/` 相对 → 任务目录相对) |
|
|
50
50
|
| `model` | 覆盖该任务的模型 |
|
|
51
51
|
| `branch` | `true`(自动 `feature-YYYYMMDD-NNN`)、`"custom/name"`、或 `false`(跳过分支) |
|
|
52
|
+
| `use` | 任务执行器:`cybervisor`(默认,多阶段复杂任务)或 `claude`(定向任务,支持循环) |
|
|
53
|
+
| `max_rounds` | `use: claude` 时生效,最大迭代次数(默认 5);到达上限前未收到 `<promise>COMPLETE</promise>` 信号则任务失败 |
|
|
52
54
|
|
|
53
55
|
## 用法
|
|
54
56
|
|
|
@@ -65,6 +67,37 @@ tloop archive --latest # 显示最近一次归档详情
|
|
|
65
67
|
tloop migrate # 迁移旧的项目本地数据到 ~/.tloop/
|
|
66
68
|
```
|
|
67
69
|
|
|
70
|
+
## 执行器
|
|
71
|
+
|
|
72
|
+
t-loop 支持两种任务执行器,通过 `use` 字段选择:
|
|
73
|
+
|
|
74
|
+
| 执行器 | 说明 | 适用场景 |
|
|
75
|
+
|--------|------|----------|
|
|
76
|
+
| `cybervisor` | 默认,执行 `cybervisor run`,适合复杂多阶段任务 | 大型重构、多步骤分析 |
|
|
77
|
+
| `claude` | 执行 `claude -p --dangerously-skip-permissions`,支持循环迭代 | 定向 bug 修复、明确目标的任务 |
|
|
78
|
+
|
|
79
|
+
### ClaudeRunner 循环机制
|
|
80
|
+
|
|
81
|
+
`use: claude` 时,任务以多轮迭代方式执行:
|
|
82
|
+
|
|
83
|
+
1. 每轮启动一次 `claude -p --dangerously-skip-permissions`,传入 prompt
|
|
84
|
+
2. 如果输出包含 `<promise>COMPLETE</promise>`,立即结束并标记成功
|
|
85
|
+
3. 否则等待 2 秒,继续下一轮,直到达到 `max_rounds`(默认 5)
|
|
86
|
+
4. `max_rounds` 用尽仍未检测到完成信号,任务标记失败
|
|
87
|
+
|
|
88
|
+
使用示例:
|
|
89
|
+
|
|
90
|
+
```yaml
|
|
91
|
+
tasks:
|
|
92
|
+
- name: 修复空指针 bug
|
|
93
|
+
dir: ~/proj
|
|
94
|
+
use: claude
|
|
95
|
+
max_rounds: 3
|
|
96
|
+
prompt_file: bugfix.md
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
在 prompt 文件中加入 `<promise>COMPLETE</promise>` 即可让 Claude 主动退出循环。
|
|
100
|
+
|
|
68
101
|
## 工作原理
|
|
69
102
|
|
|
70
103
|
每个任务的执行流程:
|
|
@@ -45,12 +45,21 @@ def run_claude(prompt, cwd, max_retries=DEFAULT_MAX_RETRIES, verify_fn=None, log
|
|
|
45
45
|
else:
|
|
46
46
|
attempt_cmd = cmd
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
48
|
+
print(f" Running claude (attempt {attempt}/{max_retries})...")
|
|
49
|
+
try:
|
|
50
|
+
result = subprocess.run(
|
|
51
|
+
attempt_cmd,
|
|
52
|
+
cwd=cwd,
|
|
53
|
+
capture_output=True,
|
|
54
|
+
text=True,
|
|
55
|
+
timeout=300,
|
|
56
|
+
)
|
|
57
|
+
except subprocess.TimeoutExpired:
|
|
58
|
+
print(f"{YELLOW} Claude timed out after 300s (attempt {attempt}/{max_retries}){RESET}")
|
|
59
|
+
if attempt >= max_retries:
|
|
60
|
+
print(f"{RED} Max retries reached. Giving up.{RESET}")
|
|
61
|
+
return False
|
|
62
|
+
continue
|
|
54
63
|
|
|
55
64
|
if log_file:
|
|
56
65
|
with open(log_file, "a") as log:
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""tloop edit — open ~/.tloop/tasks.yaml in editor."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
import config
|
|
10
|
+
|
|
11
|
+
KNOWN_EDITORS = {
|
|
12
|
+
"code": ("Visual Studio Code", "code"),
|
|
13
|
+
"vim": ("Vim", "vim"),
|
|
14
|
+
"nano": ("Nano", "nano"),
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
EDIT_HELP = """\
|
|
18
|
+
Open ~/.tloop/tasks.yaml in your editor.
|
|
19
|
+
|
|
20
|
+
On first run, you will be prompted to choose an editor (VS Code, Vim, Nano,
|
|
21
|
+
or a custom command). The choice is saved to ~/.tloop/settings.json.
|
|
22
|
+
Override anytime with: tloop edit --editor <command>
|
|
23
|
+
|
|
24
|
+
Task file format (~/.tloop/tasks.yaml):
|
|
25
|
+
|
|
26
|
+
tasks:
|
|
27
|
+
- name: My task
|
|
28
|
+
dir: ~/projects/my-project
|
|
29
|
+
prompt: |
|
|
30
|
+
Describe what Claude should do.
|
|
31
|
+
# OR:
|
|
32
|
+
prompt_file: ./prompts/my-task.md
|
|
33
|
+
branch: true # true=auto, "custom/name", false=skip
|
|
34
|
+
use: cybervisor # cybervisor (default) or claude
|
|
35
|
+
max_rounds: 5 # only for use: claude
|
|
36
|
+
|
|
37
|
+
Each task runs in the specified directory. Completed tasks are
|
|
38
|
+
archived to ~/.tloop/archive/ after each run cycle.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _load_settings():
|
|
43
|
+
if config.SETTINGS_FILE.exists():
|
|
44
|
+
try:
|
|
45
|
+
return json.loads(config.SETTINGS_FILE.read_text())
|
|
46
|
+
except (json.JSONDecodeError, OSError):
|
|
47
|
+
pass
|
|
48
|
+
return {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _save_settings(settings):
|
|
52
|
+
config.TLOOP_HOME.mkdir(exist_ok=True)
|
|
53
|
+
config.SETTINGS_FILE.write_text(json.dumps(settings, indent=2) + "\n")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _prompt_editor():
|
|
57
|
+
options = []
|
|
58
|
+
for key, (label, cmd) in KNOWN_EDITORS.items():
|
|
59
|
+
if shutil.which(cmd):
|
|
60
|
+
options.append((key, label, cmd))
|
|
61
|
+
|
|
62
|
+
print(f"{config.BOLD}Choose your editor for tasks.yaml:{config.RESET}\n")
|
|
63
|
+
for i, (key, label, _) in enumerate(options, 1):
|
|
64
|
+
print(f" {i}) {label}")
|
|
65
|
+
print(f" {len(options) + 1}) Other (enter command manually)")
|
|
66
|
+
print()
|
|
67
|
+
|
|
68
|
+
while True:
|
|
69
|
+
choice = input(f"Enter number [1-{len(options) + 1}]: ").strip()
|
|
70
|
+
if choice.isdigit():
|
|
71
|
+
idx = int(choice)
|
|
72
|
+
if 1 <= idx <= len(options):
|
|
73
|
+
return options[idx - 1][2]
|
|
74
|
+
if idx == len(options) + 1:
|
|
75
|
+
cmd = input("Enter editor command: ").strip()
|
|
76
|
+
if cmd:
|
|
77
|
+
return cmd
|
|
78
|
+
print("Invalid choice, try again.")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _resolve_editor(cli_editor=None):
|
|
82
|
+
if cli_editor:
|
|
83
|
+
return cli_editor
|
|
84
|
+
settings = _load_settings()
|
|
85
|
+
if "editor" in settings:
|
|
86
|
+
return settings["editor"]
|
|
87
|
+
editor = _prompt_editor()
|
|
88
|
+
_save_settings({"editor": editor})
|
|
89
|
+
print(f"{config.GREEN}Editor saved. Change anytime with: tloop edit --editor <command>{config.RESET}\n")
|
|
90
|
+
return editor
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def add_parser(subparsers):
|
|
94
|
+
p = subparsers.add_parser(
|
|
95
|
+
"edit",
|
|
96
|
+
help="Open ~/.tloop/tasks.yaml in editor",
|
|
97
|
+
description=EDIT_HELP,
|
|
98
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
99
|
+
)
|
|
100
|
+
p.add_argument("--editor", help="Override editor command for this session")
|
|
101
|
+
p.set_defaults(func=handle)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def handle(args):
|
|
105
|
+
config.TLOOP_HOME.mkdir(exist_ok=True)
|
|
106
|
+
if not config.TASKS_FILE.exists():
|
|
107
|
+
config.TASKS_FILE.write_text(config.SAMPLE_TASKS_YAML)
|
|
108
|
+
|
|
109
|
+
editor = _resolve_editor(getattr(args, "editor", None))
|
|
110
|
+
subprocess.run([editor, str(config.TASKS_FILE)])
|
|
@@ -24,7 +24,6 @@ def handle(args):
|
|
|
24
24
|
|
|
25
25
|
cfg = load_config()
|
|
26
26
|
tasks = cfg.get("tasks", [])
|
|
27
|
-
defaults = cfg.get("defaults", {})
|
|
28
27
|
|
|
29
28
|
if not tasks:
|
|
30
29
|
print("No tasks defined in tasks.yaml")
|
|
@@ -71,7 +70,7 @@ def handle(args):
|
|
|
71
70
|
continue
|
|
72
71
|
|
|
73
72
|
ran_any = True
|
|
74
|
-
success = run_task(tasks[i], i, state
|
|
73
|
+
success = run_task(tasks[i], i, state)
|
|
75
74
|
if not success and not args.continue_on_fail:
|
|
76
75
|
print(f"\n{config.YELLOW}Stopped. Use -c to continue after failures.{config.RESET}")
|
|
77
76
|
break
|
|
@@ -8,6 +8,7 @@ import yaml
|
|
|
8
8
|
TLOOP_HOME = Path.home() / ".tloop"
|
|
9
9
|
TASKS_FILE = TLOOP_HOME / "tasks.yaml"
|
|
10
10
|
STATE_FILE = TLOOP_HOME / "state.json"
|
|
11
|
+
SETTINGS_FILE = TLOOP_HOME / "settings.json"
|
|
11
12
|
LOGS_DIR = TLOOP_HOME / "logs"
|
|
12
13
|
ARCHIVE_DIR = TLOOP_HOME / "archive"
|
|
13
14
|
|
|
@@ -18,34 +19,7 @@ CYAN = "\033[96m"
|
|
|
18
19
|
BOLD = "\033[1m"
|
|
19
20
|
RESET = "\033[0m"
|
|
20
21
|
|
|
21
|
-
SAMPLE_TASKS_YAML = ""
|
|
22
|
-
# t-loop tasks.yaml
|
|
23
|
-
# Define your Claude Code automation tasks here.
|
|
24
|
-
#
|
|
25
|
-
# Location: ~/.tloop/tasks.yaml
|
|
26
|
-
#
|
|
27
|
-
# Each task runs "cybervisor run <prompt>" in the specified directory.
|
|
28
|
-
# Completed tasks are automatically archived after each run cycle.
|
|
29
|
-
# View archives with: tloop archive
|
|
30
|
-
#
|
|
31
|
-
# prompt_file paths resolve in this order:
|
|
32
|
-
# 1. Absolute path (after ~ expansion)
|
|
33
|
-
# 2. Relative to ~/.tloop/
|
|
34
|
-
# 3. Relative to the task's dir
|
|
35
|
-
|
|
36
|
-
defaults:
|
|
37
|
-
# model: opus
|
|
38
|
-
|
|
39
|
-
tasks: []
|
|
40
|
-
# - name: My first task
|
|
41
|
-
# dir: ~/projects/my-project
|
|
42
|
-
# prompt: |
|
|
43
|
-
# Describe what Claude should do here.
|
|
44
|
-
#
|
|
45
|
-
# - name: Task with prompt file
|
|
46
|
-
# dir: ~/projects/my-project
|
|
47
|
-
# prompt_file: ./prompts/my-task.md
|
|
48
|
-
"""
|
|
22
|
+
SAMPLE_TASKS_YAML = "# Run 'tloop edit --help' for details on how to write this file.\ntasks: []\n"
|
|
49
23
|
|
|
50
24
|
|
|
51
25
|
def ensure_tloop_home():
|
|
@@ -65,5 +39,16 @@ def load_config():
|
|
|
65
39
|
print(f"{RED}Error: {TASKS_FILE} not found{RESET}")
|
|
66
40
|
print(f"Run tloop run to initialize, then edit tasks.yaml.")
|
|
67
41
|
sys.exit(1)
|
|
68
|
-
|
|
69
|
-
|
|
42
|
+
try:
|
|
43
|
+
with open(TASKS_FILE) as f:
|
|
44
|
+
return yaml.safe_load(f) or {}
|
|
45
|
+
except yaml.YAMLError as e:
|
|
46
|
+
location = ""
|
|
47
|
+
if hasattr(e, "problem_mark") and e.problem_mark:
|
|
48
|
+
mark = e.problem_mark
|
|
49
|
+
location = f" (line {mark.line + 1}, column {mark.column + 1})"
|
|
50
|
+
print(f"{RED}Error: {TASKS_FILE} 解析失败{location}{RESET}")
|
|
51
|
+
if hasattr(e, "problem") and e.problem:
|
|
52
|
+
print(f" {e.problem}")
|
|
53
|
+
print("请检查 YAML 格式,常见问题:含冒号/特殊字符的值需要用引号包裹。")
|
|
54
|
+
sys.exit(1)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Claude Code runner backend with round-loop execution."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from runner import Runner
|
|
7
|
+
|
|
8
|
+
COMPLETION_SUFFIX = (
|
|
9
|
+
|
|
10
|
+
"\n\nWhen you have fully completed all the requested work, "
|
|
11
|
+
"output the following on its own line to signal completion:\n"
|
|
12
|
+
"<promise>COMPLETE</promise>\n"
|
|
13
|
+
"Do NOT output this unless you have finished everything. "
|
|
14
|
+
"If there is still work to do, end your response normally — "
|
|
15
|
+
"another iteration will pick up where you left off."
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ClaudeRunner(Runner):
|
|
20
|
+
def run(self, prompt, cwd, log_file=None, max_rounds=5, prompt_file=None):
|
|
21
|
+
"""
|
|
22
|
+
Run Claude Code in a loop with configurable round limit.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
prompt: The prompt to send to Claude Code
|
|
26
|
+
cwd: Working directory for the task
|
|
27
|
+
log_file: Optional path to log file
|
|
28
|
+
max_rounds: Maximum number of loop iterations (default 5)
|
|
29
|
+
prompt_file: Optional path to prompt file (used as stdin like CybervisorRunner)
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
0 on success (completion signal detected), non-zero on failure
|
|
33
|
+
"""
|
|
34
|
+
enriched_prompt = prompt + COMPLETION_SUFFIX
|
|
35
|
+
|
|
36
|
+
log = open(log_file, "a") if log_file else open("/dev/null", "a")
|
|
37
|
+
try:
|
|
38
|
+
for round_num in range(1, max_rounds + 1):
|
|
39
|
+
log.write(f"\n{'='*60}\n")
|
|
40
|
+
log.write(f"Round {round_num}/{max_rounds}\n")
|
|
41
|
+
log.write(f"{'='*60}\n\n")
|
|
42
|
+
log.flush()
|
|
43
|
+
|
|
44
|
+
print(f"\n{'='*60}")
|
|
45
|
+
print(f" Round {round_num}/{max_rounds} ")
|
|
46
|
+
print(f"{'='*60}")
|
|
47
|
+
|
|
48
|
+
process = subprocess.Popen(
|
|
49
|
+
["claude", "-p", "--dangerously-skip-permissions"],
|
|
50
|
+
cwd=cwd,
|
|
51
|
+
stdin=subprocess.PIPE,
|
|
52
|
+
stdout=subprocess.PIPE,
|
|
53
|
+
stderr=subprocess.STDOUT,
|
|
54
|
+
text=True,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
output_parts = []
|
|
58
|
+
if prompt_file:
|
|
59
|
+
with open(prompt_file, "r") as f:
|
|
60
|
+
process.stdin.write(f.read())
|
|
61
|
+
process.stdin.write(COMPLETION_SUFFIX)
|
|
62
|
+
else:
|
|
63
|
+
process.stdin.write(enriched_prompt)
|
|
64
|
+
process.stdin.close()
|
|
65
|
+
|
|
66
|
+
for line in process.stdout:
|
|
67
|
+
print(line, end="")
|
|
68
|
+
log.write(line)
|
|
69
|
+
output_parts.append(line)
|
|
70
|
+
|
|
71
|
+
process.wait()
|
|
72
|
+
log.flush()
|
|
73
|
+
|
|
74
|
+
accumulated = "".join(output_parts)
|
|
75
|
+
if "<promise>COMPLETE</promise>" in accumulated:
|
|
76
|
+
log.write("\n[Completion signal detected - exiting loop]\n")
|
|
77
|
+
log.flush()
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
if round_num < max_rounds:
|
|
81
|
+
log.write(f"\n[Round {round_num} complete, sleeping 2s before next round]\n")
|
|
82
|
+
log.flush()
|
|
83
|
+
time.sleep(2)
|
|
84
|
+
|
|
85
|
+
log.write(f"\n[All {max_rounds} rounds exhausted without completion signal]\n")
|
|
86
|
+
log.flush()
|
|
87
|
+
return 1
|
|
88
|
+
finally:
|
|
89
|
+
log.close()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Cybervisor runner backend."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
from runner import Runner
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CybervisorRunner(Runner):
|
|
9
|
+
def run(self, prompt, cwd, log_file=None, prompt_file=None):
|
|
10
|
+
cmd = ["cybervisor", "run"]
|
|
11
|
+
|
|
12
|
+
with open(log_file, "a") if log_file else open("/dev/null", "a") as log:
|
|
13
|
+
stdin_fh = open(prompt_file, "r") if prompt_file else None
|
|
14
|
+
try:
|
|
15
|
+
process = subprocess.Popen(
|
|
16
|
+
cmd,
|
|
17
|
+
cwd=cwd,
|
|
18
|
+
stdin=stdin_fh if stdin_fh else subprocess.PIPE,
|
|
19
|
+
stdout=subprocess.PIPE,
|
|
20
|
+
stderr=subprocess.STDOUT,
|
|
21
|
+
text=True,
|
|
22
|
+
)
|
|
23
|
+
if not stdin_fh:
|
|
24
|
+
process.stdin.write(prompt)
|
|
25
|
+
process.stdin.close()
|
|
26
|
+
for line in process.stdout:
|
|
27
|
+
print(line, end="")
|
|
28
|
+
log.write(line)
|
|
29
|
+
log.flush()
|
|
30
|
+
process.wait()
|
|
31
|
+
finally:
|
|
32
|
+
if stdin_fh:
|
|
33
|
+
stdin_fh.close()
|
|
34
|
+
|
|
35
|
+
return process.returncode
|
|
@@ -7,6 +7,7 @@ from pathlib import Path
|
|
|
7
7
|
import config
|
|
8
8
|
from git_ops import ensure_clean_git, create_task_branch
|
|
9
9
|
from runner.cybervisor import CybervisorRunner
|
|
10
|
+
from runner.claude import ClaudeRunner
|
|
10
11
|
from state import save_state
|
|
11
12
|
|
|
12
13
|
|
|
@@ -28,12 +29,11 @@ def resolve_prompt_file(prompt_file, dir_path):
|
|
|
28
29
|
return Path(prompt_file)
|
|
29
30
|
|
|
30
31
|
|
|
31
|
-
def run_task(task, index, state
|
|
32
|
+
def run_task(task, index, state):
|
|
32
33
|
name = task.get("name", f"Task {index + 1}")
|
|
33
|
-
dir_path = expand_dir(task.get("dir",
|
|
34
|
+
dir_path = expand_dir(task.get("dir", "."))
|
|
34
35
|
prompt = task.get("prompt", "")
|
|
35
36
|
prompt_file = task.get("prompt_file")
|
|
36
|
-
model = task.get("model", defaults.get("model"))
|
|
37
37
|
branch_config = task.get("branch", True)
|
|
38
38
|
|
|
39
39
|
if not os.path.isdir(dir_path):
|
|
@@ -46,10 +46,11 @@ def run_task(task, index, state, defaults):
|
|
|
46
46
|
save_state(state)
|
|
47
47
|
return False
|
|
48
48
|
|
|
49
|
+
resolved_pf = None
|
|
49
50
|
if prompt_file:
|
|
50
|
-
|
|
51
|
-
if
|
|
52
|
-
prompt =
|
|
51
|
+
resolved_pf = resolve_prompt_file(prompt_file, dir_path)
|
|
52
|
+
if resolved_pf.exists():
|
|
53
|
+
prompt = resolved_pf.read_text()
|
|
53
54
|
else:
|
|
54
55
|
print(f"{config.RED} Prompt file not found: {prompt_file}{config.RESET}")
|
|
55
56
|
return False
|
|
@@ -100,12 +101,24 @@ def run_task(task, index, state, defaults):
|
|
|
100
101
|
try:
|
|
101
102
|
with open(log_file, "a") as log:
|
|
102
103
|
log.write(f"Started: {started}\n")
|
|
103
|
-
log.write(f"Command: cybervisor run <prompt>\n")
|
|
104
|
-
log.write("-" * 60 + "\n\n")
|
|
105
104
|
log.flush()
|
|
106
105
|
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
runner_name = task.get("use", "cybervisor")
|
|
107
|
+
if runner_name == "claude":
|
|
108
|
+
runner = ClaudeRunner()
|
|
109
|
+
max_rounds = task.get("max_rounds", 5)
|
|
110
|
+
log.write(f"Runner: ClaudeRunner (max_rounds={max_rounds})\n")
|
|
111
|
+
log.write(f"Command: claude -p --dangerously-skip-permissions\n")
|
|
112
|
+
log.write("-" * 60 + "\n\n")
|
|
113
|
+
log.flush()
|
|
114
|
+
returncode = runner.run(prompt, dir_path, log_file=log_file, max_rounds=max_rounds, prompt_file=resolved_pf)
|
|
115
|
+
else:
|
|
116
|
+
runner = CybervisorRunner()
|
|
117
|
+
log.write(f"Runner: CybervisorRunner\n")
|
|
118
|
+
log.write(f"Command: cybervisor run < {'<prompt_file>' if resolved_pf else '<prompt>'}\n")
|
|
119
|
+
log.write("-" * 60 + "\n\n")
|
|
120
|
+
log.flush()
|
|
121
|
+
returncode = runner.run(prompt, dir_path, log_file=log_file, prompt_file=resolved_pf)
|
|
109
122
|
|
|
110
123
|
if returncode == 0:
|
|
111
124
|
state["tasks"][str(index)] = {
|
|
@@ -139,4 +152,4 @@ def run_task(task, index, state, defaults):
|
|
|
139
152
|
print(f"\n{config.RED}❌ Task [{index + 1}] error: {e}{config.RESET}")
|
|
140
153
|
return False
|
|
141
154
|
|
|
142
|
-
return True
|
|
155
|
+
return True
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tloop-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Automated Claude Code task runner
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Requires-Python: >=3.9
|
|
@@ -16,8 +16,8 @@ Dynamic: license-file
|
|
|
16
16
|
## 安装
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
|
-
pip install
|
|
20
|
-
pip install -e .
|
|
19
|
+
pip install tloop-cli # 从 PyPI 安装
|
|
20
|
+
pip install -e . # 本地开发安装
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
需要 Python >=3.9,唯一外部依赖:`pyyaml`。
|
|
@@ -60,6 +60,8 @@ tasks:
|
|
|
60
60
|
| `prompt_file` | prompt 文件路径(解析顺序:绝对路径 → `~/.tloop/` 相对 → 任务目录相对) |
|
|
61
61
|
| `model` | 覆盖该任务的模型 |
|
|
62
62
|
| `branch` | `true`(自动 `feature-YYYYMMDD-NNN`)、`"custom/name"`、或 `false`(跳过分支) |
|
|
63
|
+
| `use` | 任务执行器:`cybervisor`(默认,多阶段复杂任务)或 `claude`(定向任务,支持循环) |
|
|
64
|
+
| `max_rounds` | `use: claude` 时生效,最大迭代次数(默认 5);到达上限前未收到 `<promise>COMPLETE</promise>` 信号则任务失败 |
|
|
63
65
|
|
|
64
66
|
## 用法
|
|
65
67
|
|
|
@@ -76,6 +78,37 @@ tloop archive --latest # 显示最近一次归档详情
|
|
|
76
78
|
tloop migrate # 迁移旧的项目本地数据到 ~/.tloop/
|
|
77
79
|
```
|
|
78
80
|
|
|
81
|
+
## 执行器
|
|
82
|
+
|
|
83
|
+
t-loop 支持两种任务执行器,通过 `use` 字段选择:
|
|
84
|
+
|
|
85
|
+
| 执行器 | 说明 | 适用场景 |
|
|
86
|
+
|--------|------|----------|
|
|
87
|
+
| `cybervisor` | 默认,执行 `cybervisor run`,适合复杂多阶段任务 | 大型重构、多步骤分析 |
|
|
88
|
+
| `claude` | 执行 `claude -p --dangerously-skip-permissions`,支持循环迭代 | 定向 bug 修复、明确目标的任务 |
|
|
89
|
+
|
|
90
|
+
### ClaudeRunner 循环机制
|
|
91
|
+
|
|
92
|
+
`use: claude` 时,任务以多轮迭代方式执行:
|
|
93
|
+
|
|
94
|
+
1. 每轮启动一次 `claude -p --dangerously-skip-permissions`,传入 prompt
|
|
95
|
+
2. 如果输出包含 `<promise>COMPLETE</promise>`,立即结束并标记成功
|
|
96
|
+
3. 否则等待 2 秒,继续下一轮,直到达到 `max_rounds`(默认 5)
|
|
97
|
+
4. `max_rounds` 用尽仍未检测到完成信号,任务标记失败
|
|
98
|
+
|
|
99
|
+
使用示例:
|
|
100
|
+
|
|
101
|
+
```yaml
|
|
102
|
+
tasks:
|
|
103
|
+
- name: 修复空指针 bug
|
|
104
|
+
dir: ~/proj
|
|
105
|
+
use: claude
|
|
106
|
+
max_rounds: 3
|
|
107
|
+
prompt_file: bugfix.md
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
在 prompt 文件中加入 `<promise>COMPLETE</promise>` 即可让 Claude 主动退出循环。
|
|
111
|
+
|
|
79
112
|
## 工作原理
|
|
80
113
|
|
|
81
114
|
每个任务的执行流程:
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Tests for ClaudeRunner."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import unittest
|
|
5
|
+
from unittest.mock import patch, MagicMock
|
|
6
|
+
|
|
7
|
+
from runner.claude import ClaudeRunner
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MockProcess:
|
|
11
|
+
def __init__(self, stdout_lines, returncode=0):
|
|
12
|
+
self._stdout_lines = stdout_lines
|
|
13
|
+
self.returncode = returncode
|
|
14
|
+
self.stdin = MagicMock()
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def stdout(self):
|
|
18
|
+
return iter(self._stdout_lines)
|
|
19
|
+
|
|
20
|
+
def wait(self):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ClaudeRunnerTests(unittest.TestCase):
|
|
25
|
+
|
|
26
|
+
def test_completion_signal_exits_early(self):
|
|
27
|
+
runner = ClaudeRunner()
|
|
28
|
+
mock_process = MockProcess(["Some output\n", "<promise>COMPLETE</promise>\n", "More output\n"])
|
|
29
|
+
with patch("subprocess.Popen", return_value=mock_process):
|
|
30
|
+
with patch("time.sleep"):
|
|
31
|
+
returncode = runner.run("test prompt", "/tmp", max_rounds=5)
|
|
32
|
+
self.assertEqual(returncode, 0)
|
|
33
|
+
|
|
34
|
+
def test_no_signal_exhausts_rounds(self):
|
|
35
|
+
runner = ClaudeRunner()
|
|
36
|
+
mock_process = MockProcess(["Some output\n", "No completion here\n"])
|
|
37
|
+
with patch("subprocess.Popen", return_value=mock_process):
|
|
38
|
+
with patch("time.sleep"):
|
|
39
|
+
returncode = runner.run("test prompt", "/tmp", max_rounds=3)
|
|
40
|
+
self.assertEqual(returncode, 1)
|
|
41
|
+
|
|
42
|
+
def test_default_max_rounds_is_5(self):
|
|
43
|
+
runner = ClaudeRunner()
|
|
44
|
+
calls = []
|
|
45
|
+
mock_process = MockProcess(["No signal\n"])
|
|
46
|
+
|
|
47
|
+
def track_popen(*args, **kwargs):
|
|
48
|
+
calls.append(args)
|
|
49
|
+
return mock_process
|
|
50
|
+
|
|
51
|
+
with patch("subprocess.Popen", side_effect=track_popen):
|
|
52
|
+
with patch("time.sleep"):
|
|
53
|
+
runner.run("test prompt", "/tmp")
|
|
54
|
+
self.assertEqual(len(calls), 5)
|
|
55
|
+
|
|
56
|
+
def test_custom_max_rounds_respected(self):
|
|
57
|
+
runner = ClaudeRunner()
|
|
58
|
+
calls = []
|
|
59
|
+
mock_process = MockProcess(["No signal\n"])
|
|
60
|
+
|
|
61
|
+
def track_popen(*args, **kwargs):
|
|
62
|
+
calls.append(args)
|
|
63
|
+
return mock_process
|
|
64
|
+
|
|
65
|
+
with patch("subprocess.Popen", side_effect=track_popen):
|
|
66
|
+
with patch("time.sleep"):
|
|
67
|
+
runner.run("test prompt", "/tmp", max_rounds=3)
|
|
68
|
+
self.assertEqual(len(calls), 3)
|
|
69
|
+
|
|
70
|
+
def test_first_round_complete_exits_early(self):
|
|
71
|
+
runner = ClaudeRunner()
|
|
72
|
+
mock_process = MockProcess(["<promise>COMPLETE</promise>\n"])
|
|
73
|
+
with patch("subprocess.Popen", return_value=mock_process):
|
|
74
|
+
with patch("time.sleep") as mock_sleep:
|
|
75
|
+
returncode = runner.run("test prompt", "/tmp", max_rounds=5)
|
|
76
|
+
self.assertEqual(returncode, 0)
|
|
77
|
+
mock_sleep.assert_not_called()
|
|
78
|
+
|
|
79
|
+
def test_sleep_between_rounds(self):
|
|
80
|
+
runner = ClaudeRunner()
|
|
81
|
+
mock_process = MockProcess(["No signal\n"])
|
|
82
|
+
with patch("subprocess.Popen", return_value=mock_process):
|
|
83
|
+
with patch("time.sleep") as mock_sleep:
|
|
84
|
+
runner.run("test prompt", "/tmp", max_rounds=3)
|
|
85
|
+
self.assertEqual(mock_sleep.call_count, 2)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
unittest.main()
|
|
@@ -15,6 +15,7 @@ import config
|
|
|
15
15
|
import state as state_mod
|
|
16
16
|
import task as task_mod
|
|
17
17
|
import cmd_run
|
|
18
|
+
import cmd_edit
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
def _init_git_repo(tmpdir):
|
|
@@ -44,6 +45,7 @@ def _setup_home(tmp_path):
|
|
|
44
45
|
patch.object(config, "TLOOP_HOME", home),
|
|
45
46
|
patch.object(config, "TASKS_FILE", home / "tasks.yaml"),
|
|
46
47
|
patch.object(config, "STATE_FILE", home / "state.json"),
|
|
48
|
+
patch.object(config, "SETTINGS_FILE", home / "settings.json"),
|
|
47
49
|
patch.object(config, "LOGS_DIR", home / "logs"),
|
|
48
50
|
patch.object(config, "ARCHIVE_DIR", home / "archive"),
|
|
49
51
|
]
|
|
@@ -60,6 +62,7 @@ class EnsureTloopHomeTests(unittest.TestCase):
|
|
|
60
62
|
patch.object(config, "TLOOP_HOME", self.home),
|
|
61
63
|
patch.object(config, "TASKS_FILE", self.home / "tasks.yaml"),
|
|
62
64
|
patch.object(config, "STATE_FILE", self.home / "state.json"),
|
|
65
|
+
patch.object(config, "SETTINGS_FILE", self.home / "settings.json"),
|
|
63
66
|
patch.object(config, "LOGS_DIR", self.home / "logs"),
|
|
64
67
|
patch.object(config, "ARCHIVE_DIR", self.home / "archive"),
|
|
65
68
|
]
|
|
@@ -86,7 +89,7 @@ class EnsureTloopHomeTests(unittest.TestCase):
|
|
|
86
89
|
self.assertTrue(tasks_file.exists())
|
|
87
90
|
content = tasks_file.read_text()
|
|
88
91
|
self.assertIn("tasks:", content)
|
|
89
|
-
self.
|
|
92
|
+
self.assertTrue(content.startswith("# Run 'tloop edit --help'"))
|
|
90
93
|
|
|
91
94
|
def test_idempotent_no_exit_when_tasks_exist(self):
|
|
92
95
|
self.home.mkdir(parents=True, exist_ok=True)
|
|
@@ -765,7 +768,7 @@ class RunTaskGitIntegrationTests(unittest.TestCase):
|
|
|
765
768
|
"prompt": "do something",
|
|
766
769
|
"branch": "feat/test",
|
|
767
770
|
}
|
|
768
|
-
result = task_mod.run_task(task, 0, state
|
|
771
|
+
result = task_mod.run_task(task, 0, state)
|
|
769
772
|
self.assertTrue(result)
|
|
770
773
|
mock_clean.assert_called_once()
|
|
771
774
|
mock_branch.assert_called_once_with(self.tmpdir, "feat/test")
|
|
@@ -778,7 +781,7 @@ class RunTaskGitIntegrationTests(unittest.TestCase):
|
|
|
778
781
|
"dir": self.tmpdir,
|
|
779
782
|
"prompt": "do something",
|
|
780
783
|
}
|
|
781
|
-
result = task_mod.run_task(task, 0, state
|
|
784
|
+
result = task_mod.run_task(task, 0, state)
|
|
782
785
|
self.assertFalse(result)
|
|
783
786
|
self.assertEqual(state["tasks"]["0"]["status"], "failed")
|
|
784
787
|
self.assertIn("auto-commit", state["tasks"]["0"]["error"])
|
|
@@ -793,7 +796,7 @@ class RunTaskGitIntegrationTests(unittest.TestCase):
|
|
|
793
796
|
"prompt": "do something",
|
|
794
797
|
"branch": True,
|
|
795
798
|
}
|
|
796
|
-
result = task_mod.run_task(task, 0, state
|
|
799
|
+
result = task_mod.run_task(task, 0, state)
|
|
797
800
|
self.assertFalse(result)
|
|
798
801
|
self.assertEqual(state["tasks"]["0"]["status"], "failed")
|
|
799
802
|
self.assertIn("branch", state["tasks"]["0"]["error"])
|
|
@@ -806,10 +809,182 @@ class RunTaskGitIntegrationTests(unittest.TestCase):
|
|
|
806
809
|
"dir": self.tmpdir,
|
|
807
810
|
"prompt": "do something",
|
|
808
811
|
}
|
|
809
|
-
task_mod.run_task(task, 0, state
|
|
812
|
+
task_mod.run_task(task, 0, state)
|
|
810
813
|
self.assertNotEqual(state["tasks"]["0"]["status"], "running")
|
|
811
814
|
self.assertEqual(state["tasks"]["0"]["status"], "failed")
|
|
812
815
|
|
|
813
816
|
|
|
817
|
+
class RunnerSelectionTests(unittest.TestCase):
|
|
818
|
+
"""T5.11: Tests for runner selection based on task 'use' field."""
|
|
819
|
+
|
|
820
|
+
def setUp(self):
|
|
821
|
+
self.tmpdir = tempfile.mkdtemp()
|
|
822
|
+
_init_git_repo(self.tmpdir)
|
|
823
|
+
self.home = Path(tempfile.mkdtemp()) / "tloop"
|
|
824
|
+
self.home.mkdir(parents=True)
|
|
825
|
+
(self.home / "logs").mkdir()
|
|
826
|
+
(self.home / "archive").mkdir()
|
|
827
|
+
self.patches = [
|
|
828
|
+
patch.object(config, "TLOOP_HOME", self.home),
|
|
829
|
+
patch.object(config, "TASKS_FILE", self.home / "tasks.yaml"),
|
|
830
|
+
patch.object(config, "STATE_FILE", self.home / "state.json"),
|
|
831
|
+
patch.object(config, "LOGS_DIR", self.home / "logs"),
|
|
832
|
+
patch.object(config, "ARCHIVE_DIR", self.home / "archive"),
|
|
833
|
+
]
|
|
834
|
+
for p in self.patches:
|
|
835
|
+
p.start()
|
|
836
|
+
|
|
837
|
+
def tearDown(self):
|
|
838
|
+
for p in self.patches:
|
|
839
|
+
p.stop()
|
|
840
|
+
|
|
841
|
+
@patch.object(task_mod, "ensure_clean_git", return_value=True)
|
|
842
|
+
@patch.object(task_mod, "create_task_branch", return_value=True)
|
|
843
|
+
@patch("runner.claude.subprocess.Popen")
|
|
844
|
+
def test_use_claude_selects_claude_runner(self, mock_popen, mock_branch, mock_clean):
|
|
845
|
+
mock_proc = MagicMock()
|
|
846
|
+
mock_proc.stdout = iter(["<promise>COMPLETE</promise>\n"])
|
|
847
|
+
mock_proc.returncode = 0
|
|
848
|
+
mock_proc.stdin = MagicMock()
|
|
849
|
+
mock_proc.wait.return_value = 0
|
|
850
|
+
mock_popen.return_value = mock_proc
|
|
851
|
+
|
|
852
|
+
state = {"tasks": {}, "version": 1}
|
|
853
|
+
task = {
|
|
854
|
+
"name": "test claude task",
|
|
855
|
+
"dir": self.tmpdir,
|
|
856
|
+
"prompt": "do something",
|
|
857
|
+
"use": "claude",
|
|
858
|
+
"branch": "feat/test",
|
|
859
|
+
}
|
|
860
|
+
result = task_mod.run_task(task, 0, state)
|
|
861
|
+
self.assertTrue(result)
|
|
862
|
+
self.assertEqual(state["tasks"]["0"]["status"], "done")
|
|
863
|
+
|
|
864
|
+
@patch.object(task_mod, "ensure_clean_git", return_value=True)
|
|
865
|
+
@patch.object(task_mod, "create_task_branch", return_value=True)
|
|
866
|
+
@patch("runner.claude.subprocess.Popen")
|
|
867
|
+
def test_use_claude_max_rounds_respected(self, mock_popen, mock_branch, mock_clean):
|
|
868
|
+
mock_proc = MagicMock()
|
|
869
|
+
mock_proc.stdout = iter(["no completion\n"])
|
|
870
|
+
mock_proc.returncode = 0
|
|
871
|
+
mock_proc.stdin = MagicMock()
|
|
872
|
+
mock_proc.wait.return_value = 0
|
|
873
|
+
mock_popen.return_value = mock_proc
|
|
874
|
+
|
|
875
|
+
state = {"tasks": {}, "version": 1}
|
|
876
|
+
task = {
|
|
877
|
+
"name": "test claude task",
|
|
878
|
+
"dir": self.tmpdir,
|
|
879
|
+
"prompt": "do something",
|
|
880
|
+
"use": "claude",
|
|
881
|
+
"max_rounds": 3,
|
|
882
|
+
"branch": "feat/test",
|
|
883
|
+
}
|
|
884
|
+
with patch("time.sleep"):
|
|
885
|
+
result = task_mod.run_task(task, 0, state)
|
|
886
|
+
self.assertFalse(result)
|
|
887
|
+
self.assertEqual(state["tasks"]["0"]["status"], "failed")
|
|
888
|
+
self.assertEqual(mock_popen.call_count, 3)
|
|
889
|
+
|
|
890
|
+
@patch.object(task_mod, "ensure_clean_git", return_value=True)
|
|
891
|
+
@patch.object(task_mod, "create_task_branch", return_value=True)
|
|
892
|
+
@patch("runner.cybervisor.subprocess.Popen")
|
|
893
|
+
def test_use_cybervisor_selects_cybervisor_runner(self, mock_popen, mock_branch, mock_clean):
|
|
894
|
+
mock_proc = MagicMock()
|
|
895
|
+
mock_proc.stdout = iter(["output\n"])
|
|
896
|
+
mock_proc.returncode = 0
|
|
897
|
+
mock_proc.stdin = MagicMock()
|
|
898
|
+
mock_proc.wait.return_value = 0
|
|
899
|
+
mock_popen.return_value = mock_proc
|
|
900
|
+
|
|
901
|
+
state = {"tasks": {}, "version": 1}
|
|
902
|
+
task = {
|
|
903
|
+
"name": "test cybervisor task",
|
|
904
|
+
"dir": self.tmpdir,
|
|
905
|
+
"prompt": "do something",
|
|
906
|
+
"use": "cybervisor",
|
|
907
|
+
"branch": "feat/test",
|
|
908
|
+
}
|
|
909
|
+
result = task_mod.run_task(task, 0, state)
|
|
910
|
+
self.assertTrue(result)
|
|
911
|
+
self.assertEqual(state["tasks"]["0"]["status"], "done")
|
|
912
|
+
|
|
913
|
+
@patch.object(task_mod, "ensure_clean_git", return_value=True)
|
|
914
|
+
@patch.object(task_mod, "create_task_branch", return_value=True)
|
|
915
|
+
@patch("runner.cybervisor.subprocess.Popen")
|
|
916
|
+
def test_missing_use_field_defaults_to_cybervisor(self, mock_popen, mock_branch, mock_clean):
|
|
917
|
+
mock_proc = MagicMock()
|
|
918
|
+
mock_proc.stdout = iter(["output\n"])
|
|
919
|
+
mock_proc.returncode = 0
|
|
920
|
+
mock_proc.stdin = MagicMock()
|
|
921
|
+
mock_proc.wait.return_value = 0
|
|
922
|
+
mock_popen.return_value = mock_proc
|
|
923
|
+
|
|
924
|
+
state = {"tasks": {}, "version": 1}
|
|
925
|
+
task = {
|
|
926
|
+
"name": "test default task",
|
|
927
|
+
"dir": self.tmpdir,
|
|
928
|
+
"prompt": "do something",
|
|
929
|
+
"branch": "feat/test",
|
|
930
|
+
}
|
|
931
|
+
result = task_mod.run_task(task, 0, state)
|
|
932
|
+
self.assertTrue(result)
|
|
933
|
+
self.assertEqual(state["tasks"]["0"]["status"], "done")
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
class EditorSelectionTests(unittest.TestCase):
|
|
937
|
+
"""Tests for editor selection and persistence in cmd_edit."""
|
|
938
|
+
|
|
939
|
+
def setUp(self):
|
|
940
|
+
self.tmpdir = tempfile.mkdtemp()
|
|
941
|
+
self.home = Path(self.tmpdir) / "tloop"
|
|
942
|
+
self.home.mkdir(parents=True)
|
|
943
|
+
self.patches = [
|
|
944
|
+
patch.object(config, "TLOOP_HOME", self.home),
|
|
945
|
+
patch.object(config, "SETTINGS_FILE", self.home / "settings.json"),
|
|
946
|
+
]
|
|
947
|
+
for p in self.patches:
|
|
948
|
+
p.start()
|
|
949
|
+
|
|
950
|
+
def tearDown(self):
|
|
951
|
+
for p in self.patches:
|
|
952
|
+
p.stop()
|
|
953
|
+
|
|
954
|
+
def test_cli_editor_overrides_settings(self):
|
|
955
|
+
cmd_edit._save_settings({"editor": "vim"})
|
|
956
|
+
result = cmd_edit._resolve_editor("code")
|
|
957
|
+
self.assertEqual(result, "code")
|
|
958
|
+
|
|
959
|
+
def test_saved_editor_used_when_no_cli_override(self):
|
|
960
|
+
cmd_edit._save_settings({"editor": "code"})
|
|
961
|
+
result = cmd_edit._resolve_editor()
|
|
962
|
+
self.assertEqual(result, "code")
|
|
963
|
+
|
|
964
|
+
def test_prompt_on_first_run(self):
|
|
965
|
+
with patch("builtins.input", side_effect=["1"]):
|
|
966
|
+
with patch.object(cmd_edit, "KNOWN_EDITORS", {"code": ("VS Code", "code")}):
|
|
967
|
+
with patch("shutil.which", return_value="/usr/local/bin/code"):
|
|
968
|
+
result = cmd_edit._resolve_editor()
|
|
969
|
+
self.assertEqual(result, "code")
|
|
970
|
+
settings = json.loads((self.home / "settings.json").read_text())
|
|
971
|
+
self.assertEqual(settings["editor"], "code")
|
|
972
|
+
|
|
973
|
+
def test_custom_editor_via_prompt(self):
|
|
974
|
+
with patch("builtins.input", side_effect=["2", "subl"]):
|
|
975
|
+
with patch.object(cmd_edit, "KNOWN_EDITORS", {"code": ("VS Code", "code")}):
|
|
976
|
+
with patch("shutil.which", return_value="/usr/local/bin/code"):
|
|
977
|
+
result = cmd_edit._resolve_editor()
|
|
978
|
+
self.assertEqual(result, "subl")
|
|
979
|
+
|
|
980
|
+
def test_corrupt_settings_file_triggers_prompt(self):
|
|
981
|
+
(self.home / "settings.json").write_text("not json")
|
|
982
|
+
with patch("builtins.input", side_effect=["1"]):
|
|
983
|
+
with patch.object(cmd_edit, "KNOWN_EDITORS", {"code": ("VS Code", "code")}):
|
|
984
|
+
with patch("shutil.which", return_value="/usr/local/bin/code"):
|
|
985
|
+
result = cmd_edit._resolve_editor()
|
|
986
|
+
self.assertEqual(result, "code")
|
|
987
|
+
|
|
988
|
+
|
|
814
989
|
if __name__ == "__main__":
|
|
815
990
|
unittest.main()
|
tloop_cli-0.4.0/src/cmd_edit.py
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
"""tloop edit — open ~/.tloop/tasks.yaml in editor."""
|
|
2
|
-
|
|
3
|
-
import argparse
|
|
4
|
-
import os
|
|
5
|
-
import subprocess
|
|
6
|
-
|
|
7
|
-
import config
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
EDIT_HELP = """\
|
|
11
|
-
Open ~/.tloop/tasks.yaml in your editor ($EDITOR, defaults to vi).
|
|
12
|
-
|
|
13
|
-
Task file format (~/.tloop/tasks.yaml):
|
|
14
|
-
|
|
15
|
-
defaults:
|
|
16
|
-
model: opus # optional default model
|
|
17
|
-
|
|
18
|
-
tasks:
|
|
19
|
-
- name: My task
|
|
20
|
-
dir: ~/projects/my-project
|
|
21
|
-
prompt: |
|
|
22
|
-
Describe what Claude should do.
|
|
23
|
-
# OR:
|
|
24
|
-
prompt_file: ./prompts/my-task.md
|
|
25
|
-
model: opus # optional override
|
|
26
|
-
branch: true # true=auto, "custom/name", false=skip
|
|
27
|
-
|
|
28
|
-
Each task runs in the specified directory. Completed tasks are
|
|
29
|
-
archived to ~/.tloop/archive/ after each run cycle.
|
|
30
|
-
"""
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def add_parser(subparsers):
|
|
34
|
-
p = subparsers.add_parser(
|
|
35
|
-
"edit",
|
|
36
|
-
help="Open ~/.tloop/tasks.yaml in editor",
|
|
37
|
-
description=EDIT_HELP,
|
|
38
|
-
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
39
|
-
)
|
|
40
|
-
p.set_defaults(func=handle)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def handle(args):
|
|
44
|
-
editor = os.environ.get("EDITOR", "vi")
|
|
45
|
-
subprocess.run([editor, str(config.TASKS_FILE)])
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
"""Claude Code runner backend (placeholder)."""
|
|
2
|
-
|
|
3
|
-
from runner import Runner
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
class ClaudeRunner(Runner):
|
|
7
|
-
"""Claude Code runner (not yet implemented)."""
|
|
8
|
-
|
|
9
|
-
def run(self, prompt, cwd, model=None, log_file=None):
|
|
10
|
-
raise NotImplementedError("Claude Code runner not yet implemented")
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
"""Cybervisor runner backend."""
|
|
2
|
-
|
|
3
|
-
import subprocess
|
|
4
|
-
|
|
5
|
-
from runner import Runner
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class CybervisorRunner(Runner):
|
|
9
|
-
def run(self, prompt, cwd, model=None, log_file=None):
|
|
10
|
-
cmd = ["cybervisor", "run", prompt]
|
|
11
|
-
if model:
|
|
12
|
-
cmd.extend(["--model", model])
|
|
13
|
-
|
|
14
|
-
with open(log_file, "a") if log_file else open("/dev/null", "a") as log:
|
|
15
|
-
process = subprocess.Popen(
|
|
16
|
-
cmd,
|
|
17
|
-
cwd=cwd,
|
|
18
|
-
stdout=subprocess.PIPE,
|
|
19
|
-
stderr=subprocess.STDOUT,
|
|
20
|
-
text=True,
|
|
21
|
-
)
|
|
22
|
-
for line in process.stdout:
|
|
23
|
-
print(line, end="")
|
|
24
|
-
log.write(line)
|
|
25
|
-
log.flush()
|
|
26
|
-
process.wait()
|
|
27
|
-
|
|
28
|
-
return process.returncode
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|