tloop-cli 0.4.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/LICENSE +21 -0
- tloop_cli-0.4.0/PKG-INFO +107 -0
- tloop_cli-0.4.0/README.md +96 -0
- tloop_cli-0.4.0/pyproject.toml +28 -0
- tloop_cli-0.4.0/setup.cfg +4 -0
- tloop_cli-0.4.0/src/claude_runner.py +82 -0
- tloop_cli-0.4.0/src/cmd_archive.py +15 -0
- tloop_cli-0.4.0/src/cmd_edit.py +45 -0
- tloop_cli-0.4.0/src/cmd_migrate.py +71 -0
- tloop_cli-0.4.0/src/cmd_run.py +83 -0
- tloop_cli-0.4.0/src/config.py +69 -0
- tloop_cli-0.4.0/src/git_ops.py +151 -0
- tloop_cli-0.4.0/src/main.py +38 -0
- tloop_cli-0.4.0/src/runner/__init__.py +10 -0
- tloop_cli-0.4.0/src/runner/claude.py +10 -0
- tloop_cli-0.4.0/src/runner/cybervisor.py +28 -0
- tloop_cli-0.4.0/src/state.py +141 -0
- tloop_cli-0.4.0/src/task.py +142 -0
- tloop_cli-0.4.0/src/tloop_cli.egg-info/PKG-INFO +107 -0
- tloop_cli-0.4.0/src/tloop_cli.egg-info/SOURCES.txt +24 -0
- tloop_cli-0.4.0/src/tloop_cli.egg-info/dependency_links.txt +1 -0
- tloop_cli-0.4.0/src/tloop_cli.egg-info/entry_points.txt +2 -0
- tloop_cli-0.4.0/src/tloop_cli.egg-info/requires.txt +1 -0
- tloop_cli-0.4.0/src/tloop_cli.egg-info/top_level.txt +11 -0
- tloop_cli-0.4.0/test/test_cli.py +815 -0
- tloop_cli-0.4.0/test/test_git_ops.py +238 -0
tloop_cli-0.4.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 XiaodongTong
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
tloop_cli-0.4.0/PKG-INFO
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tloop-cli
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Automated Claude Code task runner
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: pyyaml>=6.0
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
# t-loop
|
|
13
|
+
|
|
14
|
+
自动化 Claude Code 任务运行器。用 YAML 定义任务,t-loop 按顺序执行,自带 git 安全保护。
|
|
15
|
+
|
|
16
|
+
## 安装
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install t-loop # 从 PyPI 安装
|
|
20
|
+
pip install -e . # 本地开发安装
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
需要 Python >=3.9,唯一外部依赖:`pyyaml`。
|
|
24
|
+
|
|
25
|
+
## 快速开始
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# 首次运行会创建 ~/.tloop/tasks.yaml 示例配置
|
|
29
|
+
tloop run
|
|
30
|
+
|
|
31
|
+
# 编辑任务后运行
|
|
32
|
+
tloop edit
|
|
33
|
+
tloop run
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## 任务配置
|
|
37
|
+
|
|
38
|
+
编辑 `~/.tloop/tasks.yaml`:
|
|
39
|
+
|
|
40
|
+
```yaml
|
|
41
|
+
tasks:
|
|
42
|
+
- name: 我的第一个任务
|
|
43
|
+
dir: ~/projects/my-project
|
|
44
|
+
prompt: |
|
|
45
|
+
描述 Claude 应该做什么。
|
|
46
|
+
|
|
47
|
+
- name: 使用 prompt 文件的任务
|
|
48
|
+
dir: ~/projects/my-project
|
|
49
|
+
prompt_file: ./prompts/my-task.md
|
|
50
|
+
branch: feat/login # 自定义分支名
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### 任务字段
|
|
54
|
+
|
|
55
|
+
| 字段 | 说明 |
|
|
56
|
+
|-------|-------------|
|
|
57
|
+
| `name` | 任务显示名称 |
|
|
58
|
+
| `dir` | 任务工作目录 |
|
|
59
|
+
| `prompt` | 内联 prompt 文本 |
|
|
60
|
+
| `prompt_file` | prompt 文件路径(解析顺序:绝对路径 → `~/.tloop/` 相对 → 任务目录相对) |
|
|
61
|
+
| `model` | 覆盖该任务的模型 |
|
|
62
|
+
| `branch` | `true`(自动 `feature-YYYYMMDD-NNN`)、`"custom/name"`、或 `false`(跳过分支) |
|
|
63
|
+
|
|
64
|
+
## 用法
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
tloop run # 运行所有待执行任务
|
|
68
|
+
tloop run --status # 查看任务状态
|
|
69
|
+
tloop run --only 2 # 只运行第 2 个任务
|
|
70
|
+
tloop run --confirm # 每个任务前确认
|
|
71
|
+
tloop run -c # 失败后继续执行
|
|
72
|
+
tloop run --reset # 重置所有任务为待执行
|
|
73
|
+
tloop edit # 用 $EDITOR 打开 tasks.yaml
|
|
74
|
+
tloop archive # 列出归档记录
|
|
75
|
+
tloop archive --latest # 显示最近一次归档详情
|
|
76
|
+
tloop migrate # 迁移旧的项目本地数据到 ~/.tloop/
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## 工作原理
|
|
80
|
+
|
|
81
|
+
每个任务的执行流程:
|
|
82
|
+
|
|
83
|
+
1. **自动提交** — 如果工作目录有未提交的更改,t-loop 会用 Claude 先提交暂存区内容,再提交剩余更改。敏感文件(.env、密钥等)会被排除。
|
|
84
|
+
2. **创建分支** — 创建任务分支(默认 `feature-YYYYMMDD-NNN`)隔离更改。
|
|
85
|
+
3. **执行任务** — 在目标目录运行 `cybervisor run <prompt>`。
|
|
86
|
+
4. **归档** — 已完成的任务移至 `~/.tloop/archive/`,并从 `tasks.yaml` 中移除。
|
|
87
|
+
|
|
88
|
+
## 文件位置
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
~/.tloop/
|
|
92
|
+
├── tasks.yaml # 任务定义
|
|
93
|
+
├── state.json # 运行时状态(任务状态)
|
|
94
|
+
├── logs/ # 执行日志
|
|
95
|
+
└── archive/ # 已完成任务的归档
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## 开发
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
pip install -e .
|
|
102
|
+
python -m pytest test/ -v
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## 许可证
|
|
106
|
+
|
|
107
|
+
MIT
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# t-loop
|
|
2
|
+
|
|
3
|
+
自动化 Claude Code 任务运行器。用 YAML 定义任务,t-loop 按顺序执行,自带 git 安全保护。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install t-loop # 从 PyPI 安装
|
|
9
|
+
pip install -e . # 本地开发安装
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
需要 Python >=3.9,唯一外部依赖:`pyyaml`。
|
|
13
|
+
|
|
14
|
+
## 快速开始
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
# 首次运行会创建 ~/.tloop/tasks.yaml 示例配置
|
|
18
|
+
tloop run
|
|
19
|
+
|
|
20
|
+
# 编辑任务后运行
|
|
21
|
+
tloop edit
|
|
22
|
+
tloop run
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## 任务配置
|
|
26
|
+
|
|
27
|
+
编辑 `~/.tloop/tasks.yaml`:
|
|
28
|
+
|
|
29
|
+
```yaml
|
|
30
|
+
tasks:
|
|
31
|
+
- name: 我的第一个任务
|
|
32
|
+
dir: ~/projects/my-project
|
|
33
|
+
prompt: |
|
|
34
|
+
描述 Claude 应该做什么。
|
|
35
|
+
|
|
36
|
+
- name: 使用 prompt 文件的任务
|
|
37
|
+
dir: ~/projects/my-project
|
|
38
|
+
prompt_file: ./prompts/my-task.md
|
|
39
|
+
branch: feat/login # 自定义分支名
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### 任务字段
|
|
43
|
+
|
|
44
|
+
| 字段 | 说明 |
|
|
45
|
+
|-------|-------------|
|
|
46
|
+
| `name` | 任务显示名称 |
|
|
47
|
+
| `dir` | 任务工作目录 |
|
|
48
|
+
| `prompt` | 内联 prompt 文本 |
|
|
49
|
+
| `prompt_file` | prompt 文件路径(解析顺序:绝对路径 → `~/.tloop/` 相对 → 任务目录相对) |
|
|
50
|
+
| `model` | 覆盖该任务的模型 |
|
|
51
|
+
| `branch` | `true`(自动 `feature-YYYYMMDD-NNN`)、`"custom/name"`、或 `false`(跳过分支) |
|
|
52
|
+
|
|
53
|
+
## 用法
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
tloop run # 运行所有待执行任务
|
|
57
|
+
tloop run --status # 查看任务状态
|
|
58
|
+
tloop run --only 2 # 只运行第 2 个任务
|
|
59
|
+
tloop run --confirm # 每个任务前确认
|
|
60
|
+
tloop run -c # 失败后继续执行
|
|
61
|
+
tloop run --reset # 重置所有任务为待执行
|
|
62
|
+
tloop edit # 用 $EDITOR 打开 tasks.yaml
|
|
63
|
+
tloop archive # 列出归档记录
|
|
64
|
+
tloop archive --latest # 显示最近一次归档详情
|
|
65
|
+
tloop migrate # 迁移旧的项目本地数据到 ~/.tloop/
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## 工作原理
|
|
69
|
+
|
|
70
|
+
每个任务的执行流程:
|
|
71
|
+
|
|
72
|
+
1. **自动提交** — 如果工作目录有未提交的更改,t-loop 会用 Claude 先提交暂存区内容,再提交剩余更改。敏感文件(.env、密钥等)会被排除。
|
|
73
|
+
2. **创建分支** — 创建任务分支(默认 `feature-YYYYMMDD-NNN`)隔离更改。
|
|
74
|
+
3. **执行任务** — 在目标目录运行 `cybervisor run <prompt>`。
|
|
75
|
+
4. **归档** — 已完成的任务移至 `~/.tloop/archive/`,并从 `tasks.yaml` 中移除。
|
|
76
|
+
|
|
77
|
+
## 文件位置
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
~/.tloop/
|
|
81
|
+
├── tasks.yaml # 任务定义
|
|
82
|
+
├── state.json # 运行时状态(任务状态)
|
|
83
|
+
├── logs/ # 执行日志
|
|
84
|
+
└── archive/ # 已完成任务的归档
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## 开发
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
pip install -e .
|
|
91
|
+
python -m pytest test/ -v
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## 许可证
|
|
95
|
+
|
|
96
|
+
MIT
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tloop-cli"
|
|
7
|
+
version = "0.4.0"
|
|
8
|
+
description = "Automated Claude Code task runner"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"pyyaml>=6.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
tloop = "main:main"
|
|
18
|
+
|
|
19
|
+
[tool.setuptools]
|
|
20
|
+
package-dir = {"" = "src"}
|
|
21
|
+
py-modules = ["main", "config", "state", "task", "cmd_run", "cmd_edit", "cmd_migrate", "cmd_archive", "git_ops", "claude_runner"]
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
where = ["src"]
|
|
25
|
+
include = ["runner*"]
|
|
26
|
+
|
|
27
|
+
[tool.pytest.ini_options]
|
|
28
|
+
testpaths = ["test"]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Reusable Claude CLI runner with retry and verification."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
GREEN = "\033[92m"
|
|
6
|
+
RED = "\033[91m"
|
|
7
|
+
YELLOW = "\033[93m"
|
|
8
|
+
RESET = "\033[0m"
|
|
9
|
+
|
|
10
|
+
DEFAULT_MAX_RETRIES = 3
|
|
11
|
+
|
|
12
|
+
# Append this to prompts where Claude might just plan instead of executing.
|
|
13
|
+
EXECUTION_SUFFIX = (
|
|
14
|
+
"\n\nIMPORTANT: Execute the steps above immediately. "
|
|
15
|
+
"Do NOT ask for confirmation, do NOT just describe what you would do. "
|
|
16
|
+
"Perform every step now using your available tools."
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_claude(prompt, cwd, max_retries=DEFAULT_MAX_RETRIES, verify_fn=None, log_file=None):
|
|
21
|
+
"""Run `claude -p` with --dangerously-skip-permissions and optional retry loop.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
prompt: The prompt text to send to Claude.
|
|
25
|
+
cwd: Working directory for the subprocess.
|
|
26
|
+
max_retries: Max attempts before giving up.
|
|
27
|
+
verify_fn: Optional callable(cwd) -> bool that checks if the work was actually done.
|
|
28
|
+
log_file: Optional path to append logs.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
True if Claude succeeded (and passed verification if provided), False otherwise.
|
|
32
|
+
"""
|
|
33
|
+
enriched_prompt = prompt + EXECUTION_SUFFIX
|
|
34
|
+
cmd = ["claude", "--dangerously-skip-permissions", "--print", enriched_prompt]
|
|
35
|
+
|
|
36
|
+
for attempt in range(1, max_retries + 1):
|
|
37
|
+
if attempt > 1:
|
|
38
|
+
hint = (
|
|
39
|
+
f"This is attempt {attempt}/{max_retries}. "
|
|
40
|
+
"The previous attempt did not complete the task. "
|
|
41
|
+
"You MUST execute the steps now, not describe them."
|
|
42
|
+
)
|
|
43
|
+
attempt_prompt = prompt + "\n\n" + hint + EXECUTION_SUFFIX
|
|
44
|
+
attempt_cmd = ["claude", "--dangerously-skip-permissions", "--print", attempt_prompt]
|
|
45
|
+
else:
|
|
46
|
+
attempt_cmd = cmd
|
|
47
|
+
|
|
48
|
+
result = subprocess.run(
|
|
49
|
+
attempt_cmd,
|
|
50
|
+
cwd=cwd,
|
|
51
|
+
capture_output=True,
|
|
52
|
+
text=True,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if log_file:
|
|
56
|
+
with open(log_file, "a") as log:
|
|
57
|
+
log.write(f"[claude_runner] attempt {attempt}/{max_retries} exit={result.returncode}\n")
|
|
58
|
+
if result.stdout:
|
|
59
|
+
log.write(result.stdout + "\n")
|
|
60
|
+
if result.stderr:
|
|
61
|
+
log.write(result.stderr + "\n")
|
|
62
|
+
log.flush()
|
|
63
|
+
|
|
64
|
+
if result.returncode != 0:
|
|
65
|
+
print(f"{YELLOW} Claude exited with code {result.returncode} (attempt {attempt}/{max_retries}){RESET}")
|
|
66
|
+
if attempt < max_retries:
|
|
67
|
+
continue
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
# If no verification function, trust the exit code.
|
|
71
|
+
if verify_fn is None:
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
if verify_fn(cwd):
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
print(f"{YELLOW} Claude completed but verification failed (attempt {attempt}/{max_retries}){RESET}")
|
|
78
|
+
if attempt >= max_retries:
|
|
79
|
+
print(f"{RED} Max retries reached. Giving up.{RESET}")
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
return False
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""tloop archive — view archived task runs."""
|
|
2
|
+
|
|
3
|
+
import config
|
|
4
|
+
from state import show_archives
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def add_parser(subparsers):
|
|
8
|
+
p = subparsers.add_parser("archive", help="View archived task runs")
|
|
9
|
+
p.add_argument("--latest", action="store_true", help="Show most recent archive details")
|
|
10
|
+
p.set_defaults(func=handle)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def handle(args):
|
|
14
|
+
config.ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
show_archives(latest=args.latest)
|
|
@@ -0,0 +1,45 @@
|
|
|
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)])
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""tloop migrate — migrate old data files to ~/.tloop/."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
import config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def add_parser(subparsers):
|
|
13
|
+
p = subparsers.add_parser("migrate", help="Migrate old data to ~/.tloop/")
|
|
14
|
+
p.set_defaults(func=handle)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def handle(args):
|
|
18
|
+
project_root = Path(__file__).resolve().parent.parent.parent
|
|
19
|
+
|
|
20
|
+
old_tasks = project_root / "tasks.yaml"
|
|
21
|
+
old_state = project_root / ".tloop-state.json"
|
|
22
|
+
old_logs = project_root / "logs"
|
|
23
|
+
|
|
24
|
+
found = []
|
|
25
|
+
if old_tasks.exists():
|
|
26
|
+
found.append(old_tasks)
|
|
27
|
+
if old_state.exists():
|
|
28
|
+
found.append(old_state)
|
|
29
|
+
if old_logs.exists() and old_logs.is_dir():
|
|
30
|
+
found.append(old_logs)
|
|
31
|
+
|
|
32
|
+
if not found:
|
|
33
|
+
print(f"{config.YELLOW}No old data files found in {project_root}{config.RESET}")
|
|
34
|
+
print("Nothing to migrate.")
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
if config.TASKS_FILE.exists():
|
|
38
|
+
print(f"{config.RED}Conflict: {config.TASKS_FILE} already exists{config.RESET}")
|
|
39
|
+
print("Resolve the conflict manually before migrating.")
|
|
40
|
+
sys.exit(1)
|
|
41
|
+
|
|
42
|
+
config.TLOOP_HOME.mkdir(exist_ok=True)
|
|
43
|
+
config.LOGS_DIR.mkdir(exist_ok=True)
|
|
44
|
+
config.ARCHIVE_DIR.mkdir(exist_ok=True)
|
|
45
|
+
|
|
46
|
+
if old_tasks.exists():
|
|
47
|
+
shutil.copy2(old_tasks, config.TASKS_FILE)
|
|
48
|
+
print(f" Migrated: tasks.yaml → {config.TASKS_FILE}")
|
|
49
|
+
|
|
50
|
+
data = yaml.safe_load(open(old_tasks)) or {}
|
|
51
|
+
for i, task in enumerate(data.get("tasks", [])):
|
|
52
|
+
pf = task.get("prompt_file")
|
|
53
|
+
if pf and not Path(pf).expanduser().is_absolute():
|
|
54
|
+
print(
|
|
55
|
+
f" {config.YELLOW}Warning: task '{task.get('name', f'Task {i + 1}')}' "
|
|
56
|
+
f"has relative prompt_file '{pf}'{config.RESET}"
|
|
57
|
+
)
|
|
58
|
+
print(f" Will resolve from {config.TLOOP_HOME} first, then the task's dir.")
|
|
59
|
+
|
|
60
|
+
if old_state.exists():
|
|
61
|
+
shutil.copy2(old_state, config.STATE_FILE)
|
|
62
|
+
print(f" Migrated: .tloop-state.json → {config.STATE_FILE}")
|
|
63
|
+
|
|
64
|
+
if old_logs.exists() and old_logs.is_dir():
|
|
65
|
+
for log_file in old_logs.iterdir():
|
|
66
|
+
if log_file.is_file():
|
|
67
|
+
shutil.copy2(log_file, config.LOGS_DIR / log_file.name)
|
|
68
|
+
print(f" Migrated: logs/ → {config.LOGS_DIR}/")
|
|
69
|
+
|
|
70
|
+
print(f"\n{config.GREEN}Migration complete.{config.RESET}")
|
|
71
|
+
print(f"Old files still exist in {project_root} — remove them manually when ready.")
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""tloop run — execute tasks defined in ~/.tloop/tasks.yaml."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import config
|
|
6
|
+
from config import ensure_tloop_home, load_config
|
|
7
|
+
from state import load_state, save_state, show_status, archive_completed_tasks
|
|
8
|
+
from task import run_task
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def add_parser(subparsers):
|
|
12
|
+
p = subparsers.add_parser("run", help="Run tasks defined in ~/.tloop/tasks.yaml")
|
|
13
|
+
p.add_argument("--status", "-s", action="store_true", help="Show task status")
|
|
14
|
+
p.add_argument("--reset", action="store_true", help="Reset all tasks to pending")
|
|
15
|
+
p.add_argument("--only", type=int, help="Run only task #N (1-based)")
|
|
16
|
+
p.add_argument("--confirm", "-i", action="store_true", help="Confirm before each task")
|
|
17
|
+
p.add_argument("--continue", "-c", dest="continue_on_fail", action="store_true",
|
|
18
|
+
help="Continue even if a task fails")
|
|
19
|
+
p.set_defaults(func=handle)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def handle(args):
|
|
23
|
+
ensure_tloop_home()
|
|
24
|
+
|
|
25
|
+
cfg = load_config()
|
|
26
|
+
tasks = cfg.get("tasks", [])
|
|
27
|
+
defaults = cfg.get("defaults", {})
|
|
28
|
+
|
|
29
|
+
if not tasks:
|
|
30
|
+
print("No tasks defined in tasks.yaml")
|
|
31
|
+
sys.exit(0)
|
|
32
|
+
|
|
33
|
+
state = load_state()
|
|
34
|
+
|
|
35
|
+
if args.status:
|
|
36
|
+
show_status(tasks, state)
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
if args.reset:
|
|
40
|
+
state = {"tasks": {}, "version": 1}
|
|
41
|
+
save_state(state)
|
|
42
|
+
print(f"{config.GREEN}State reset. All tasks are pending.{config.RESET}")
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
if args.only is not None:
|
|
46
|
+
if args.only < 1 or args.only > len(tasks):
|
|
47
|
+
print(f"{config.RED}Invalid task number: {args.only}{config.RESET}")
|
|
48
|
+
sys.exit(1)
|
|
49
|
+
indices = [args.only - 1]
|
|
50
|
+
else:
|
|
51
|
+
indices = list(range(len(tasks)))
|
|
52
|
+
|
|
53
|
+
ran_any = False
|
|
54
|
+
for i in indices:
|
|
55
|
+
ts = state.get("tasks", {}).get(str(i), {})
|
|
56
|
+
status = ts.get("status", "pending")
|
|
57
|
+
|
|
58
|
+
if status == "done" and args.only is None:
|
|
59
|
+
print(f"⏭️ Task [{i + 1}] already done, skipping")
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
if args.confirm:
|
|
63
|
+
name = tasks[i].get("name", f"Task {i + 1}")
|
|
64
|
+
try:
|
|
65
|
+
resp = input(f"\nRun task [{i + 1}] '{name}'? [y/N] ")
|
|
66
|
+
except (EOFError, KeyboardInterrupt):
|
|
67
|
+
print("\nAborted.")
|
|
68
|
+
break
|
|
69
|
+
if resp.lower() != "y":
|
|
70
|
+
print("Skipped.")
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
ran_any = True
|
|
74
|
+
success = run_task(tasks[i], i, state, defaults)
|
|
75
|
+
if not success and not args.continue_on_fail:
|
|
76
|
+
print(f"\n{config.YELLOW}Stopped. Use -c to continue after failures.{config.RESET}")
|
|
77
|
+
break
|
|
78
|
+
|
|
79
|
+
if ran_any:
|
|
80
|
+
print(f"\n{config.BOLD}--- Final Status ---{config.RESET}")
|
|
81
|
+
show_status(tasks, state)
|
|
82
|
+
|
|
83
|
+
archive_completed_tasks(cfg, state)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Configuration constants and helpers for t-loop."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
TLOOP_HOME = Path.home() / ".tloop"
|
|
9
|
+
TASKS_FILE = TLOOP_HOME / "tasks.yaml"
|
|
10
|
+
STATE_FILE = TLOOP_HOME / "state.json"
|
|
11
|
+
LOGS_DIR = TLOOP_HOME / "logs"
|
|
12
|
+
ARCHIVE_DIR = TLOOP_HOME / "archive"
|
|
13
|
+
|
|
14
|
+
GREEN = "\033[92m"
|
|
15
|
+
RED = "\033[91m"
|
|
16
|
+
YELLOW = "\033[93m"
|
|
17
|
+
CYAN = "\033[96m"
|
|
18
|
+
BOLD = "\033[1m"
|
|
19
|
+
RESET = "\033[0m"
|
|
20
|
+
|
|
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
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def ensure_tloop_home():
|
|
52
|
+
TLOOP_HOME.mkdir(exist_ok=True)
|
|
53
|
+
LOGS_DIR.mkdir(exist_ok=True)
|
|
54
|
+
ARCHIVE_DIR.mkdir(exist_ok=True)
|
|
55
|
+
|
|
56
|
+
if not TASKS_FILE.exists():
|
|
57
|
+
TASKS_FILE.write_text(SAMPLE_TASKS_YAML)
|
|
58
|
+
print(f"{GREEN}Created {TASKS_FILE}{RESET}")
|
|
59
|
+
print(f"\nEdit ~/.tloop/tasks.yaml to define your tasks, then run tloop run.")
|
|
60
|
+
sys.exit(0)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_config():
|
|
64
|
+
if not TASKS_FILE.exists():
|
|
65
|
+
print(f"{RED}Error: {TASKS_FILE} not found{RESET}")
|
|
66
|
+
print(f"Run tloop run to initialize, then edit tasks.yaml.")
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
with open(TASKS_FILE) as f:
|
|
69
|
+
return yaml.safe_load(f) or {}
|