cheaphelp 1.0.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.
- cheaphelp/__init__.py +10 -0
- cheaphelp/__main__.py +14 -0
- cheaphelp/_internal/__init__.py +0 -0
- cheaphelp/_internal/cleanup.py +111 -0
- cheaphelp/_internal/cli.py +372 -0
- cheaphelp/_internal/commands.py +1025 -0
- cheaphelp/_internal/config.py +326 -0
- cheaphelp/_internal/conventions.py +21 -0
- cheaphelp/_internal/debug.py +107 -0
- cheaphelp/_internal/env.py +103 -0
- cheaphelp/_internal/fixer.py +133 -0
- cheaphelp/_internal/github.py +372 -0
- cheaphelp/_internal/gitutil.py +216 -0
- cheaphelp/_internal/lock.py +83 -0
- cheaphelp/_internal/opencode.py +655 -0
- cheaphelp/_internal/orchestrator.py +801 -0
- cheaphelp/_internal/planner.py +187 -0
- cheaphelp/_internal/pr_state.py +48 -0
- cheaphelp/_internal/registry.py +147 -0
- cheaphelp/_internal/responder.py +225 -0
- cheaphelp/_internal/reviewer.py +322 -0
- cheaphelp/_internal/rework.py +371 -0
- cheaphelp/_internal/spend.py +107 -0
- cheaphelp/_internal/systemd.py +302 -0
- cheaphelp/_internal/tasks.py +263 -0
- cheaphelp/_internal/templates/__init__.py +24 -0
- cheaphelp/_internal/templates/fixer.md +50 -0
- cheaphelp/_internal/templates/planner.md +64 -0
- cheaphelp/_internal/templates/responder.md +154 -0
- cheaphelp/_internal/templates/reviewer.md +53 -0
- cheaphelp/_internal/templates/rework.md +61 -0
- cheaphelp/_internal/templates/worker.md +49 -0
- cheaphelp/_internal/worker.py +147 -0
- cheaphelp/py.typed +0 -0
- cheaphelp-1.0.0.dist-info/METADATA +112 -0
- cheaphelp-1.0.0.dist-info/RECORD +39 -0
- cheaphelp-1.0.0.dist-info/WHEEL +4 -0
- cheaphelp-1.0.0.dist-info/entry_points.txt +5 -0
- cheaphelp-1.0.0.dist-info/licenses/LICENSE +21 -0
cheaphelp/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""cheaphelp package.
|
|
2
|
+
|
|
3
|
+
An AI software-engineer that triages GitHub issues, plans, implements, and reviews changes on your repos using cheap OpenRouter models via the opencode harness.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from cheaphelp._internal.cli import get_parser, main
|
|
9
|
+
|
|
10
|
+
__all__: list[str] = ["get_parser", "main"]
|
cheaphelp/__main__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Entry-point module, in case you use `python -m cheaphelp`.
|
|
2
|
+
|
|
3
|
+
Why does this file exist, and why `__main__`? For more info, read:
|
|
4
|
+
|
|
5
|
+
- https://www.python.org/dev/peps/pep-0338/
|
|
6
|
+
- https://docs.python.org/3/using/cmdline.html#cmdoption-m
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from cheaphelp._internal.cli import main
|
|
12
|
+
|
|
13
|
+
if __name__ == "__main__":
|
|
14
|
+
sys.exit(main(sys.argv[1:]))
|
|
File without changes
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Workspace maintenance: prune build clones the harness no longer needs.
|
|
2
|
+
|
|
3
|
+
The orchestrator keeps two kinds of clone under ``<workspace>/clones/``:
|
|
4
|
+
|
|
5
|
+
- a shared read-only clone per repo, ``<owner>__<repo>`` (reused every tick), and
|
|
6
|
+
- a build clone per issue, ``<owner>__<repo>__issue-<n>`` (used while the issue is
|
|
7
|
+
being implemented).
|
|
8
|
+
|
|
9
|
+
Build clones are only needed while an issue is *live* (open). Once it closes they
|
|
10
|
+
are dead weight — and they are large (a full working tree each). This module
|
|
11
|
+
removes build clones for closed issues, and any clone belonging to a repo that is
|
|
12
|
+
no longer registered. Per-issue **state** under ``state/`` is intentionally left
|
|
13
|
+
untouched so it stays available for inspection and debugging.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import shutil
|
|
19
|
+
from collections.abc import Callable, Iterable
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from cheaphelp._internal.config import Workspace
|
|
23
|
+
from cheaphelp._internal.lock import RunLock
|
|
24
|
+
from cheaphelp._internal.registry import RepoEntry
|
|
25
|
+
|
|
26
|
+
Logger = Callable[[str], None]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def iter_issue_work_clones(workspace: Workspace, owner: str, name: str) -> dict[int, Path]:
|
|
30
|
+
"""Map issue number -> build-clone path for one repo's per-issue clones.
|
|
31
|
+
|
|
32
|
+
The repo's shared clone (``<owner>__<repo>``, no ``__issue-`` suffix) is never
|
|
33
|
+
included. Matching by the known ``<owner>__<repo>__issue-`` prefix avoids the
|
|
34
|
+
ambiguity of splitting on ``__`` (repo names may contain underscores).
|
|
35
|
+
"""
|
|
36
|
+
prefix = f"{owner}__{name}__issue-"
|
|
37
|
+
clones: dict[int, Path] = {}
|
|
38
|
+
if not workspace.clones_dir.exists():
|
|
39
|
+
return clones
|
|
40
|
+
for path in workspace.clones_dir.iterdir():
|
|
41
|
+
if path.is_dir() and path.name.startswith(prefix):
|
|
42
|
+
suffix = path.name[len(prefix) :]
|
|
43
|
+
if suffix.isdigit():
|
|
44
|
+
clones[int(suffix)] = path
|
|
45
|
+
return clones
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def prune_repo_work_clones(
|
|
49
|
+
workspace: Workspace,
|
|
50
|
+
repo: RepoEntry,
|
|
51
|
+
live_numbers: set[int],
|
|
52
|
+
*,
|
|
53
|
+
dry_run: bool = False,
|
|
54
|
+
log: Logger | None = None,
|
|
55
|
+
) -> list[int]:
|
|
56
|
+
"""Remove build clones for this repo's issues that are no longer live.
|
|
57
|
+
|
|
58
|
+
``live_numbers`` is the set of currently-open issue numbers; any build clone for
|
|
59
|
+
an issue outside it is removed. Returns the issue numbers whose clones were
|
|
60
|
+
removed (or, in dry-run, would be).
|
|
61
|
+
"""
|
|
62
|
+
log = log or (lambda _m: None)
|
|
63
|
+
removed: list[int] = []
|
|
64
|
+
for number, path in sorted(iter_issue_work_clones(workspace, repo.owner, repo.name).items()):
|
|
65
|
+
if number in live_numbers:
|
|
66
|
+
continue
|
|
67
|
+
if dry_run:
|
|
68
|
+
log(f" · would remove build clone for {repo.slug}#{number}")
|
|
69
|
+
removed.append(number)
|
|
70
|
+
continue
|
|
71
|
+
# Guard against a concurrent tick still using this issue's clone. Closed
|
|
72
|
+
# issues are never actionable, so this should never contend in practice.
|
|
73
|
+
with RunLock(workspace.issue_lock_path(repo.owner, repo.name, number)) as lock:
|
|
74
|
+
if not lock.acquired:
|
|
75
|
+
continue
|
|
76
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
77
|
+
log(f" · removed build clone for {repo.slug}#{number}")
|
|
78
|
+
removed.append(number)
|
|
79
|
+
return removed
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def prune_orphan_clones(
|
|
83
|
+
workspace: Workspace,
|
|
84
|
+
repos: Iterable[RepoEntry],
|
|
85
|
+
*,
|
|
86
|
+
dry_run: bool = False,
|
|
87
|
+
log: Logger | None = None,
|
|
88
|
+
) -> list[str]:
|
|
89
|
+
"""Remove clone dirs (shared or per-issue) for repos no longer registered.
|
|
90
|
+
|
|
91
|
+
Returns the clone directory names that were removed (or would be, in dry-run).
|
|
92
|
+
"""
|
|
93
|
+
log = log or (lambda _m: None)
|
|
94
|
+
if not workspace.clones_dir.exists():
|
|
95
|
+
return []
|
|
96
|
+
known = {f"{r.owner}__{r.name}" for r in repos}
|
|
97
|
+
removed: list[str] = []
|
|
98
|
+
for path in workspace.clones_dir.iterdir():
|
|
99
|
+
if not path.is_dir():
|
|
100
|
+
continue
|
|
101
|
+
# A clone belongs to a registered repo if its name equals "<owner>__<repo>"
|
|
102
|
+
# or starts with "<owner>__<repo>__issue-".
|
|
103
|
+
if any(path.name == k or path.name.startswith(f"{k}__issue-") for k in known):
|
|
104
|
+
continue
|
|
105
|
+
if dry_run:
|
|
106
|
+
log(f" · would remove orphaned clone {path.name}")
|
|
107
|
+
else:
|
|
108
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
109
|
+
log(f" · removed orphaned clone {path.name}")
|
|
110
|
+
removed.append(path.name)
|
|
111
|
+
return removed
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
# Why does this file exist, and why not put this in `__main__`?
|
|
2
|
+
#
|
|
3
|
+
# You might be tempted to import things from `__main__` later,
|
|
4
|
+
# but that will cause problems: the code will get executed twice:
|
|
5
|
+
#
|
|
6
|
+
# - When you run `python -m cheaphelp` python will execute
|
|
7
|
+
# `__main__.py` as a script. That means there won't be any
|
|
8
|
+
# `cheaphelp.__main__` in `sys.modules`.
|
|
9
|
+
# - When you import `__main__` it will get executed again (as a module) because
|
|
10
|
+
# there's no `cheaphelp.__main__` in `sys.modules`.
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from cheaphelp._internal import commands, debug
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _DebugInfo(argparse.Action):
|
|
22
|
+
def __init__(self, nargs: int | str | None = 0, **kwargs: Any) -> None:
|
|
23
|
+
super().__init__(nargs=nargs, **kwargs)
|
|
24
|
+
|
|
25
|
+
def __call__(self, *args: Any, **kwargs: Any) -> None: # noqa: ARG002
|
|
26
|
+
debug._print_debug_info()
|
|
27
|
+
sys.exit(0)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_parser() -> argparse.ArgumentParser:
|
|
31
|
+
"""Return the CLI argument parser.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
An argparse parser.
|
|
35
|
+
"""
|
|
36
|
+
parser = argparse.ArgumentParser(
|
|
37
|
+
prog="cheaphelp",
|
|
38
|
+
description="An AI software-engineer for your GitHub repositories, powered by "
|
|
39
|
+
"cheap OpenRouter models via the opencode harness.",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {debug._get_version()}")
|
|
42
|
+
parser.add_argument("--debug-info", action=_DebugInfo, help="Print debug information.")
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--home",
|
|
45
|
+
metavar="DIR",
|
|
46
|
+
help="Workspace directory (default: $CHEAPHELP_HOME or ~/.cheaphelp).",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
subparsers = parser.add_subparsers(dest="command", metavar="<command>")
|
|
50
|
+
|
|
51
|
+
# init
|
|
52
|
+
p_init = subparsers.add_parser("init", help="Create or update the workspace.")
|
|
53
|
+
p_init.add_argument("--github-token", help="GitHub personal access token.")
|
|
54
|
+
p_init.add_argument("--openrouter-key", help="OpenRouter API key.")
|
|
55
|
+
p_init.add_argument("--no-prompt", action="store_true", help="Never prompt for secrets.")
|
|
56
|
+
p_init.set_defaults(func=commands.cmd_init)
|
|
57
|
+
|
|
58
|
+
# repo
|
|
59
|
+
p_repo = subparsers.add_parser("repo", help="Manage registered repositories.")
|
|
60
|
+
repo_sub = p_repo.add_subparsers(dest="repo_command", metavar="<action>")
|
|
61
|
+
p_add = repo_sub.add_parser("add", help="Register a repository (owner/name).")
|
|
62
|
+
p_add.add_argument("slug", help="Repository as owner/name or a GitHub URL.")
|
|
63
|
+
p_add.add_argument(
|
|
64
|
+
"--checks",
|
|
65
|
+
default="",
|
|
66
|
+
help="Quality-gate shell command run in the clone before a PR is opened "
|
|
67
|
+
'(e.g. "ruff check . && pytest"). A failing gate sends the issue back to planning.',
|
|
68
|
+
)
|
|
69
|
+
p_add.add_argument(
|
|
70
|
+
"--autofix",
|
|
71
|
+
default="",
|
|
72
|
+
help="Shell command run in the clone before the gate to auto-fix trivial "
|
|
73
|
+
'issues (e.g. "ruff check --fix . ; ruff format ."). Changes are committed.',
|
|
74
|
+
)
|
|
75
|
+
p_add.add_argument(
|
|
76
|
+
"--max-diff-files",
|
|
77
|
+
type=int,
|
|
78
|
+
default=None,
|
|
79
|
+
metavar="N",
|
|
80
|
+
help="Maximum files allowed in a single PR for this repo (0 = unlimited, default 30).",
|
|
81
|
+
)
|
|
82
|
+
p_add.add_argument(
|
|
83
|
+
"--max-diff-lines",
|
|
84
|
+
type=int,
|
|
85
|
+
default=None,
|
|
86
|
+
metavar="N",
|
|
87
|
+
help="Maximum lines (added+removed) allowed in a single PR (0 = unlimited, default 1000).",
|
|
88
|
+
)
|
|
89
|
+
p_add.set_defaults(func=commands.cmd_repo_add)
|
|
90
|
+
p_list = repo_sub.add_parser("list", help="List registered repositories.")
|
|
91
|
+
p_list.add_argument(
|
|
92
|
+
"--json",
|
|
93
|
+
action="store_true",
|
|
94
|
+
help="Output the list of registered repositories as a JSON array.",
|
|
95
|
+
)
|
|
96
|
+
p_list.set_defaults(func=commands.cmd_repo_list)
|
|
97
|
+
p_rm = repo_sub.add_parser("remove", help="Unregister a repository.")
|
|
98
|
+
p_rm.add_argument("slug")
|
|
99
|
+
p_rm.set_defaults(func=commands.cmd_repo_remove)
|
|
100
|
+
p_set = repo_sub.add_parser(
|
|
101
|
+
"set",
|
|
102
|
+
help="Update checks/autofix on an already-registered repository.",
|
|
103
|
+
)
|
|
104
|
+
p_set.add_argument("slug", help="Repository as owner/name or a GitHub URL.")
|
|
105
|
+
p_set.add_argument(
|
|
106
|
+
"--checks",
|
|
107
|
+
default=None,
|
|
108
|
+
help="Replace the quality-gate shell command. Pass an empty string to disable the gate.",
|
|
109
|
+
)
|
|
110
|
+
p_set.add_argument(
|
|
111
|
+
"--autofix",
|
|
112
|
+
default=None,
|
|
113
|
+
help="Replace the auto-fix shell command. Pass an empty string to disable auto-fix.",
|
|
114
|
+
)
|
|
115
|
+
p_set.add_argument(
|
|
116
|
+
"--max-diff-files",
|
|
117
|
+
type=int,
|
|
118
|
+
default=None,
|
|
119
|
+
metavar="N",
|
|
120
|
+
help="Maximum files allowed in a single PR for this repo (0 = unlimited, default 30).",
|
|
121
|
+
)
|
|
122
|
+
p_set.add_argument(
|
|
123
|
+
"--max-diff-lines",
|
|
124
|
+
type=int,
|
|
125
|
+
default=None,
|
|
126
|
+
metavar="N",
|
|
127
|
+
help="Maximum lines (added+removed) allowed in a single PR (0 = unlimited, default 1000).",
|
|
128
|
+
)
|
|
129
|
+
p_set.set_defaults(func=commands.cmd_repo_set)
|
|
130
|
+
p_update = repo_sub.add_parser(
|
|
131
|
+
"update",
|
|
132
|
+
help="Update checks/autofix/max-diff-* on an already-registered repository (alias of `set`).",
|
|
133
|
+
)
|
|
134
|
+
p_update.add_argument("slug", help="Repository as owner/name or a GitHub URL.")
|
|
135
|
+
p_update.add_argument(
|
|
136
|
+
"--checks",
|
|
137
|
+
default=None,
|
|
138
|
+
help="Replace the quality-gate shell command. Pass an empty string to disable the gate.",
|
|
139
|
+
)
|
|
140
|
+
p_update.add_argument(
|
|
141
|
+
"--autofix",
|
|
142
|
+
default=None,
|
|
143
|
+
help="Replace the auto-fix shell command. Pass an empty string to disable auto-fix.",
|
|
144
|
+
)
|
|
145
|
+
p_update.add_argument(
|
|
146
|
+
"--max-diff-files",
|
|
147
|
+
type=int,
|
|
148
|
+
default=None,
|
|
149
|
+
metavar="N",
|
|
150
|
+
help="Maximum files allowed in a single PR for this repo (0 = unlimited, default 30).",
|
|
151
|
+
)
|
|
152
|
+
p_update.add_argument(
|
|
153
|
+
"--max-diff-lines",
|
|
154
|
+
type=int,
|
|
155
|
+
default=None,
|
|
156
|
+
metavar="N",
|
|
157
|
+
help="Maximum lines (added+removed) allowed in a single PR (0 = unlimited, default 1000).",
|
|
158
|
+
)
|
|
159
|
+
p_update.set_defaults(func=commands.cmd_repo_set)
|
|
160
|
+
p_en = repo_sub.add_parser("enable", help="Enable processing for a repository.")
|
|
161
|
+
p_en.add_argument("slug")
|
|
162
|
+
p_en.set_defaults(func=lambda a: commands.cmd_repo_toggle(a, enabled=True))
|
|
163
|
+
p_dis = repo_sub.add_parser("disable", help="Disable processing for a repository.")
|
|
164
|
+
p_dis.add_argument("slug")
|
|
165
|
+
p_dis.set_defaults(func=lambda a: commands.cmd_repo_toggle(a, enabled=False))
|
|
166
|
+
|
|
167
|
+
# run
|
|
168
|
+
p_run = subparsers.add_parser("run", help="Run the orchestrator. Default: one tick.")
|
|
169
|
+
p_run.add_argument(
|
|
170
|
+
"--dry-run",
|
|
171
|
+
action="store_true",
|
|
172
|
+
help="Report what would happen without acting (applies to every tick in a multi-tick run).",
|
|
173
|
+
)
|
|
174
|
+
p_run.add_argument(
|
|
175
|
+
"-n",
|
|
176
|
+
"--num-ticks",
|
|
177
|
+
type=int,
|
|
178
|
+
default=1,
|
|
179
|
+
metavar="N",
|
|
180
|
+
help="Run exactly N ticks, sleeping --sleep seconds between each (default 1). "
|
|
181
|
+
"Mutually exclusive with --continuous.",
|
|
182
|
+
)
|
|
183
|
+
p_run.add_argument(
|
|
184
|
+
"--continuous",
|
|
185
|
+
action="store_true",
|
|
186
|
+
help="Run until a tick produces no work, capped at --max-ticks. Mutually exclusive with --num-ticks.",
|
|
187
|
+
)
|
|
188
|
+
p_run.add_argument(
|
|
189
|
+
"--max-ticks",
|
|
190
|
+
type=int,
|
|
191
|
+
default=20,
|
|
192
|
+
metavar="N",
|
|
193
|
+
help="Hard cap on total ticks when --continuous is used (default 20). Ignored when --num-ticks is set.",
|
|
194
|
+
)
|
|
195
|
+
p_run.add_argument(
|
|
196
|
+
"--sleep",
|
|
197
|
+
type=float,
|
|
198
|
+
default=30.0,
|
|
199
|
+
metavar="N",
|
|
200
|
+
help="Seconds to sleep between ticks in multi-tick mode (default 30).",
|
|
201
|
+
)
|
|
202
|
+
p_run.add_argument(
|
|
203
|
+
"--max-issues",
|
|
204
|
+
type=int,
|
|
205
|
+
default=0,
|
|
206
|
+
metavar="N",
|
|
207
|
+
help=(
|
|
208
|
+
"Cap the number of issues processed per repo in this tick "
|
|
209
|
+
"(0 = unlimited, default 0). Overrides max_issues_per_tick from "
|
|
210
|
+
"config.json when > 0."
|
|
211
|
+
),
|
|
212
|
+
)
|
|
213
|
+
p_run.set_defaults(func=commands.cmd_run)
|
|
214
|
+
|
|
215
|
+
# systemd
|
|
216
|
+
p_sys = subparsers.add_parser("systemd", help="Manage the systemd user timer.")
|
|
217
|
+
sys_sub = p_sys.add_subparsers(dest="systemd_command", metavar="<action>")
|
|
218
|
+
p_sys_install = sys_sub.add_parser("install", help="Install and start the timer.")
|
|
219
|
+
p_sys_install.add_argument(
|
|
220
|
+
"--interval",
|
|
221
|
+
default="10m",
|
|
222
|
+
help="Timer firing interval, e.g. 30s, 10m, 2h (default: 10m).",
|
|
223
|
+
)
|
|
224
|
+
p_sys_install.add_argument(
|
|
225
|
+
"--continuous",
|
|
226
|
+
action=argparse.BooleanOptionalAction,
|
|
227
|
+
default=True,
|
|
228
|
+
help=(
|
|
229
|
+
"Each timer firing runs `cheaphelp run --continuous`, draining the "
|
|
230
|
+
"backlog (repeated ticks until one is idle, capped at --max-ticks) "
|
|
231
|
+
"instead of a single tick (default: enabled). Use --no-continuous "
|
|
232
|
+
"for one tick per firing."
|
|
233
|
+
),
|
|
234
|
+
)
|
|
235
|
+
p_sys_install.add_argument(
|
|
236
|
+
"--max-ticks",
|
|
237
|
+
type=int,
|
|
238
|
+
default=20,
|
|
239
|
+
metavar="N",
|
|
240
|
+
help="Cap on ticks per timer firing in continuous mode (default 20).",
|
|
241
|
+
)
|
|
242
|
+
p_sys_install.add_argument(
|
|
243
|
+
"--sleep",
|
|
244
|
+
type=float,
|
|
245
|
+
default=30.0,
|
|
246
|
+
metavar="N",
|
|
247
|
+
help="Seconds to sleep between ticks in continuous mode (default 30).",
|
|
248
|
+
)
|
|
249
|
+
p_sys_install.add_argument(
|
|
250
|
+
"--linger",
|
|
251
|
+
action="store_true",
|
|
252
|
+
help=(
|
|
253
|
+
"After installing the timer, run `loginctl enable-linger $USER` so the "
|
|
254
|
+
"user timer keeps firing after logout. Suppresses the lingering tip. "
|
|
255
|
+
"Failures are reported as warnings; install is not aborted."
|
|
256
|
+
),
|
|
257
|
+
)
|
|
258
|
+
p_sys_install.set_defaults(func=commands.cmd_systemd_install)
|
|
259
|
+
sys_sub.add_parser("uninstall", help="Stop and remove the timer.").set_defaults(
|
|
260
|
+
func=commands.cmd_systemd_uninstall,
|
|
261
|
+
)
|
|
262
|
+
sys_sub.add_parser("status", help="Show timer status.").set_defaults(
|
|
263
|
+
func=commands.cmd_systemd_status,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# agents
|
|
267
|
+
p_agents = subparsers.add_parser("agents", help="Manage agent prompts and opencode config.")
|
|
268
|
+
agents_sub = p_agents.add_subparsers(dest="agents_command", metavar="<action>")
|
|
269
|
+
p_sync = agents_sub.add_parser("sync", help="Regenerate opencode.json from prompts + config.")
|
|
270
|
+
p_sync.add_argument("--force", action="store_true", help="Overwrite workspace prompts.")
|
|
271
|
+
p_sync.set_defaults(func=commands.cmd_agents_sync)
|
|
272
|
+
|
|
273
|
+
# config
|
|
274
|
+
p_config = subparsers.add_parser("config", help="View or change configuration settings.")
|
|
275
|
+
config_sub = p_config.add_subparsers(dest="config_command", metavar="<action>")
|
|
276
|
+
p_cfg_show = config_sub.add_parser("show", help="Print the effective configuration.")
|
|
277
|
+
p_cfg_show.set_defaults(func=commands.cmd_config_show)
|
|
278
|
+
p_cfg_get = config_sub.add_parser("get", help="Look up a single config value by dotted path.")
|
|
279
|
+
p_cfg_get.add_argument("key", help="Dotted path to a config key (e.g. models.worker).")
|
|
280
|
+
p_cfg_get.set_defaults(func=commands.cmd_config_get)
|
|
281
|
+
p_cfg_set = config_sub.add_parser("set", help="Set a config value by dotted path.")
|
|
282
|
+
p_cfg_set.add_argument("key", help="Dotted path to a config key (e.g. agent_timeout).")
|
|
283
|
+
p_cfg_set.add_argument("value", help="New value for the config key.")
|
|
284
|
+
p_cfg_set.set_defaults(func=commands.cmd_config_set)
|
|
285
|
+
|
|
286
|
+
# doctor
|
|
287
|
+
subparsers.add_parser("doctor", help="Check workspace, tokens and opencode.").set_defaults(
|
|
288
|
+
func=commands.cmd_doctor,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# status
|
|
292
|
+
p_status = subparsers.add_parser(
|
|
293
|
+
"status",
|
|
294
|
+
help="List open issues for each enabled repo and their pipeline stage.",
|
|
295
|
+
)
|
|
296
|
+
p_status.add_argument(
|
|
297
|
+
"--costs",
|
|
298
|
+
action="store_true",
|
|
299
|
+
help="Also show the cumulative cost per issue (read from <issue_dir>/cost.json).",
|
|
300
|
+
)
|
|
301
|
+
p_status.set_defaults(func=commands.cmd_status)
|
|
302
|
+
|
|
303
|
+
# clean
|
|
304
|
+
p_clean = subparsers.add_parser(
|
|
305
|
+
"clean",
|
|
306
|
+
help="Remove build clones for closed issues and unregistered repos (keeps state).",
|
|
307
|
+
)
|
|
308
|
+
p_clean.add_argument(
|
|
309
|
+
"--dry-run",
|
|
310
|
+
action="store_true",
|
|
311
|
+
help="Report what would be removed without deleting anything.",
|
|
312
|
+
)
|
|
313
|
+
p_clean.set_defaults(func=commands.cmd_clean)
|
|
314
|
+
|
|
315
|
+
# retry
|
|
316
|
+
p_retry = subparsers.add_parser(
|
|
317
|
+
"retry",
|
|
318
|
+
help="Un-stick an issue labeled 'needs-human' and run one orchestrator tick.",
|
|
319
|
+
)
|
|
320
|
+
p_retry.add_argument("slug", help="Repository as owner/name or a GitHub URL.")
|
|
321
|
+
p_retry.add_argument("number", type=int, help="Issue number to retry.")
|
|
322
|
+
p_retry.add_argument(
|
|
323
|
+
"--dry-run",
|
|
324
|
+
action="store_true",
|
|
325
|
+
help="Report the planned actions without making API or filesystem changes.",
|
|
326
|
+
)
|
|
327
|
+
p_retry.add_argument(
|
|
328
|
+
"-y",
|
|
329
|
+
"--yes",
|
|
330
|
+
action="store_true",
|
|
331
|
+
help="Skip the confirmation prompt.",
|
|
332
|
+
)
|
|
333
|
+
p_retry.set_defaults(func=commands.cmd_retry)
|
|
334
|
+
|
|
335
|
+
# logs
|
|
336
|
+
p_logs = subparsers.add_parser("logs", help="Show recent run activity, or follow it live.")
|
|
337
|
+
p_logs.add_argument(
|
|
338
|
+
"--follow",
|
|
339
|
+
"-f",
|
|
340
|
+
action="store_true",
|
|
341
|
+
help="Stream new log lines as they are appended (Ctrl-C to stop).",
|
|
342
|
+
)
|
|
343
|
+
p_logs.add_argument(
|
|
344
|
+
"--issue",
|
|
345
|
+
type=int,
|
|
346
|
+
metavar="N",
|
|
347
|
+
help="Show only lines that reference issue #N.",
|
|
348
|
+
)
|
|
349
|
+
p_logs.set_defaults(func=commands.cmd_logs)
|
|
350
|
+
|
|
351
|
+
return parser
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def main(args: list[str] | None = None) -> int:
|
|
355
|
+
"""Run the main program.
|
|
356
|
+
|
|
357
|
+
This function is executed when you type `cheaphelp` or `python -m cheaphelp`.
|
|
358
|
+
|
|
359
|
+
Parameters:
|
|
360
|
+
args: Arguments passed from the command line.
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
An exit code.
|
|
364
|
+
"""
|
|
365
|
+
parser = get_parser()
|
|
366
|
+
opts = parser.parse_args(args=args)
|
|
367
|
+
|
|
368
|
+
func = getattr(opts, "func", None)
|
|
369
|
+
if func is None:
|
|
370
|
+
parser.print_help()
|
|
371
|
+
return 1
|
|
372
|
+
return func(opts)
|