tloop-cli 0.4.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.
claude_runner.py ADDED
@@ -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
cmd_archive.py ADDED
@@ -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)
cmd_edit.py ADDED
@@ -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)])
cmd_migrate.py ADDED
@@ -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.")
cmd_run.py ADDED
@@ -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)
config.py ADDED
@@ -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 {}
git_ops.py ADDED
@@ -0,0 +1,151 @@
1
+ """Git operations for t-loop: auto-commit, branch management, and safety checks."""
2
+
3
+ import subprocess
4
+ from datetime import datetime
5
+
6
+ from claude_runner import run_claude
7
+
8
+ GREEN = "\033[92m"
9
+ RED = "\033[91m"
10
+ YELLOW = "\033[93m"
11
+ RESET = "\033[0m"
12
+
13
+ COMMIT_STAGED_PROMPT = (
14
+ "You are performing a protective auto-commit of STAGED changes in this repository.\n"
15
+ "Follow these steps exactly:\n"
16
+ "1. Run `git diff --cached --name-only` to see what is staged.\n"
17
+ "2. Check each file for sensitive content (e.g., .env, credentials, secret keys, API tokens, private keys). "
18
+ "If you find any sensitive files staged, unstage them with `git restore --staged <file>` and report what you excluded.\n"
19
+ "3. If any files remain staged after the sensitive-file check, commit them with a Chinese conventional-commit message "
20
+ "(format: `<type>: <description>` where type is one of: feat, fix, refactor, docs, style, test, chore, perf, ci, build). "
21
+ "Do NOT use the `--no-verify` flag.\n"
22
+ "4. If nothing remains staged after the sensitive-file check, report that there is nothing to commit.\n"
23
+ )
24
+
25
+ COMMIT_WORKDIR_PROMPT = (
26
+ "You are performing a protective auto-commit of all remaining UNSTAGED changes in this repository.\n"
27
+ "Follow these steps exactly:\n"
28
+ "1. Run `git add -A` to stage all working-directory changes.\n"
29
+ "2. Run `git diff --cached --name-only` to see what is now staged.\n"
30
+ "3. Check each file for sensitive content (e.g., .env, credentials, secret keys, API tokens, private keys). "
31
+ "If you find any sensitive files staged, unstage them with `git restore --staged <file>` and report what you excluded.\n"
32
+ "4. If any files remain staged after the sensitive-file check, commit them with a Chinese conventional-commit message "
33
+ "(format: `<type>: <description>` where type is one of: feat, fix, refactor, docs, style, test, chore, perf, ci, build). "
34
+ "Do NOT use the `--no-verify` flag.\n"
35
+ "5. If nothing remains staged after the sensitive-file check, report that there is nothing to commit.\n"
36
+ )
37
+
38
+
39
+ def _git(dir_path, *args):
40
+ result = subprocess.run(
41
+ ["git"] + list(args),
42
+ cwd=dir_path,
43
+ capture_output=True,
44
+ text=True,
45
+ )
46
+ return result
47
+
48
+
49
+ def is_git_repo(dir_path):
50
+ result = _git(dir_path, "rev-parse", "--is-inside-work-tree")
51
+ return result.returncode == 0 and result.stdout.strip() == "true"
52
+
53
+
54
+ def is_git_clean(dir_path):
55
+ result = _git(dir_path, "status", "--porcelain")
56
+ return result.returncode == 0 and result.stdout.strip() == ""
57
+
58
+
59
+ def has_staged_changes(dir_path):
60
+ result = _git(dir_path, "diff", "--cached", "--quiet")
61
+ return result.returncode == 1
62
+
63
+
64
+ def is_detached_head(dir_path):
65
+ result = _git(dir_path, "symbolic-ref", "-q", "HEAD")
66
+ return result.returncode != 0
67
+
68
+
69
+ def branch_exists(dir_path, name):
70
+ result = _git(dir_path, "branch", "--list", name)
71
+ return result.stdout.strip() != ""
72
+
73
+
74
+ def ensure_clean_git(dir_path, task_name, log_file=None):
75
+ if not is_git_repo(dir_path):
76
+ return True
77
+
78
+ if is_git_clean(dir_path):
79
+ return True
80
+
81
+ if has_staged_changes(dir_path):
82
+ print(f"{YELLOW} Dirty working tree detected. Auto-committing staged changes...{RESET}")
83
+ run_claude(
84
+ COMMIT_STAGED_PROMPT,
85
+ cwd=dir_path,
86
+ verify_fn=is_git_clean,
87
+ log_file=log_file,
88
+ )
89
+
90
+ if not is_git_clean(dir_path):
91
+ print(f"{YELLOW} Committing remaining working-directory changes...{RESET}")
92
+ run_claude(
93
+ COMMIT_WORKDIR_PROMPT,
94
+ cwd=dir_path,
95
+ verify_fn=is_git_clean,
96
+ log_file=log_file,
97
+ )
98
+
99
+ if is_git_clean(dir_path):
100
+ print(f"{GREEN} Working tree is now clean.{RESET}")
101
+ return True
102
+
103
+ print(f"{RED} Failed to clean working tree after auto-commit. Skipping task.{RESET}")
104
+ return False
105
+
106
+
107
+ def find_next_available_branch(dir_path, prefix):
108
+ for n in range(1, 1000):
109
+ name = f"{prefix}-{n:03d}"
110
+ if not branch_exists(dir_path, name):
111
+ return name
112
+ return None
113
+
114
+
115
+ def create_task_branch(dir_path, branch_config):
116
+ if not is_git_repo(dir_path):
117
+ return True
118
+
119
+ if branch_config is False:
120
+ return True
121
+
122
+ if is_detached_head(dir_path):
123
+ print(f"{RED} Cannot create branch: repository is in detached HEAD state.{RESET}")
124
+ return False
125
+
126
+ today = datetime.now().strftime("%Y%m%d")
127
+
128
+ if branch_config is True or branch_config is None:
129
+ prefix = f"feature-{today}"
130
+ name = find_next_available_branch(dir_path, prefix)
131
+ if name is None:
132
+ print(f"{RED} Could not find an available branch name with prefix {prefix}{RESET}")
133
+ return False
134
+ else:
135
+ custom = str(branch_config)
136
+ if not branch_exists(dir_path, custom):
137
+ name = custom
138
+ else:
139
+ prefix = custom
140
+ name = find_next_available_branch(dir_path, prefix)
141
+ if name is None:
142
+ print(f"{RED} Could not find an available branch name with prefix {prefix}{RESET}")
143
+ return False
144
+
145
+ result = _git(dir_path, "checkout", "-b", name)
146
+ if result.returncode != 0:
147
+ print(f"{RED} Failed to create branch '{name}': {result.stderr.strip()}{RESET}")
148
+ return False
149
+
150
+ print(f"{GREEN} Created and checked out branch: {name}{RESET}")
151
+ return True
main.py ADDED
@@ -0,0 +1,38 @@
1
+ """t-loop entry point."""
2
+
3
+ import argparse
4
+
5
+ __version__ = "0.4.0"
6
+
7
+
8
+ def main():
9
+ parser = argparse.ArgumentParser(
10
+ prog="tloop",
11
+ description="t-loop: Automated Claude Code task runner",
12
+ )
13
+ parser.add_argument("-v", "--version", action="version",
14
+ version=f"tloop {__version__}")
15
+
16
+ subparsers = parser.add_subparsers(title="commands")
17
+
18
+ from cmd_run import add_parser as add_run
19
+ from cmd_edit import add_parser as add_edit
20
+ from cmd_migrate import add_parser as add_migrate
21
+ from cmd_archive import add_parser as add_archive
22
+
23
+ add_run(subparsers)
24
+ add_edit(subparsers)
25
+ add_migrate(subparsers)
26
+ add_archive(subparsers)
27
+
28
+ args = parser.parse_args()
29
+
30
+ if not hasattr(args, "func"):
31
+ parser.print_help()
32
+ return
33
+
34
+ args.func(args)
35
+
36
+
37
+ if __name__ == "__main__":
38
+ main()
runner/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Runner base class for t-loop task execution backends."""
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+
6
+ class Runner(ABC):
7
+ @abstractmethod
8
+ def run(self, prompt, cwd, model=None, log_file=None):
9
+ """Run a task. Returns exit code (0=success)."""
10
+ ...
runner/claude.py ADDED
@@ -0,0 +1,10 @@
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")
runner/cybervisor.py ADDED
@@ -0,0 +1,28 @@
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
state.py ADDED
@@ -0,0 +1,141 @@
1
+ """State management and archiving for t-loop."""
2
+
3
+ import json
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+
7
+ import yaml
8
+
9
+ import config
10
+
11
+
12
+ def load_state():
13
+ if config.STATE_FILE.exists():
14
+ with open(config.STATE_FILE) as f:
15
+ return json.load(f)
16
+ return {"tasks": {}, "version": 1}
17
+
18
+
19
+ def save_state(state):
20
+ with open(config.STATE_FILE, "w") as f:
21
+ json.dump(state, f, indent=2, ensure_ascii=False)
22
+
23
+
24
+ def get_status_icon(status):
25
+ return {"pending": "⏳", "running": "🔄", "done": "✅", "failed": "❌"}.get(
26
+ status, "?"
27
+ )
28
+
29
+
30
+ def show_status(tasks, state):
31
+ if not tasks:
32
+ print(" (no tasks)")
33
+ return
34
+ for i, task in enumerate(tasks):
35
+ name = task.get("name", f"Task {i + 1}")
36
+ ts = state.get("tasks", {}).get(str(i), {})
37
+ status = ts.get("status", "pending")
38
+ icon = get_status_icon(status)
39
+ extra = ""
40
+ if status == "done" and "finished_at" in ts:
41
+ extra = f" ({ts['finished_at'][:16]})"
42
+ elif status == "failed":
43
+ extra = " (see logs/)"
44
+ print(f" {icon} [{i + 1}] {name}{config.RESET} {config.CYAN}{status}{config.RESET}{extra}")
45
+ print()
46
+
47
+
48
+ def archive_completed_tasks(config_data, state):
49
+ tasks = config_data.get("tasks", [])
50
+
51
+ completed = []
52
+ remaining = []
53
+ for i, task in enumerate(tasks):
54
+ ts = state.get("tasks", {}).get(str(i), {})
55
+ status = ts.get("status", "pending")
56
+ if status == "done":
57
+ completed.append({"task": task, "index": i, "result": ts})
58
+ else:
59
+ remaining.append(task)
60
+
61
+ if not completed:
62
+ return
63
+
64
+ total = len(tasks)
65
+ done_count = len(completed)
66
+ failed_count = sum(
67
+ 1 for i in range(total)
68
+ if state.get("tasks", {}).get(str(i), {}).get("status") == "failed"
69
+ )
70
+
71
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
72
+ archive_data = {
73
+ "archived_at": datetime.now().isoformat(),
74
+ "run_summary": {
75
+ "total": total,
76
+ "done": done_count,
77
+ "failed": failed_count,
78
+ "pending": total - done_count - failed_count,
79
+ },
80
+ "tasks": completed,
81
+ }
82
+
83
+ config.ARCHIVE_DIR.mkdir(exist_ok=True)
84
+ archive_file = config.ARCHIVE_DIR / f"run-{timestamp}.yaml"
85
+ with open(archive_file, "w") as f:
86
+ yaml.dump(archive_data, f, default_flow_style=False, allow_unicode=True)
87
+
88
+ remaining_config = dict(config_data)
89
+ remaining_config["tasks"] = remaining
90
+ with open(config.TASKS_FILE, "w") as f:
91
+ yaml.dump(remaining_config, f, default_flow_style=False, allow_unicode=True)
92
+
93
+ save_state({"tasks": {}, "version": 1})
94
+
95
+ print(f"\n{config.GREEN}Archived {done_count} completed task(s) to {archive_file.name}{config.RESET}")
96
+
97
+
98
+ def show_archives(latest=False):
99
+ if not config.ARCHIVE_DIR.exists():
100
+ print("No archive files found.")
101
+ return
102
+
103
+ archives = sorted(config.ARCHIVE_DIR.glob("run-*.yaml"), reverse=True)
104
+ if not archives:
105
+ print("No archive files found.")
106
+ return
107
+
108
+ if latest:
109
+ with open(archives[0]) as f:
110
+ data = yaml.safe_load(f)
111
+ print(f"{config.BOLD}Latest archive: {archives[0].name}{config.RESET}")
112
+ print(f" Archived at: {data.get('archived_at', 'unknown')}")
113
+ summary = data.get("run_summary", {})
114
+ print(
115
+ f" Total: {summary.get('total', 0)}, "
116
+ f"Done: {summary.get('done', 0)}, "
117
+ f"Failed: {summary.get('failed', 0)}, "
118
+ f"Pending: {summary.get('pending', 0)}"
119
+ )
120
+ print()
121
+ for entry in data.get("tasks", []):
122
+ task = entry.get("task", {})
123
+ result = entry.get("result", {})
124
+ name = task.get("name", "Unnamed")
125
+ status = result.get("status", "unknown")
126
+ icon = get_status_icon(status)
127
+ finished = result.get("finished_at", "")
128
+ extra = f" ({finished[:16]})" if finished else ""
129
+ print(f" {icon} {name}{extra}")
130
+ else:
131
+ print(f"{config.BOLD}Archive files:{config.RESET}")
132
+ for archive in archives:
133
+ with open(archive) as f:
134
+ data = yaml.safe_load(f)
135
+ summary = data.get("run_summary", {})
136
+ print(
137
+ f" {archive.name} "
138
+ f"(done: {summary.get('done', 0)}, "
139
+ f"failed: {summary.get('failed', 0)}, "
140
+ f"total: {summary.get('total', 0)})"
141
+ )
task.py ADDED
@@ -0,0 +1,142 @@
1
+ """Single task execution for t-loop."""
2
+
3
+ import os
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+
7
+ import config
8
+ from git_ops import ensure_clean_git, create_task_branch
9
+ from runner.cybervisor import CybervisorRunner
10
+ from state import save_state
11
+
12
+
13
+ def expand_dir(d):
14
+ return os.path.expandvars(os.path.expanduser(d))
15
+
16
+
17
+ def resolve_prompt_file(prompt_file, dir_path):
18
+ """Resolve prompt_file: ~ expansion, TLOOP_HOME-relative, then dir-relative."""
19
+ pf = Path(prompt_file).expanduser()
20
+ if pf.is_absolute():
21
+ return pf
22
+ candidate = config.TLOOP_HOME / prompt_file
23
+ if candidate.exists():
24
+ return candidate
25
+ candidate = Path(dir_path) / prompt_file
26
+ if candidate.exists():
27
+ return candidate
28
+ return Path(prompt_file)
29
+
30
+
31
+ def run_task(task, index, state, defaults):
32
+ name = task.get("name", f"Task {index + 1}")
33
+ dir_path = expand_dir(task.get("dir", defaults.get("dir", ".")))
34
+ prompt = task.get("prompt", "")
35
+ prompt_file = task.get("prompt_file")
36
+ model = task.get("model", defaults.get("model"))
37
+ branch_config = task.get("branch", True)
38
+
39
+ if not os.path.isdir(dir_path):
40
+ print(f"{config.RED} Directory not found: {dir_path}{config.RESET}")
41
+ state.setdefault("tasks", {})[str(index)] = {
42
+ "status": "failed",
43
+ "error": f"Directory not found: {dir_path}",
44
+ "updated_at": datetime.now().isoformat(),
45
+ }
46
+ save_state(state)
47
+ return False
48
+
49
+ if prompt_file:
50
+ pf = resolve_prompt_file(prompt_file, dir_path)
51
+ if pf.exists():
52
+ prompt = pf.read_text()
53
+ else:
54
+ print(f"{config.RED} Prompt file not found: {prompt_file}{config.RESET}")
55
+ return False
56
+
57
+ if not prompt.strip():
58
+ print(f"{config.RED} No prompt defined for task: {name}{config.RESET}")
59
+ return False
60
+
61
+ print(f"\n{config.BOLD}{'=' * 60}{config.RESET}")
62
+ print(f"{config.BOLD} Task [{index + 1}]: {name}{config.RESET}")
63
+ print(f" Directory: {config.CYAN}{dir_path}{config.RESET}")
64
+ print(f"{config.BOLD}{'=' * 60}{config.RESET}\n")
65
+
66
+ config.LOGS_DIR.mkdir(exist_ok=True)
67
+ safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in name)
68
+ log_file = config.LOGS_DIR / f"{index + 1:03d}-{safe_name}.log"
69
+
70
+ with open(log_file, "w") as log:
71
+ log.write(f"Task: {name}\n")
72
+ log.write(f"Directory: {dir_path}\n")
73
+ log.write("-" * 60 + "\n\n")
74
+
75
+ if not ensure_clean_git(dir_path, name, log_file):
76
+ state.setdefault("tasks", {})[str(index)] = {
77
+ "status": "failed",
78
+ "error": "Failed to clean working tree via auto-commit",
79
+ "updated_at": datetime.now().isoformat(),
80
+ }
81
+ save_state(state)
82
+ return False
83
+
84
+ if not create_task_branch(dir_path, branch_config):
85
+ state.setdefault("tasks", {})[str(index)] = {
86
+ "status": "failed",
87
+ "error": "Failed to create task branch",
88
+ "updated_at": datetime.now().isoformat(),
89
+ }
90
+ save_state(state)
91
+ return False
92
+
93
+ started = datetime.now().isoformat()
94
+ state.setdefault("tasks", {})[str(index)] = {
95
+ "status": "running",
96
+ "started_at": started,
97
+ }
98
+ save_state(state)
99
+
100
+ try:
101
+ with open(log_file, "a") as log:
102
+ log.write(f"Started: {started}\n")
103
+ log.write(f"Command: cybervisor run <prompt>\n")
104
+ log.write("-" * 60 + "\n\n")
105
+ log.flush()
106
+
107
+ runner = CybervisorRunner()
108
+ returncode = runner.run(prompt, dir_path, model=model, log_file=log_file)
109
+
110
+ if returncode == 0:
111
+ state["tasks"][str(index)] = {
112
+ "status": "done",
113
+ "started_at": started,
114
+ "finished_at": datetime.now().isoformat(),
115
+ }
116
+ save_state(state)
117
+ print(f"\n{config.GREEN}✅ Task [{index + 1}] done{config.RESET}")
118
+ else:
119
+ state["tasks"][str(index)] = {
120
+ "status": "failed",
121
+ "started_at": started,
122
+ "finished_at": datetime.now().isoformat(),
123
+ "returncode": returncode,
124
+ }
125
+ save_state(state)
126
+ print(
127
+ f"\n{config.RED}❌ Task [{index + 1}] failed (exit code: {returncode}){config.RESET}"
128
+ )
129
+ print(f" Log: {log_file}")
130
+ return False
131
+
132
+ except Exception as e:
133
+ state["tasks"][str(index)] = {
134
+ "status": "failed",
135
+ "started_at": started,
136
+ "error": str(e),
137
+ }
138
+ save_state(state)
139
+ print(f"\n{config.RED}❌ Task [{index + 1}] error: {e}{config.RESET}")
140
+ return False
141
+
142
+ return True
@@ -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,19 @@
1
+ claude_runner.py,sha256=Tga-aOaKnpRMts-wW__f2FvtZoTCbrV_DjrSNQjt8-g,2899
2
+ cmd_archive.py,sha256=mg1Ltg8YCYAlWzlk-ABPwDTqhzeixgg3FMyzhGRB-FM,440
3
+ cmd_edit.py,sha256=Ns0-7C2fTHYOWk-Vr7yINRPpCyoT_S-WEPBPOli_MqA,1099
4
+ cmd_migrate.py,sha256=dfPNXVsdTebZAj8uKhC6mMA1xj6aNGc6az9aG2wx2qw,2435
5
+ cmd_run.py,sha256=9SqUlY_uL88mxxkvRil15VpugwUPwqIvLHNZwaCPMtM,2724
6
+ config.py,sha256=OBlwVP4rz6WaNk5YNtwmJ0XzCNQA1olh9LjVVaMyZoM,1773
7
+ git_ops.py,sha256=iOvtqYstAeqd37G77vzTcrlgiYRsIgca761AHuWJtr8,5430
8
+ main.py,sha256=joFC2oL0sDCpc-fbagTGxFeEOJG_AnRInnpzjZ7iEHw,866
9
+ state.py,sha256=ierDMWgPRuEiQIk_yGL027291zFnU9XAJNjSc1wTHyk,4518
10
+ task.py,sha256=QLSbIfY2aMQZxG3QEZGeOM4A59Qikiq8kCXwl7X2Gt8,4739
11
+ runner/__init__.py,sha256=qC1bhmC9Qncx6ZRGD0_Gz2_kjUeAGHDW01sI8kzQrsA,266
12
+ runner/claude.py,sha256=y211t1vdN3l-MQf-ykh4kuxCCcyu4-bCr5jivuZb-jM,293
13
+ runner/cybervisor.py,sha256=dDbgtuPVzx4ubVcIAdlJRCQ0Pis06dG3_vQhVZRqMiU,762
14
+ tloop_cli-0.4.0.dist-info/licenses/LICENSE,sha256=RK9GmSj0e7HTwoxX88OHwqjgh76C1LcyDUFxt5dYHAo,1069
15
+ tloop_cli-0.4.0.dist-info/METADATA,sha256=bTvQ7ogNoQztxcZR9n2F9i1yTcuzIyGyTL6S8ijTK1o,2880
16
+ tloop_cli-0.4.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
17
+ tloop_cli-0.4.0.dist-info/entry_points.txt,sha256=pz8TL9_xikjuXCJsEJvFNJtmk6PjOUDU3m1tAA_wA8k,36
18
+ tloop_cli-0.4.0.dist-info/top_level.txt,sha256=PQkmT-TbTpWGdRdrDvw3GUW6gAppl4Naex9DGFDIfYs,93
19
+ tloop_cli-0.4.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tloop = main:main
@@ -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.
@@ -0,0 +1,11 @@
1
+ claude_runner
2
+ cmd_archive
3
+ cmd_edit
4
+ cmd_migrate
5
+ cmd_run
6
+ config
7
+ git_ops
8
+ main
9
+ runner
10
+ state
11
+ task