parallel-codex 0.1.2__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.
- parallel_codex-0.1.2/.gitignore +108 -0
- parallel_codex-0.1.2/.python-version +1 -0
- parallel_codex-0.1.2/PKG-INFO +110 -0
- parallel_codex-0.1.2/README.md +84 -0
- parallel_codex-0.1.2/pyproject.toml +71 -0
- parallel_codex-0.1.2/src/main.py +10 -0
- parallel_codex-0.1.2/src/parallel_codex/__init__.py +24 -0
- parallel_codex-0.1.2/src/parallel_codex/cli.py +49 -0
- parallel_codex-0.1.2/src/parallel_codex/commands/__init__.py +27 -0
- parallel_codex-0.1.2/src/parallel_codex/commands/list_worktrees.py +35 -0
- parallel_codex-0.1.2/src/parallel_codex/commands/plan.py +32 -0
- parallel_codex-0.1.2/src/parallel_codex/commands/prune.py +34 -0
- parallel_codex-0.1.2/src/parallel_codex/core.py +95 -0
- parallel_codex-0.1.2/src/parallel_codex/pcodex.py +419 -0
- parallel_codex-0.1.2/tests/__init__.py +1 -0
- parallel_codex-0.1.2/tests/test_core.py +86 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Node.js and npm
|
|
2
|
+
node_modules/
|
|
3
|
+
npm-debug.log
|
|
4
|
+
npm-error.log
|
|
5
|
+
yarn-error.log
|
|
6
|
+
yarn.lock
|
|
7
|
+
pnpm-lock.yaml
|
|
8
|
+
.pnpm-store/
|
|
9
|
+
|
|
10
|
+
# TypeScript
|
|
11
|
+
*.tsbuildinfo
|
|
12
|
+
dist/
|
|
13
|
+
build/
|
|
14
|
+
*.d.ts.map
|
|
15
|
+
.tsc-output/
|
|
16
|
+
|
|
17
|
+
# Python
|
|
18
|
+
__pycache__/
|
|
19
|
+
*.py[cod]
|
|
20
|
+
*$py.class
|
|
21
|
+
*.so
|
|
22
|
+
.Python
|
|
23
|
+
env/
|
|
24
|
+
venv/
|
|
25
|
+
ENV/
|
|
26
|
+
build/
|
|
27
|
+
develop-eggs/
|
|
28
|
+
dist/
|
|
29
|
+
downloads/
|
|
30
|
+
eggs/
|
|
31
|
+
.eggs/
|
|
32
|
+
lib/
|
|
33
|
+
lib64/
|
|
34
|
+
parts/
|
|
35
|
+
sdist/
|
|
36
|
+
var/
|
|
37
|
+
wheels/
|
|
38
|
+
pip-wheel-metadata/
|
|
39
|
+
share/python-wheels/
|
|
40
|
+
*.egg-info/
|
|
41
|
+
.installed.cfg
|
|
42
|
+
*.egg
|
|
43
|
+
MANIFEST
|
|
44
|
+
.pytest_cache/
|
|
45
|
+
.coverage
|
|
46
|
+
.coverage.*
|
|
47
|
+
htmlcov/
|
|
48
|
+
.tox/
|
|
49
|
+
.hypothesis/
|
|
50
|
+
.mypy_cache/
|
|
51
|
+
.dmypy.json
|
|
52
|
+
dmypy.json
|
|
53
|
+
.pyre/
|
|
54
|
+
*.pyc
|
|
55
|
+
.uv/
|
|
56
|
+
|
|
57
|
+
# IDE and Editor
|
|
58
|
+
.vscode/
|
|
59
|
+
.idea/
|
|
60
|
+
*.swp
|
|
61
|
+
*.swo
|
|
62
|
+
*~
|
|
63
|
+
.DS_Store
|
|
64
|
+
.project
|
|
65
|
+
.classpath
|
|
66
|
+
.c9/
|
|
67
|
+
*.sublime-workspace
|
|
68
|
+
*.sublime-project
|
|
69
|
+
.tern-port
|
|
70
|
+
.env.local
|
|
71
|
+
.env.*.local
|
|
72
|
+
|
|
73
|
+
# OS
|
|
74
|
+
Thumbs.db
|
|
75
|
+
.DS_Store
|
|
76
|
+
.AppleDouble
|
|
77
|
+
.LSOverride
|
|
78
|
+
|
|
79
|
+
# Project specific
|
|
80
|
+
.env
|
|
81
|
+
.env.local
|
|
82
|
+
.env.*.local
|
|
83
|
+
.env.production.local
|
|
84
|
+
.env.development.local
|
|
85
|
+
.env.test.local
|
|
86
|
+
|
|
87
|
+
# Temporary files
|
|
88
|
+
*.tmp
|
|
89
|
+
*.temp
|
|
90
|
+
*.bak
|
|
91
|
+
*.backup
|
|
92
|
+
.cache/
|
|
93
|
+
tmp/
|
|
94
|
+
temp/
|
|
95
|
+
|
|
96
|
+
# Logs
|
|
97
|
+
logs/
|
|
98
|
+
*.log
|
|
99
|
+
|
|
100
|
+
# Build artifacts
|
|
101
|
+
.next/
|
|
102
|
+
out/
|
|
103
|
+
|
|
104
|
+
# Coverage
|
|
105
|
+
coverage/
|
|
106
|
+
.nyc_output/
|
|
107
|
+
|
|
108
|
+
.agents/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.11
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: parallel-codex
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Python toolkit for orchestrating Parallel Codex agents across isolated worktrees.
|
|
5
|
+
Project-URL: Homepage, https://github.com/parallel-codex/parallel-codex
|
|
6
|
+
Project-URL: Issues, https://github.com/parallel-codex/parallel-codex/issues
|
|
7
|
+
Project-URL: Repository, https://github.com/parallel-codex/parallel-codex.git
|
|
8
|
+
Project-URL: Documentation, https://github.com/parallel-codex/parallel-codex/tree/main/packages/python-package
|
|
9
|
+
Author-email: Parallel Codex Team <alejandro.quiros3101@gmail.com>
|
|
10
|
+
License: MIT
|
|
11
|
+
Keywords: agents,automation,codex,git,worktree
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: mypy>=1.7.0; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# parallel-codex
|
|
28
|
+
|
|
29
|
+
Python helpers for orchestrating Parallel Codex agents working in isolated Git worktrees. The toolkit offers a typed core module and a small CLI for planning agent worktrees that the TypeScript layer can execute.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
uv tool install parallel-codex
|
|
35
|
+
# or: pip install parallel-codex
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
You’ll get two CLIs:
|
|
39
|
+
- `pcodex` – minimal, cross‑platform helper that manages git worktrees + tmux and can run `codex .`
|
|
40
|
+
- `parallel-codex` – lower-level planner/list/prune for worktree metadata
|
|
41
|
+
|
|
42
|
+
Prerequisites:
|
|
43
|
+
- `git` and `tmux` on PATH. On Windows without tmux, `pcodex` auto-falls back to `wsl.exe -- tmux ...`.
|
|
44
|
+
- The `codex` command should be available if you use `--run-codex`.
|
|
45
|
+
|
|
46
|
+
## Commands
|
|
47
|
+
|
|
48
|
+
- `uv sync` – install dependencies defined in `pyproject.toml`
|
|
49
|
+
- `uv build` – build a wheel and sdist using Hatchling
|
|
50
|
+
- `uv run pytest` – execute the test suite
|
|
51
|
+
- `uv run ruff check .` – lint the codebase
|
|
52
|
+
- `uv run mypy src/` – run type checking with MyPy
|
|
53
|
+
|
|
54
|
+
## Release Checklist
|
|
55
|
+
|
|
56
|
+
1. Update the `version` field in `pyproject.toml`.
|
|
57
|
+
2. Commit and push the changes.
|
|
58
|
+
3. Tag the commit with `py-vX.Y.Z` (or `vX.Y.Z`) and push the tag to trigger the GitHub Actions publish workflow.
|
|
59
|
+
4. Confirm the new release appears on [PyPI](https://pypi.org/project/parallel-codex/).
|
|
60
|
+
|
|
61
|
+
## CLI Usage (quickstart)
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pcodex up reviewer main --run-codex --attach
|
|
65
|
+
pcodex switch reviewer
|
|
66
|
+
pcodex list
|
|
67
|
+
pcodex prune reviewer --kill-session --remove-dir
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## CLI Usage (development, no install)
|
|
71
|
+
|
|
72
|
+
Run the CLIs without installing the package:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
uv run src/main.py plan reviewer main --base-dir ./.agents
|
|
76
|
+
# or the single-file helper:
|
|
77
|
+
uv run src/parallel_codex/pcodex.py up reviewer main --run-codex --attach
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The published CLIs expose sub-commands:
|
|
81
|
+
|
|
82
|
+
- `parallel-codex plan <agent> <branch>` – calculate (and optionally materialise) a worktree plan.
|
|
83
|
+
- `parallel-codex list` – list discovered plans inside a base directory.
|
|
84
|
+
- `parallel-codex prune <agent>` – remove stored metadata, with `--prune-dir` to delete the folder entirely.
|
|
85
|
+
- `pcodex up <agent> <branch>` – ensure git worktree, ensure tmux session, optionally run `codex .`, and attach.
|
|
86
|
+
- `pcodex switch <agent>` – switch/attach to the tmux session.
|
|
87
|
+
- `pcodex list` – list worktrees and tmux session state.
|
|
88
|
+
- `pcodex prune <agent> [--kill-session] [--remove-dir]` – kill session and/or remove directory.
|
|
89
|
+
|
|
90
|
+
Each sub-command accepts `--base-dir` to target a custom location (defaults to `./.agents`).
|
|
91
|
+
|
|
92
|
+
## Library Usage
|
|
93
|
+
|
|
94
|
+
Import the helpers in automation scripts:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from pathlib import Path
|
|
98
|
+
from parallel_codex import plan_worktree
|
|
99
|
+
|
|
100
|
+
plan = plan_worktree(Path("./agents"), "summariser", "feature/summary")
|
|
101
|
+
print(plan.path)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Or rely on the CLI for quick experiments:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
uv run parallel-codex summariser feature/summary --base-dir ./agents
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The CLI prints a single line summary describing the worktree location, agent name, and branch target.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# parallel-codex
|
|
2
|
+
|
|
3
|
+
Python helpers for orchestrating Parallel Codex agents working in isolated Git worktrees. The toolkit offers a typed core module and a small CLI for planning agent worktrees that the TypeScript layer can execute.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv tool install parallel-codex
|
|
9
|
+
# or: pip install parallel-codex
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
You’ll get two CLIs:
|
|
13
|
+
- `pcodex` – minimal, cross‑platform helper that manages git worktrees + tmux and can run `codex .`
|
|
14
|
+
- `parallel-codex` – lower-level planner/list/prune for worktree metadata
|
|
15
|
+
|
|
16
|
+
Prerequisites:
|
|
17
|
+
- `git` and `tmux` on PATH. On Windows without tmux, `pcodex` auto-falls back to `wsl.exe -- tmux ...`.
|
|
18
|
+
- The `codex` command should be available if you use `--run-codex`.
|
|
19
|
+
|
|
20
|
+
## Commands
|
|
21
|
+
|
|
22
|
+
- `uv sync` – install dependencies defined in `pyproject.toml`
|
|
23
|
+
- `uv build` – build a wheel and sdist using Hatchling
|
|
24
|
+
- `uv run pytest` – execute the test suite
|
|
25
|
+
- `uv run ruff check .` – lint the codebase
|
|
26
|
+
- `uv run mypy src/` – run type checking with MyPy
|
|
27
|
+
|
|
28
|
+
## Release Checklist
|
|
29
|
+
|
|
30
|
+
1. Update the `version` field in `pyproject.toml`.
|
|
31
|
+
2. Commit and push the changes.
|
|
32
|
+
3. Tag the commit with `py-vX.Y.Z` (or `vX.Y.Z`) and push the tag to trigger the GitHub Actions publish workflow.
|
|
33
|
+
4. Confirm the new release appears on [PyPI](https://pypi.org/project/parallel-codex/).
|
|
34
|
+
|
|
35
|
+
## CLI Usage (quickstart)
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pcodex up reviewer main --run-codex --attach
|
|
39
|
+
pcodex switch reviewer
|
|
40
|
+
pcodex list
|
|
41
|
+
pcodex prune reviewer --kill-session --remove-dir
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## CLI Usage (development, no install)
|
|
45
|
+
|
|
46
|
+
Run the CLIs without installing the package:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
uv run src/main.py plan reviewer main --base-dir ./.agents
|
|
50
|
+
# or the single-file helper:
|
|
51
|
+
uv run src/parallel_codex/pcodex.py up reviewer main --run-codex --attach
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The published CLIs expose sub-commands:
|
|
55
|
+
|
|
56
|
+
- `parallel-codex plan <agent> <branch>` – calculate (and optionally materialise) a worktree plan.
|
|
57
|
+
- `parallel-codex list` – list discovered plans inside a base directory.
|
|
58
|
+
- `parallel-codex prune <agent>` – remove stored metadata, with `--prune-dir` to delete the folder entirely.
|
|
59
|
+
- `pcodex up <agent> <branch>` – ensure git worktree, ensure tmux session, optionally run `codex .`, and attach.
|
|
60
|
+
- `pcodex switch <agent>` – switch/attach to the tmux session.
|
|
61
|
+
- `pcodex list` – list worktrees and tmux session state.
|
|
62
|
+
- `pcodex prune <agent> [--kill-session] [--remove-dir]` – kill session and/or remove directory.
|
|
63
|
+
|
|
64
|
+
Each sub-command accepts `--base-dir` to target a custom location (defaults to `./.agents`).
|
|
65
|
+
|
|
66
|
+
## Library Usage
|
|
67
|
+
|
|
68
|
+
Import the helpers in automation scripts:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from pathlib import Path
|
|
72
|
+
from parallel_codex import plan_worktree
|
|
73
|
+
|
|
74
|
+
plan = plan_worktree(Path("./agents"), "summariser", "feature/summary")
|
|
75
|
+
print(plan.path)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Or rely on the CLI for quick experiments:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
uv run parallel-codex summariser feature/summary --base-dir ./agents
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The CLI prints a single line summary describing the worktree location, agent name, and branch target.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "parallel-codex"
|
|
3
|
+
version = "0.1.2"
|
|
4
|
+
description = "Python toolkit for orchestrating Parallel Codex agents across isolated worktrees."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Parallel Codex Team", email = "alejandro.quiros3101@gmail.com" },
|
|
10
|
+
]
|
|
11
|
+
keywords = ["codex", "automation", "git", "agents", "worktree"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
]
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
dev = [
|
|
25
|
+
"pytest>=8.0.0",
|
|
26
|
+
"pytest-cov>=4.1.0",
|
|
27
|
+
"ruff>=0.1.0",
|
|
28
|
+
"mypy>=1.7.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
parallel-codex = "parallel_codex.cli:main"
|
|
33
|
+
pcodex = "parallel_codex.pcodex:main"
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["hatchling"]
|
|
37
|
+
build-backend = "hatchling.build"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["src/parallel_codex"]
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://github.com/parallel-codex/parallel-codex"
|
|
44
|
+
Issues = "https://github.com/parallel-codex/parallel-codex/issues"
|
|
45
|
+
Repository = "https://github.com/parallel-codex/parallel-codex.git"
|
|
46
|
+
Documentation = "https://github.com/parallel-codex/parallel-codex/tree/main/packages/python-package"
|
|
47
|
+
|
|
48
|
+
[tool.uv]
|
|
49
|
+
[dependency-groups]
|
|
50
|
+
dev = [
|
|
51
|
+
"pytest>=8.0.0",
|
|
52
|
+
"pytest-cov>=4.1.0",
|
|
53
|
+
"ruff>=0.1.0",
|
|
54
|
+
"mypy>=1.7.0",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
[tool.pytest.ini_options]
|
|
58
|
+
testpaths = ["tests"]
|
|
59
|
+
pythonpath = ["src"]
|
|
60
|
+
|
|
61
|
+
[tool.ruff]
|
|
62
|
+
line-length = 100
|
|
63
|
+
target-version = "py311"
|
|
64
|
+
|
|
65
|
+
[tool.ruff.lint]
|
|
66
|
+
select = ["E", "F", "I", "N", "W", "UP"]
|
|
67
|
+
|
|
68
|
+
[tool.mypy]
|
|
69
|
+
python_version = "3.11"
|
|
70
|
+
warn_return_any = true
|
|
71
|
+
warn_unused_configs = true
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from parallel_codex.cli import run
|
|
4
|
+
|
|
5
|
+
# Convenience entry point for local development. Allows running the CLI via
|
|
6
|
+
# `uv run src/main.py` without installing the package.
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
if __name__ == "__main__": # pragma: no cover
|
|
10
|
+
run()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Parallel Codex Python toolkit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .core import (
|
|
6
|
+
WorktreePlan,
|
|
7
|
+
ensure_worktree,
|
|
8
|
+
find_worktree,
|
|
9
|
+
format_plan,
|
|
10
|
+
list_worktrees,
|
|
11
|
+
plan_worktree,
|
|
12
|
+
prune_worktree,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"WorktreePlan",
|
|
17
|
+
"plan_worktree",
|
|
18
|
+
"ensure_worktree",
|
|
19
|
+
"list_worktrees",
|
|
20
|
+
"find_worktree",
|
|
21
|
+
"prune_worktree",
|
|
22
|
+
"format_plan",
|
|
23
|
+
]
|
|
24
|
+
__version__ = "0.1.1"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Command-line interface entry point for the Parallel Codex toolkit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .commands import register as register_commands
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
14
|
+
"""Create the top-level CLI parser."""
|
|
15
|
+
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="parallel-codex",
|
|
18
|
+
description="Utilities for orchestrating Parallel Codex agents",
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"--version",
|
|
22
|
+
action="version",
|
|
23
|
+
version=f"parallel-codex {__version__}",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
27
|
+
register_commands(subparsers)
|
|
28
|
+
return parser
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main(argv: list[str] | None = None) -> int:
|
|
32
|
+
"""Return an exit code after executing the requested subcommand."""
|
|
33
|
+
|
|
34
|
+
parser = build_parser()
|
|
35
|
+
args = parser.parse_args(argv)
|
|
36
|
+
handler: Callable[..., int] | None = getattr(args, "handler", None)
|
|
37
|
+
if handler is None:
|
|
38
|
+
parser.error("No handler registered for command.")
|
|
39
|
+
return handler(args)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def run(argv: list[str] | None = None) -> None:
|
|
43
|
+
"""Execute the CLI and exit the current process."""
|
|
44
|
+
|
|
45
|
+
sys.exit(main(argv))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__": # pragma: no cover
|
|
49
|
+
run()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Command modules for the Parallel Codex CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from argparse import ArgumentParser, _SubParsersAction
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def add_common_arguments(parser: ArgumentParser) -> None:
|
|
10
|
+
"""Attach arguments shared by multiple subcommands."""
|
|
11
|
+
|
|
12
|
+
parser.add_argument(
|
|
13
|
+
"--base-dir",
|
|
14
|
+
type=Path,
|
|
15
|
+
default=Path("./.agents"),
|
|
16
|
+
help="Directory where agent worktrees are stored (default: ./.agents)",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def register(subparsers: _SubParsersAction[ArgumentParser]) -> None:
|
|
21
|
+
"""Import command modules and register their subparsers."""
|
|
22
|
+
|
|
23
|
+
from . import list_worktrees, plan, prune
|
|
24
|
+
|
|
25
|
+
plan.register(subparsers)
|
|
26
|
+
list_worktrees.register(subparsers)
|
|
27
|
+
prune.register(subparsers)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""`parallel-codex list` implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from argparse import ArgumentParser, Namespace, _SubParsersAction
|
|
6
|
+
|
|
7
|
+
from ..core import format_plan, list_worktrees
|
|
8
|
+
from . import add_common_arguments
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def register(subparsers: _SubParsersAction[ArgumentParser]) -> None:
|
|
12
|
+
parser = subparsers.add_parser(
|
|
13
|
+
"list",
|
|
14
|
+
help="List discovered agent worktrees",
|
|
15
|
+
)
|
|
16
|
+
add_common_arguments(parser)
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"--agent",
|
|
19
|
+
help="Filter results to a specific agent name",
|
|
20
|
+
)
|
|
21
|
+
parser.set_defaults(handler=execute)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def execute(args: Namespace) -> int:
|
|
25
|
+
plans = list_worktrees(args.base_dir)
|
|
26
|
+
if args.agent:
|
|
27
|
+
plans = [plan for plan in plans if plan.name == args.agent]
|
|
28
|
+
|
|
29
|
+
if not plans:
|
|
30
|
+
print("No worktrees found.")
|
|
31
|
+
return 0
|
|
32
|
+
|
|
33
|
+
for plan in plans:
|
|
34
|
+
print(format_plan(plan))
|
|
35
|
+
return 0
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""`parallel-codex plan` implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from argparse import ArgumentParser, Namespace, _SubParsersAction
|
|
6
|
+
|
|
7
|
+
from ..core import ensure_worktree, format_plan, plan_worktree
|
|
8
|
+
from . import add_common_arguments
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def register(subparsers: _SubParsersAction[ArgumentParser]) -> None:
|
|
12
|
+
parser = subparsers.add_parser(
|
|
13
|
+
"plan",
|
|
14
|
+
help="Create or update the plan for a Codex agent worktree",
|
|
15
|
+
)
|
|
16
|
+
add_common_arguments(parser)
|
|
17
|
+
parser.add_argument("agent", help="Agent identifier to plan")
|
|
18
|
+
parser.add_argument("branch", help="Git branch the agent should track")
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--ensure",
|
|
21
|
+
action="store_true",
|
|
22
|
+
help="Materialise the plan by creating the worktree folder and metadata",
|
|
23
|
+
)
|
|
24
|
+
parser.set_defaults(handler=execute)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def execute(args: Namespace) -> int:
|
|
28
|
+
plan = plan_worktree(args.base_dir, args.agent, args.branch)
|
|
29
|
+
if args.ensure:
|
|
30
|
+
ensure_worktree(plan)
|
|
31
|
+
print(format_plan(plan))
|
|
32
|
+
return 0
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""`parallel-codex prune` implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from argparse import ArgumentParser, Namespace, _SubParsersAction
|
|
6
|
+
|
|
7
|
+
from ..core import find_worktree, prune_worktree
|
|
8
|
+
from . import add_common_arguments
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def register(subparsers: _SubParsersAction[ArgumentParser]) -> None:
|
|
12
|
+
parser = subparsers.add_parser(
|
|
13
|
+
"prune",
|
|
14
|
+
help="Remove plan metadata (and optionally the worktree directory)",
|
|
15
|
+
)
|
|
16
|
+
add_common_arguments(parser)
|
|
17
|
+
parser.add_argument("agent", help="Agent identifier to prune")
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
"--prune-dir",
|
|
20
|
+
action="store_true",
|
|
21
|
+
help="Delete the worktree directory in addition to metadata",
|
|
22
|
+
)
|
|
23
|
+
parser.set_defaults(handler=execute)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def execute(args: Namespace) -> int:
|
|
27
|
+
plan = find_worktree(args.base_dir, args.agent)
|
|
28
|
+
if plan is None:
|
|
29
|
+
print(f"No plan found for agent '{args.agent}'.")
|
|
30
|
+
return 1
|
|
31
|
+
|
|
32
|
+
prune_worktree(plan, remove_path=args.prune_dir)
|
|
33
|
+
print(f"Pruned plan for agent '{args.agent}'.")
|
|
34
|
+
return 0
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Core functionality for orchestrating Parallel Codex agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
BRANCH_METADATA = ".parallel-codex-branch"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(slots=True)
|
|
13
|
+
class WorktreePlan:
|
|
14
|
+
"""Describes the desired state for an agent worktree."""
|
|
15
|
+
|
|
16
|
+
name: str
|
|
17
|
+
branch: str
|
|
18
|
+
path: Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def plan_worktree(base_dir: Path, agent_name: str, branch: str) -> WorktreePlan:
|
|
22
|
+
"""Create a worktree plan rooted within ``base_dir``.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
base_dir: Root directory containing agent worktrees.
|
|
26
|
+
agent_name: Identifier for the Codex agent.
|
|
27
|
+
branch: Target git branch for the worktree.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
A :class:`WorktreePlan` describing the desired worktree placement.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
worktree_path = base_dir / agent_name
|
|
34
|
+
return WorktreePlan(name=agent_name, branch=branch, path=worktree_path)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def format_plan(plan: WorktreePlan) -> str:
|
|
38
|
+
"""Render a human-friendly summary of a worktree plan."""
|
|
39
|
+
|
|
40
|
+
return f"agent={plan.name} branch={plan.branch} path={plan.path}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def ensure_worktree(plan: WorktreePlan) -> None:
|
|
44
|
+
"""Materialise metadata for a planned worktree."""
|
|
45
|
+
|
|
46
|
+
plan.path.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
(plan.path / BRANCH_METADATA).write_text(f"{plan.branch}\n", encoding="utf-8")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def list_worktrees(base_dir: Path) -> list[WorktreePlan]:
|
|
51
|
+
"""Enumerate worktree plans stored under ``base_dir``."""
|
|
52
|
+
|
|
53
|
+
if not base_dir.exists():
|
|
54
|
+
return []
|
|
55
|
+
|
|
56
|
+
plans: list[WorktreePlan] = []
|
|
57
|
+
for candidate in base_dir.iterdir():
|
|
58
|
+
if not candidate.is_dir():
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
branch = _load_branch(candidate)
|
|
62
|
+
if branch is None:
|
|
63
|
+
continue
|
|
64
|
+
plans.append(WorktreePlan(name=candidate.name, branch=branch, path=candidate))
|
|
65
|
+
|
|
66
|
+
return sorted(plans, key=lambda plan: plan.name)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def find_worktree(base_dir: Path, agent_name: str) -> WorktreePlan | None:
|
|
70
|
+
"""Return the stored worktree plan for ``agent_name`` if present."""
|
|
71
|
+
|
|
72
|
+
candidate = base_dir / agent_name
|
|
73
|
+
branch = _load_branch(candidate)
|
|
74
|
+
if branch is None:
|
|
75
|
+
return None
|
|
76
|
+
return WorktreePlan(name=agent_name, branch=branch, path=candidate)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def prune_worktree(plan: WorktreePlan, *, remove_path: bool = False) -> None:
|
|
80
|
+
"""Remove stored metadata (and optionally the directory) for a plan."""
|
|
81
|
+
|
|
82
|
+
branch_file = plan.path / BRANCH_METADATA
|
|
83
|
+
if branch_file.exists():
|
|
84
|
+
branch_file.unlink()
|
|
85
|
+
|
|
86
|
+
if remove_path and plan.path.exists():
|
|
87
|
+
shutil.rmtree(plan.path)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _load_branch(path: Path) -> str | None:
|
|
91
|
+
branch_file = path / BRANCH_METADATA
|
|
92
|
+
if not branch_file.exists():
|
|
93
|
+
return None
|
|
94
|
+
raw = branch_file.read_text(encoding="utf-8").strip()
|
|
95
|
+
return raw or None
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
pcodex - ultra-minimal cross-platform CLI to manage agent worktrees + tmux sessions.
|
|
4
|
+
|
|
5
|
+
Commands:
|
|
6
|
+
up <agent> <branch> Ensure git worktree and tmux session;
|
|
7
|
+
optionally run `codex .` and attach/switch.
|
|
8
|
+
switch <agent> Switch/attach to tmux session.
|
|
9
|
+
list List worktrees and tmux session status.
|
|
10
|
+
prune <agent> Optionally kill tmux session and/or remove the worktree directory.
|
|
11
|
+
|
|
12
|
+
Requires: git, tmux (or WSL with tmux installed).
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import os
|
|
18
|
+
import shlex
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from collections.abc import Iterable
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
BRANCH_METADATA = ".parallel-codex-branch"
|
|
28
|
+
DEFAULT_BASE_DIR = Path("./.agents")
|
|
29
|
+
SESSION_PREFIX = "pcx-"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _supports_ansi() -> bool:
|
|
33
|
+
if os.environ.get("NO_COLOR") or os.environ.get("PCX_NO_COLOR"):
|
|
34
|
+
return False
|
|
35
|
+
try:
|
|
36
|
+
return sys.stdout.isatty()
|
|
37
|
+
except Exception:
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
ANSI = {
|
|
42
|
+
"reset": "\x1b[0m",
|
|
43
|
+
"green": "\x1b[32m",
|
|
44
|
+
"red": "\x1b[31m",
|
|
45
|
+
"cyan": "\x1b[36m",
|
|
46
|
+
"dim": "\x1b[2m",
|
|
47
|
+
}
|
|
48
|
+
USE_ANSI = _supports_ansi()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _c(code: str, text: str) -> str:
|
|
52
|
+
if not USE_ANSI:
|
|
53
|
+
return text
|
|
54
|
+
return f"{ANSI.get(code, '')}{text}{ANSI['reset']}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Spinner:
|
|
58
|
+
def __init__(self, message: str, done_text: str | None = None):
|
|
59
|
+
self.message = message
|
|
60
|
+
self.done_text = done_text or message
|
|
61
|
+
self._stop = threading.Event()
|
|
62
|
+
self._thread: threading.Thread | None = None
|
|
63
|
+
self._frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
64
|
+
|
|
65
|
+
def __enter__(self):
|
|
66
|
+
if not sys.stdout.isatty():
|
|
67
|
+
print(f"{self.message} …", flush=True)
|
|
68
|
+
return self
|
|
69
|
+
self._thread = threading.Thread(target=self._spin, daemon=True)
|
|
70
|
+
self._thread.start()
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
def _spin(self):
|
|
74
|
+
i = 0
|
|
75
|
+
while not self._stop.is_set():
|
|
76
|
+
frame = self._frames[i % len(self._frames)]
|
|
77
|
+
line = f"{_c('cyan', frame)} {_c('dim', self.message)}"
|
|
78
|
+
print(f"\r{line}", end="", flush=True)
|
|
79
|
+
time.sleep(0.08)
|
|
80
|
+
i += 1
|
|
81
|
+
|
|
82
|
+
def __exit__(self, exc_type, exc, tb):
|
|
83
|
+
self._stop.set()
|
|
84
|
+
if self._thread:
|
|
85
|
+
self._thread.join(timeout=0.2)
|
|
86
|
+
if sys.stdout.isatty():
|
|
87
|
+
print("\r", end="")
|
|
88
|
+
if exc is None:
|
|
89
|
+
print(f"{_c('green', '✔')} {self.done_text}")
|
|
90
|
+
else:
|
|
91
|
+
print(f"{_c('red', '✖')} {self.done_text}")
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def run(
|
|
96
|
+
cmd: Iterable[str],
|
|
97
|
+
*,
|
|
98
|
+
check: bool = True,
|
|
99
|
+
capture: bool = False,
|
|
100
|
+
env: dict[str, str] | None = None,
|
|
101
|
+
cwd: Path | None = None,
|
|
102
|
+
) -> subprocess.CompletedProcess:
|
|
103
|
+
kwargs = {
|
|
104
|
+
"check": check,
|
|
105
|
+
"cwd": str(cwd) if cwd else None,
|
|
106
|
+
"env": env or os.environ.copy(),
|
|
107
|
+
"text": True,
|
|
108
|
+
}
|
|
109
|
+
if capture:
|
|
110
|
+
kwargs.update({"stdout": subprocess.PIPE, "stderr": subprocess.PIPE})
|
|
111
|
+
return subprocess.run(list(cmd), **kwargs) # type: ignore[arg-type]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def which(name: str) -> str | None:
|
|
115
|
+
return shutil.which(name)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Tmux:
|
|
119
|
+
def __init__(self, prefix: list[str], path_mapper):
|
|
120
|
+
self.prefix = prefix
|
|
121
|
+
self.path_mapper = path_mapper
|
|
122
|
+
|
|
123
|
+
def _cmd(self, *args: str) -> list[str]:
|
|
124
|
+
return [*self.prefix, *args]
|
|
125
|
+
|
|
126
|
+
def has_session(self, name: str) -> bool:
|
|
127
|
+
proc = run(self._cmd("has-session", "-t", name), check=False, capture=True)
|
|
128
|
+
return proc.returncode == 0
|
|
129
|
+
|
|
130
|
+
def new_session(self, name: str, cwd: Path) -> None:
|
|
131
|
+
run(self._cmd("new-session", "-ds", name, "-c", self.path_mapper(cwd)))
|
|
132
|
+
|
|
133
|
+
def send_keys(self, name: str, command: str) -> None:
|
|
134
|
+
run(self._cmd("send-keys", "-t", name, command, "C-m"))
|
|
135
|
+
|
|
136
|
+
def switch_or_attach(self, name: str) -> None:
|
|
137
|
+
inside_tmux = bool(os.environ.get("TMUX"))
|
|
138
|
+
if inside_tmux:
|
|
139
|
+
run(self._cmd("switch-client", "-t", name), check=False)
|
|
140
|
+
else:
|
|
141
|
+
run(self._cmd("attach", "-t", name), check=False)
|
|
142
|
+
|
|
143
|
+
def kill_session(self, name: str) -> None:
|
|
144
|
+
run(self._cmd("kill-session", "-t", name), check=False)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _windows_wsl_path(path: Path) -> str:
|
|
148
|
+
wsl = which("wsl.exe")
|
|
149
|
+
if wsl:
|
|
150
|
+
try:
|
|
151
|
+
proc = run([wsl, "wslpath", "-a", str(path)], capture=True)
|
|
152
|
+
return proc.stdout.strip()
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
p = str(path)
|
|
156
|
+
if len(p) >= 2 and p[1] == ":":
|
|
157
|
+
drive = p[0].lower()
|
|
158
|
+
rest = p[2:].replace("\\", "/")
|
|
159
|
+
return f"/mnt/{drive}{rest if rest.startswith('/') else '/' + rest}"
|
|
160
|
+
return p.replace("\\", "/")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def tmux_strategy(*, prefer_wsl: bool = False) -> Tmux:
|
|
164
|
+
is_windows = os.name == "nt"
|
|
165
|
+
native_tmux = which("tmux")
|
|
166
|
+
wsl = which("wsl.exe")
|
|
167
|
+
if prefer_wsl and is_windows and wsl:
|
|
168
|
+
return Tmux(prefix=[wsl, "--", "tmux"], path_mapper=_windows_wsl_path)
|
|
169
|
+
if native_tmux:
|
|
170
|
+
return Tmux(prefix=[native_tmux], path_mapper=lambda p: str(p))
|
|
171
|
+
if is_windows:
|
|
172
|
+
if wsl:
|
|
173
|
+
return Tmux(prefix=[wsl, "--", "tmux"], path_mapper=_windows_wsl_path)
|
|
174
|
+
return Tmux(prefix=["tmux"], path_mapper=lambda p: str(p))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def ensure_worktree(repo: Path, base_dir: Path, agent: str, branch: str) -> Path:
|
|
178
|
+
base_dir.mkdir(parents=True, exist_ok=True)
|
|
179
|
+
worktree = base_dir / agent
|
|
180
|
+
if not worktree.exists():
|
|
181
|
+
worktree.parent.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
try:
|
|
183
|
+
run(["git", "-C", str(repo), "worktree", "add", "-B", branch, str(worktree)], check=True)
|
|
184
|
+
except subprocess.CalledProcessError:
|
|
185
|
+
pass
|
|
186
|
+
try:
|
|
187
|
+
(worktree / BRANCH_METADATA).write_text(f"{branch}\n", encoding="utf-8")
|
|
188
|
+
except Exception:
|
|
189
|
+
pass
|
|
190
|
+
return worktree
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def read_branch_file(worktree: Path) -> str | None:
|
|
194
|
+
meta = worktree / BRANCH_METADATA
|
|
195
|
+
if meta.exists():
|
|
196
|
+
raw = meta.read_text(encoding="utf-8").strip()
|
|
197
|
+
return raw or None
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def cmd_up(args: argparse.Namespace) -> int:
|
|
202
|
+
tmux = tmux_strategy(prefer_wsl=bool(getattr(args, "wsl", False)))
|
|
203
|
+
session = SESSION_PREFIX + args.agent
|
|
204
|
+
repo = Path(args.repo).resolve()
|
|
205
|
+
base_dir = Path(args.base_dir).resolve()
|
|
206
|
+
|
|
207
|
+
with Spinner(
|
|
208
|
+
f"Ensuring worktree for agent '{args.agent}' on '{args.branch}'",
|
|
209
|
+
"Worktree ensured",
|
|
210
|
+
):
|
|
211
|
+
worktree = ensure_worktree(repo, base_dir, args.agent, args.branch)
|
|
212
|
+
|
|
213
|
+
# If we're about to create the session for the first time, try to prepare
|
|
214
|
+
# the Python environment so jumping into the session is ready to go.
|
|
215
|
+
needs_session = not tmux.has_session(session)
|
|
216
|
+
if needs_session and bool(getattr(args, "prep_env", False)):
|
|
217
|
+
# Detect if tmux will run under WSL so we prep the env in the same OS.
|
|
218
|
+
is_wsl_tmux = bool(
|
|
219
|
+
tmux.prefix
|
|
220
|
+
and os.path.basename(tmux.prefix[0]).lower().startswith("wsl")
|
|
221
|
+
)
|
|
222
|
+
if is_wsl_tmux:
|
|
223
|
+
wsl = tmux.prefix[0]
|
|
224
|
+
wsl_worktree = _windows_wsl_path(worktree)
|
|
225
|
+
# Check if uv exists inside WSL
|
|
226
|
+
uv_present = (
|
|
227
|
+
run(
|
|
228
|
+
[wsl, "--", "bash", "-lc", "command -v uv >/dev/null 2>&1"],
|
|
229
|
+
check=False,
|
|
230
|
+
).returncode
|
|
231
|
+
== 0
|
|
232
|
+
)
|
|
233
|
+
if uv_present:
|
|
234
|
+
with Spinner(
|
|
235
|
+
"Preparing Python env in WSL (uv sync + install -e)",
|
|
236
|
+
"Python env ready",
|
|
237
|
+
):
|
|
238
|
+
commands = [
|
|
239
|
+
f"cd {shlex.quote(wsl_worktree)}",
|
|
240
|
+
"uv sync --project packages/python-package",
|
|
241
|
+
(
|
|
242
|
+
"uv run --project packages/python-package python -m pip install -e "
|
|
243
|
+
"packages/python-package"
|
|
244
|
+
),
|
|
245
|
+
]
|
|
246
|
+
cmd = " && ".join(commands)
|
|
247
|
+
run([wsl, "--", "bash", "-lc", cmd], check=False)
|
|
248
|
+
else:
|
|
249
|
+
print(_c("dim", "Tip: 'uv' not found in WSL; skipping dependency sync/install."))
|
|
250
|
+
else:
|
|
251
|
+
uv = which("uv")
|
|
252
|
+
if uv:
|
|
253
|
+
with Spinner(
|
|
254
|
+
"Preparing Python env (uv sync + install -e)",
|
|
255
|
+
"Python env ready",
|
|
256
|
+
):
|
|
257
|
+
# Best-effort; do not fail the whole command if these error
|
|
258
|
+
run(
|
|
259
|
+
[uv, "sync", "--project", "packages/python-package"],
|
|
260
|
+
check=False,
|
|
261
|
+
cwd=worktree,
|
|
262
|
+
)
|
|
263
|
+
run(
|
|
264
|
+
[
|
|
265
|
+
uv,
|
|
266
|
+
"run",
|
|
267
|
+
"--project",
|
|
268
|
+
"packages/python-package",
|
|
269
|
+
"python",
|
|
270
|
+
"-m",
|
|
271
|
+
"pip",
|
|
272
|
+
"install",
|
|
273
|
+
"-e",
|
|
274
|
+
"packages/python-package",
|
|
275
|
+
],
|
|
276
|
+
check=False,
|
|
277
|
+
cwd=worktree,
|
|
278
|
+
)
|
|
279
|
+
else:
|
|
280
|
+
print(_c("dim", "Tip: 'uv' not found on PATH; skipping dependency sync/install."))
|
|
281
|
+
|
|
282
|
+
with Spinner(f"Ensuring tmux session '{session}'", "Tmux session ready"):
|
|
283
|
+
if needs_session:
|
|
284
|
+
tmux.new_session(session, worktree)
|
|
285
|
+
if args.run_codex:
|
|
286
|
+
tmux.send_keys(session, "codex .")
|
|
287
|
+
|
|
288
|
+
if args.attach:
|
|
289
|
+
tmux.switch_or_attach(session)
|
|
290
|
+
else:
|
|
291
|
+
print(f"{_c('dim', 'Tip:')} run with --attach to switch/attach to the session.")
|
|
292
|
+
return 0
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def cmd_switch(args: argparse.Namespace) -> int:
|
|
296
|
+
tmux = tmux_strategy(prefer_wsl=bool(getattr(args, "wsl", False)))
|
|
297
|
+
session = SESSION_PREFIX + args.agent
|
|
298
|
+
if not tmux.has_session(session):
|
|
299
|
+
print(_c("red", f"Session '{session}' not found."), file=sys.stderr)
|
|
300
|
+
return 1
|
|
301
|
+
tmux.switch_or_attach(session)
|
|
302
|
+
return 0
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def cmd_list(args: argparse.Namespace) -> int:
|
|
306
|
+
tmux = tmux_strategy(prefer_wsl=bool(getattr(args, "wsl", False)))
|
|
307
|
+
base_dir = Path(args.base_dir).resolve()
|
|
308
|
+
if not base_dir.exists():
|
|
309
|
+
print("No worktrees found.")
|
|
310
|
+
return 0
|
|
311
|
+
any_printed = False
|
|
312
|
+
for child in sorted(base_dir.iterdir(), key=lambda p: p.name):
|
|
313
|
+
if not child.is_dir():
|
|
314
|
+
continue
|
|
315
|
+
agent = child.name
|
|
316
|
+
session = SESSION_PREFIX + agent
|
|
317
|
+
branch = read_branch_file(child) or "?"
|
|
318
|
+
tmux_up = "up" if tmux.has_session(session) else "down"
|
|
319
|
+
print(f"agent={agent} branch={branch} path={child} tmux={tmux_up}")
|
|
320
|
+
any_printed = True
|
|
321
|
+
if not any_printed:
|
|
322
|
+
print("No worktrees found.")
|
|
323
|
+
return 0
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def cmd_prune(args: argparse.Namespace) -> int:
|
|
327
|
+
tmux = tmux_strategy(prefer_wsl=bool(getattr(args, "wsl", False)))
|
|
328
|
+
base_dir = Path(args.base_dir).resolve()
|
|
329
|
+
worktree = base_dir / args.agent
|
|
330
|
+
session = SESSION_PREFIX + args.agent
|
|
331
|
+
if args.kill_session:
|
|
332
|
+
with Spinner(f"Killing tmux session '{session}'", "Tmux session handled"):
|
|
333
|
+
tmux.kill_session(session)
|
|
334
|
+
if args.remove_dir and worktree.exists():
|
|
335
|
+
with Spinner(f"Removing worktree directory '{worktree}'", "Worktree directory handled"):
|
|
336
|
+
shutil.rmtree(worktree, ignore_errors=True)
|
|
337
|
+
print(f"Pruned '{args.agent}'.")
|
|
338
|
+
return 0
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
342
|
+
p = argparse.ArgumentParser(prog="pcodex", description="Parallel Codex single-file CLI")
|
|
343
|
+
p.add_argument(
|
|
344
|
+
"--base-dir",
|
|
345
|
+
default=str(DEFAULT_BASE_DIR),
|
|
346
|
+
help="Directory for agent worktrees (default: ./.agents)",
|
|
347
|
+
)
|
|
348
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
349
|
+
|
|
350
|
+
up = sub.add_parser("up", help="Ensure worktree + tmux; optionally run codex and attach")
|
|
351
|
+
up.add_argument("agent")
|
|
352
|
+
up.add_argument("branch")
|
|
353
|
+
up.add_argument("--repo", default=".", help="Path to git repo (default: .)")
|
|
354
|
+
up.add_argument(
|
|
355
|
+
"--attach",
|
|
356
|
+
action="store_true",
|
|
357
|
+
help="Switch/attach to the tmux session after setup",
|
|
358
|
+
)
|
|
359
|
+
up.add_argument("--run-codex", action="store_true", help="Send 'codex .' into the tmux session")
|
|
360
|
+
up.add_argument(
|
|
361
|
+
"--prep-env",
|
|
362
|
+
action="store_true",
|
|
363
|
+
help="Before creating the session, run 'uv sync' and install the package in editable mode",
|
|
364
|
+
)
|
|
365
|
+
up.add_argument(
|
|
366
|
+
"--wsl",
|
|
367
|
+
action="store_true",
|
|
368
|
+
help="Force tmux to run via WSL on Windows and run commands inside WSL",
|
|
369
|
+
)
|
|
370
|
+
up.set_defaults(handler=cmd_up)
|
|
371
|
+
|
|
372
|
+
sw = sub.add_parser("switch", help="Switch/attach to an existing tmux session")
|
|
373
|
+
sw.add_argument("agent")
|
|
374
|
+
sw.add_argument(
|
|
375
|
+
"--wsl",
|
|
376
|
+
action="store_true",
|
|
377
|
+
help="Use WSL tmux session (Windows)",
|
|
378
|
+
)
|
|
379
|
+
sw.set_defaults(handler=cmd_switch)
|
|
380
|
+
|
|
381
|
+
ls = sub.add_parser("list", help="List known agent worktrees and tmux state")
|
|
382
|
+
ls.add_argument(
|
|
383
|
+
"--wsl",
|
|
384
|
+
action="store_true",
|
|
385
|
+
help="Use WSL tmux instance to query session state (Windows)",
|
|
386
|
+
)
|
|
387
|
+
ls.set_defaults(handler=cmd_list)
|
|
388
|
+
|
|
389
|
+
pr = sub.add_parser("prune", help="Kill tmux and/or remove a worktree directory")
|
|
390
|
+
pr.add_argument("agent")
|
|
391
|
+
pr.add_argument(
|
|
392
|
+
"--kill-session",
|
|
393
|
+
action="store_true",
|
|
394
|
+
help="Kill the tmux session for the agent",
|
|
395
|
+
)
|
|
396
|
+
pr.add_argument("--remove-dir", action="store_true", help="Delete the agent worktree directory")
|
|
397
|
+
pr.add_argument(
|
|
398
|
+
"--wsl",
|
|
399
|
+
action="store_true",
|
|
400
|
+
help="Target a WSL tmux session (Windows)",
|
|
401
|
+
)
|
|
402
|
+
pr.set_defaults(handler=cmd_prune)
|
|
403
|
+
|
|
404
|
+
return p
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def main(argv: list[str] | None = None) -> int:
|
|
408
|
+
parser = build_parser()
|
|
409
|
+
args = parser.parse_args(argv)
|
|
410
|
+
handler = getattr(args, "handler", None)
|
|
411
|
+
if handler is None:
|
|
412
|
+
parser.error("No handler.")
|
|
413
|
+
return handler(args)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
if __name__ == "__main__":
|
|
417
|
+
sys.exit(main())
|
|
418
|
+
|
|
419
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Test package for parallel_codex."""
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Tests for the Parallel Codex core helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from parallel_codex.core import (
|
|
10
|
+
BRANCH_METADATA,
|
|
11
|
+
WorktreePlan,
|
|
12
|
+
ensure_worktree,
|
|
13
|
+
find_worktree,
|
|
14
|
+
format_plan,
|
|
15
|
+
list_worktrees,
|
|
16
|
+
plan_worktree,
|
|
17
|
+
prune_worktree,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture()
|
|
22
|
+
def base_dir(tmp_path: Path) -> Path:
|
|
23
|
+
target = tmp_path / "agents"
|
|
24
|
+
target.mkdir()
|
|
25
|
+
return target
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_plan_worktree_creates_expected_plan(base_dir: Path) -> None:
|
|
29
|
+
plan = plan_worktree(base_dir, "agent-a", "feature/awesome")
|
|
30
|
+
assert plan == WorktreePlan(
|
|
31
|
+
name="agent-a",
|
|
32
|
+
branch="feature/awesome",
|
|
33
|
+
path=base_dir / "agent-a",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_format_plan(base_dir: Path) -> None:
|
|
38
|
+
plan = plan_worktree(base_dir, "reviewer", "main")
|
|
39
|
+
summary = format_plan(plan)
|
|
40
|
+
assert str(plan.path) in summary
|
|
41
|
+
assert "agent=reviewer" in summary
|
|
42
|
+
assert "branch=main" in summary
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_ensure_worktree_creates_metadata(base_dir: Path) -> None:
|
|
46
|
+
plan = plan_worktree(base_dir, "lint", "lint-branch")
|
|
47
|
+
ensure_worktree(plan)
|
|
48
|
+
branch_file = plan.path / BRANCH_METADATA
|
|
49
|
+
assert branch_file.exists()
|
|
50
|
+
assert branch_file.read_text(encoding="utf-8").strip() == "lint-branch"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_list_worktrees_discovers_plans(base_dir: Path) -> None:
|
|
54
|
+
plan_a = plan_worktree(base_dir, "alpha", "main")
|
|
55
|
+
plan_b = plan_worktree(base_dir, "beta", "feature/beta")
|
|
56
|
+
ensure_worktree(plan_a)
|
|
57
|
+
ensure_worktree(plan_b)
|
|
58
|
+
|
|
59
|
+
plans = list_worktrees(base_dir)
|
|
60
|
+
assert [plan.name for plan in plans] == ["alpha", "beta"]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_find_worktree_returns_none_when_missing(base_dir: Path) -> None:
|
|
64
|
+
assert find_worktree(base_dir, "ghost") is None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_find_worktree_returns_plan_when_present(base_dir: Path) -> None:
|
|
68
|
+
plan = plan_worktree(base_dir, "alpha", "main")
|
|
69
|
+
ensure_worktree(plan)
|
|
70
|
+
found = find_worktree(base_dir, "alpha")
|
|
71
|
+
assert found == plan
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_prune_worktree_removes_metadata_only(base_dir: Path) -> None:
|
|
75
|
+
plan = plan_worktree(base_dir, "agent", "branch")
|
|
76
|
+
ensure_worktree(plan)
|
|
77
|
+
prune_worktree(plan, remove_path=False)
|
|
78
|
+
assert not (plan.path / BRANCH_METADATA).exists()
|
|
79
|
+
assert plan.path.exists()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_prune_worktree_can_remove_directory(base_dir: Path) -> None:
|
|
83
|
+
plan = plan_worktree(base_dir, "agent", "branch")
|
|
84
|
+
ensure_worktree(plan)
|
|
85
|
+
prune_worktree(plan, remove_path=True)
|
|
86
|
+
assert not plan.path.exists()
|