Skip to content

cheaphelp

cheaphelp package.

An AI software-engineer that triages GitHub issues, plans, implements, and reviews changes on your repos using cheap OpenRouter models via the opencode harness.

Functions:

  • get_parser

    Return the CLI argument parser.

  • main

    Run the main program.

get_parser

get_parser() -> ArgumentParser

Return the CLI argument parser.

Returns:

Source code in src/cheaphelp/_internal/cli.py
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
def get_parser() -> argparse.ArgumentParser:
    """Return the CLI argument parser.

    Returns:
        An argparse parser.
    """
    parser = argparse.ArgumentParser(
        prog="cheaphelp",
        description="An AI software-engineer for your GitHub repositories, powered by "
        "cheap OpenRouter models via the opencode harness.",
    )
    parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {debug._get_version()}")
    parser.add_argument("--debug-info", action=_DebugInfo, help="Print debug information.")
    parser.add_argument(
        "--home",
        metavar="DIR",
        help="Workspace directory (default: $CHEAPHELP_HOME or ~/.cheaphelp).",
    )

    subparsers = parser.add_subparsers(dest="command", metavar="<command>")

    # init
    p_init = subparsers.add_parser("init", help="Create or update the workspace.")
    p_init.add_argument("--github-token", help="GitHub personal access token.")
    p_init.add_argument("--openrouter-key", help="OpenRouter API key.")
    p_init.add_argument("--no-prompt", action="store_true", help="Never prompt for secrets.")
    p_init.set_defaults(func=commands.cmd_init)

    # repo
    p_repo = subparsers.add_parser("repo", help="Manage registered repositories.")
    repo_sub = p_repo.add_subparsers(dest="repo_command", metavar="<action>")
    p_add = repo_sub.add_parser("add", help="Register a repository (owner/name).")
    p_add.add_argument("slug", help="Repository as owner/name or a GitHub URL.")
    p_add.add_argument(
        "--checks",
        default="",
        help="Quality-gate shell command run in the clone before a PR is opened "
        '(e.g. "ruff check . && pytest"). A failing gate sends the issue back to planning.',
    )
    p_add.add_argument(
        "--autofix",
        default="",
        help="Shell command run in the clone before the gate to auto-fix trivial "
        'issues (e.g. "ruff check --fix . ; ruff format ."). Changes are committed.',
    )
    p_add.add_argument(
        "--max-diff-files",
        type=int,
        default=None,
        metavar="N",
        help="Maximum files allowed in a single PR for this repo (0 = unlimited, default 30).",
    )
    p_add.add_argument(
        "--max-diff-lines",
        type=int,
        default=None,
        metavar="N",
        help="Maximum lines (added+removed) allowed in a single PR (0 = unlimited, default 1000).",
    )
    p_add.set_defaults(func=commands.cmd_repo_add)
    p_list = repo_sub.add_parser("list", help="List registered repositories.")
    p_list.add_argument(
        "--json",
        action="store_true",
        help="Output the list of registered repositories as a JSON array.",
    )
    p_list.set_defaults(func=commands.cmd_repo_list)
    p_rm = repo_sub.add_parser("remove", help="Unregister a repository.")
    p_rm.add_argument("slug")
    p_rm.set_defaults(func=commands.cmd_repo_remove)
    p_set = repo_sub.add_parser(
        "set",
        help="Update checks/autofix on an already-registered repository.",
    )
    p_set.add_argument("slug", help="Repository as owner/name or a GitHub URL.")
    p_set.add_argument(
        "--checks",
        default=None,
        help="Replace the quality-gate shell command. Pass an empty string to disable the gate.",
    )
    p_set.add_argument(
        "--autofix",
        default=None,
        help="Replace the auto-fix shell command. Pass an empty string to disable auto-fix.",
    )
    p_set.add_argument(
        "--max-diff-files",
        type=int,
        default=None,
        metavar="N",
        help="Maximum files allowed in a single PR for this repo (0 = unlimited, default 30).",
    )
    p_set.add_argument(
        "--max-diff-lines",
        type=int,
        default=None,
        metavar="N",
        help="Maximum lines (added+removed) allowed in a single PR (0 = unlimited, default 1000).",
    )
    p_set.set_defaults(func=commands.cmd_repo_set)
    p_update = repo_sub.add_parser(
        "update",
        help="Update checks/autofix/max-diff-* on an already-registered repository (alias of `set`).",
    )
    p_update.add_argument("slug", help="Repository as owner/name or a GitHub URL.")
    p_update.add_argument(
        "--checks",
        default=None,
        help="Replace the quality-gate shell command. Pass an empty string to disable the gate.",
    )
    p_update.add_argument(
        "--autofix",
        default=None,
        help="Replace the auto-fix shell command. Pass an empty string to disable auto-fix.",
    )
    p_update.add_argument(
        "--max-diff-files",
        type=int,
        default=None,
        metavar="N",
        help="Maximum files allowed in a single PR for this repo (0 = unlimited, default 30).",
    )
    p_update.add_argument(
        "--max-diff-lines",
        type=int,
        default=None,
        metavar="N",
        help="Maximum lines (added+removed) allowed in a single PR (0 = unlimited, default 1000).",
    )
    p_update.set_defaults(func=commands.cmd_repo_set)
    p_en = repo_sub.add_parser("enable", help="Enable processing for a repository.")
    p_en.add_argument("slug")
    p_en.set_defaults(func=lambda a: commands.cmd_repo_toggle(a, enabled=True))
    p_dis = repo_sub.add_parser("disable", help="Disable processing for a repository.")
    p_dis.add_argument("slug")
    p_dis.set_defaults(func=lambda a: commands.cmd_repo_toggle(a, enabled=False))

    # run
    p_run = subparsers.add_parser("run", help="Run the orchestrator. Default: one tick.")
    p_run.add_argument(
        "--dry-run",
        action="store_true",
        help="Report what would happen without acting (applies to every tick in a multi-tick run).",
    )
    p_run.add_argument(
        "-n",
        "--num-ticks",
        type=int,
        default=1,
        metavar="N",
        help="Run exactly N ticks, sleeping --sleep seconds between each (default 1). "
        "Mutually exclusive with --continuous.",
    )
    p_run.add_argument(
        "--continuous",
        action="store_true",
        help="Run until a tick produces no work, capped at --max-ticks. Mutually exclusive with --num-ticks.",
    )
    p_run.add_argument(
        "--max-ticks",
        type=int,
        default=20,
        metavar="N",
        help="Hard cap on total ticks when --continuous is used (default 20). Ignored when --num-ticks is set.",
    )
    p_run.add_argument(
        "--sleep",
        type=float,
        default=30.0,
        metavar="N",
        help="Seconds to sleep between ticks in multi-tick mode (default 30).",
    )
    p_run.add_argument(
        "--max-issues",
        type=int,
        default=0,
        metavar="N",
        help=(
            "Cap the number of issues processed per repo in this tick "
            "(0 = unlimited, default 0). Overrides max_issues_per_tick from "
            "config.json when > 0."
        ),
    )
    p_run.set_defaults(func=commands.cmd_run)

    # systemd
    p_sys = subparsers.add_parser("systemd", help="Manage the systemd user timer.")
    sys_sub = p_sys.add_subparsers(dest="systemd_command", metavar="<action>")
    p_sys_install = sys_sub.add_parser("install", help="Install and start the timer.")
    p_sys_install.add_argument(
        "--interval",
        default="10m",
        help="Timer firing interval, e.g. 30s, 10m, 2h (default: 10m).",
    )
    p_sys_install.add_argument(
        "--continuous",
        action=argparse.BooleanOptionalAction,
        default=True,
        help=(
            "Each timer firing runs `cheaphelp run --continuous`, draining the "
            "backlog (repeated ticks until one is idle, capped at --max-ticks) "
            "instead of a single tick (default: enabled). Use --no-continuous "
            "for one tick per firing."
        ),
    )
    p_sys_install.add_argument(
        "--max-ticks",
        type=int,
        default=20,
        metavar="N",
        help="Cap on ticks per timer firing in continuous mode (default 20).",
    )
    p_sys_install.add_argument(
        "--sleep",
        type=float,
        default=30.0,
        metavar="N",
        help="Seconds to sleep between ticks in continuous mode (default 30).",
    )
    p_sys_install.add_argument(
        "--linger",
        action="store_true",
        help=(
            "After installing the timer, run `loginctl enable-linger $USER` so the "
            "user timer keeps firing after logout. Suppresses the lingering tip. "
            "Failures are reported as warnings; install is not aborted."
        ),
    )
    p_sys_install.set_defaults(func=commands.cmd_systemd_install)
    sys_sub.add_parser("uninstall", help="Stop and remove the timer.").set_defaults(
        func=commands.cmd_systemd_uninstall,
    )
    sys_sub.add_parser("status", help="Show timer status.").set_defaults(
        func=commands.cmd_systemd_status,
    )

    # agents
    p_agents = subparsers.add_parser("agents", help="Manage agent prompts and opencode config.")
    agents_sub = p_agents.add_subparsers(dest="agents_command", metavar="<action>")
    p_sync = agents_sub.add_parser("sync", help="Regenerate opencode.json from prompts + config.")
    p_sync.add_argument("--force", action="store_true", help="Overwrite workspace prompts.")
    p_sync.set_defaults(func=commands.cmd_agents_sync)

    # config
    p_config = subparsers.add_parser("config", help="View or change configuration settings.")
    config_sub = p_config.add_subparsers(dest="config_command", metavar="<action>")
    p_cfg_show = config_sub.add_parser("show", help="Print the effective configuration.")
    p_cfg_show.set_defaults(func=commands.cmd_config_show)
    p_cfg_get = config_sub.add_parser("get", help="Look up a single config value by dotted path.")
    p_cfg_get.add_argument("key", help="Dotted path to a config key (e.g. models.worker).")
    p_cfg_get.set_defaults(func=commands.cmd_config_get)
    p_cfg_set = config_sub.add_parser("set", help="Set a config value by dotted path.")
    p_cfg_set.add_argument("key", help="Dotted path to a config key (e.g. agent_timeout).")
    p_cfg_set.add_argument("value", help="New value for the config key.")
    p_cfg_set.set_defaults(func=commands.cmd_config_set)

    # doctor
    subparsers.add_parser("doctor", help="Check workspace, tokens and opencode.").set_defaults(
        func=commands.cmd_doctor,
    )

    # status
    p_status = subparsers.add_parser(
        "status",
        help="List open issues for each enabled repo and their pipeline stage.",
    )
    p_status.add_argument(
        "--costs",
        action="store_true",
        help="Also show the cumulative cost per issue (read from <issue_dir>/cost.json).",
    )
    p_status.set_defaults(func=commands.cmd_status)

    # clean
    p_clean = subparsers.add_parser(
        "clean",
        help="Remove build clones for closed issues and unregistered repos (keeps state).",
    )
    p_clean.add_argument(
        "--dry-run",
        action="store_true",
        help="Report what would be removed without deleting anything.",
    )
    p_clean.set_defaults(func=commands.cmd_clean)

    # retry
    p_retry = subparsers.add_parser(
        "retry",
        help="Un-stick an issue labeled 'needs-human' and run one orchestrator tick.",
    )
    p_retry.add_argument("slug", help="Repository as owner/name or a GitHub URL.")
    p_retry.add_argument("number", type=int, help="Issue number to retry.")
    p_retry.add_argument(
        "--dry-run",
        action="store_true",
        help="Report the planned actions without making API or filesystem changes.",
    )
    p_retry.add_argument(
        "-y",
        "--yes",
        action="store_true",
        help="Skip the confirmation prompt.",
    )
    p_retry.set_defaults(func=commands.cmd_retry)

    # logs
    p_logs = subparsers.add_parser("logs", help="Show recent run activity, or follow it live.")
    p_logs.add_argument(
        "--follow",
        "-f",
        action="store_true",
        help="Stream new log lines as they are appended (Ctrl-C to stop).",
    )
    p_logs.add_argument(
        "--issue",
        type=int,
        metavar="N",
        help="Show only lines that reference issue #N.",
    )
    p_logs.set_defaults(func=commands.cmd_logs)

    return parser

main

main(args: list[str] | None = None) -> int

Run the main program.

This function is executed when you type cheaphelp or python -m cheaphelp.

Parameters:

  • args

    (list[str] | None, default: None ) –

    Arguments passed from the command line.

Returns:

  • int

    An exit code.

Source code in src/cheaphelp/_internal/cli.py
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
def main(args: list[str] | None = None) -> int:
    """Run the main program.

    This function is executed when you type `cheaphelp` or `python -m cheaphelp`.

    Parameters:
        args: Arguments passed from the command line.

    Returns:
        An exit code.
    """
    parser = get_parser()
    opts = parser.parse_args(args=args)

    func = getattr(opts, "func", None)
    if func is None:
        parser.print_help()
        return 1
    return func(opts)