caokit 0.3.0.dev0__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.
- caokit/__init__.py +3 -0
- caokit/adapters.py +259 -0
- caokit/cli.py +704 -0
- caokit/company.py +115 -0
- caokit/config.py +191 -0
- caokit/data/adapters/antigravity.toml +35 -0
- caokit/data/adapters/claude.toml +38 -0
- caokit/data/adapters/codex.toml +33 -0
- caokit/data/roles/engineer.md +90 -0
- caokit/data/roles/general.md +177 -0
- caokit/data/roles/inspector.md +55 -0
- caokit/data/roles/scout.md +77 -0
- caokit/data/scaffold/company/BACKLOG.md +5 -0
- caokit/data/scaffold/company/COMPANY.md +28 -0
- caokit/data/scaffold/company/DECISIONS.md +5 -0
- caokit/data/scaffold/company/README.md +16 -0
- caokit/data/scaffold/company/TEAM.md +7 -0
- caokit/data/scaffold/company/contracts/ARTIFACT_CONTRACT.md +44 -0
- caokit/data/scaffold/company/contracts/COMMUNICATION_CONTRACT.md +38 -0
- caokit/data/scaffold/company/contracts/COMPANY_CONTRACT.md +42 -0
- caokit/data/scaffold/company/contracts/REVIEW_CONTRACT.md +36 -0
- caokit/data/scaffold/company/contracts/SAFETY_CONTRACT.md +40 -0
- caokit/data/scaffold/company/contracts/STATE_CONTRACT.md +31 -0
- caokit/data/scaffold/company/contracts/TASK_CONTRACT.md +26 -0
- caokit/data/scaffold/company/prompts/first-run.md +18 -0
- caokit/data/scaffold/company/tasks/TASK-0001-smoke-test.md +109 -0
- caokit/data/scaffold/company/tasks/TASK_TEMPLATE.md +73 -0
- caokit/doctor.py +287 -0
- caokit/gitignore.py +36 -0
- caokit/init_project.py +183 -0
- caokit/install.py +242 -0
- caokit/launch.py +102 -0
- caokit/project.py +359 -0
- caokit/render.py +129 -0
- caokit/reset.py +208 -0
- caokit/roster.py +95 -0
- caokit/schedule.py +104 -0
- caokit/stack.py +117 -0
- caokit/tasks.py +180 -0
- caokit/worktrees.py +107 -0
- caokit-0.3.0.dev0.dist-info/METADATA +382 -0
- caokit-0.3.0.dev0.dist-info/RECORD +46 -0
- caokit-0.3.0.dev0.dist-info/WHEEL +5 -0
- caokit-0.3.0.dev0.dist-info/entry_points.txt +2 -0
- caokit-0.3.0.dev0.dist-info/licenses/LICENSE +21 -0
- caokit-0.3.0.dev0.dist-info/top_level.txt +1 -0
caokit/cli.py
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
"""caokit command line."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import functools
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from . import __version__, schedule, worktrees
|
|
13
|
+
from .adapters import ROLES, AdapterError, adapter_ids, default_runner, load_adapter
|
|
14
|
+
from .company import CompanyError, create_company, parse_binding, write_company
|
|
15
|
+
from .config import Binding, ConfigError, default_path, load_config
|
|
16
|
+
from .doctor import format_report, run_doctor
|
|
17
|
+
from .init_project import InitError, init_project
|
|
18
|
+
from .install import (
|
|
19
|
+
InstallError,
|
|
20
|
+
cao_home,
|
|
21
|
+
find_legacy,
|
|
22
|
+
install_profiles,
|
|
23
|
+
remove_legacy,
|
|
24
|
+
uninstall_profiles,
|
|
25
|
+
)
|
|
26
|
+
from .launch import (
|
|
27
|
+
build_launch_argv,
|
|
28
|
+
build_send_argv,
|
|
29
|
+
build_status_argv,
|
|
30
|
+
build_stop_argv,
|
|
31
|
+
session_id,
|
|
32
|
+
session_name,
|
|
33
|
+
)
|
|
34
|
+
from .project import (
|
|
35
|
+
check_effective,
|
|
36
|
+
effective_config,
|
|
37
|
+
load_overrides,
|
|
38
|
+
resolve_project,
|
|
39
|
+
set_keep_archived_days,
|
|
40
|
+
set_worktrees,
|
|
41
|
+
worktrees_enabled,
|
|
42
|
+
)
|
|
43
|
+
from .render import profile_name
|
|
44
|
+
from .reset import ResetError, plan, run_reset
|
|
45
|
+
from .roster import check_roster
|
|
46
|
+
from .tasks import (
|
|
47
|
+
TaskError,
|
|
48
|
+
archive_finished,
|
|
49
|
+
format_tasks,
|
|
50
|
+
load_tasks,
|
|
51
|
+
prune_archive,
|
|
52
|
+
with_blockers,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _ask(prompt: str, default: str) -> str:
|
|
57
|
+
"""Prompt on stdin, returning ``default`` when the answer is blank.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
prompt: Question text.
|
|
61
|
+
default: Value shown in brackets and used for an empty answer.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The stripped answer or the default.
|
|
65
|
+
"""
|
|
66
|
+
ans = input(f"{prompt} [{default}]: ").strip()
|
|
67
|
+
return ans or default
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _interactive_bindings() -> dict[str, Binding]:
|
|
71
|
+
"""Ask for a tool and model per role, offering the adapter's recommendation.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
One :class:`Binding` per role. A model answer of ``none`` means "use the
|
|
75
|
+
tool's own default" and is stored as None.
|
|
76
|
+
"""
|
|
77
|
+
print("Pick a tool and model per role. Enter accepts the recommendation.")
|
|
78
|
+
active = ", ".join(t for t in adapter_ids() if load_adapter(t).status == "active")
|
|
79
|
+
print(f"Tools: {active} (codex is parked)")
|
|
80
|
+
out: dict[str, Binding] = {}
|
|
81
|
+
for role in ROLES:
|
|
82
|
+
# Claude does the building roles; Antigravity gives the independent second opinion.
|
|
83
|
+
default_tool = "claude" if role in ("general", "engineer") else "antigravity"
|
|
84
|
+
tool = _ask(f"{role} tool", default_tool)
|
|
85
|
+
try:
|
|
86
|
+
rec = load_adapter(tool).roles[role].recommended_model or "none"
|
|
87
|
+
except AdapterError:
|
|
88
|
+
rec = "none" # unknown tool: roster validation will report it with a clear error
|
|
89
|
+
model = _ask(f"{role} model (or 'none' for the tool default)", rec)
|
|
90
|
+
out[role] = Binding(tool, None if model == "none" else model)
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def cmd_doctor(a: argparse.Namespace) -> int:
|
|
95
|
+
"""Run ``caokit doctor``.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
a: Parsed arguments (uses ``config``).
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
1 if any check failed, else 0.
|
|
102
|
+
"""
|
|
103
|
+
rep = run_doctor(a.config)
|
|
104
|
+
print(format_report(rep))
|
|
105
|
+
return 1 if rep.failed else 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def cmd_company_create(a: argparse.Namespace) -> int:
|
|
109
|
+
"""Run ``caokit company create``.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
a: Parsed arguments (role specs, policy flags, ``force``, ``config``).
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
0 on success, 1 if the roster is invalid or the file cannot be written,
|
|
116
|
+
2 if no roles were given and stdin is not a terminal.
|
|
117
|
+
"""
|
|
118
|
+
bindings: dict[str, Binding] = {}
|
|
119
|
+
for role in ROLES:
|
|
120
|
+
spec = getattr(a, role)
|
|
121
|
+
if spec:
|
|
122
|
+
bindings[role] = parse_binding(spec)
|
|
123
|
+
if not bindings:
|
|
124
|
+
if not sys.stdin.isatty():
|
|
125
|
+
# Never block on input() in a pipe or CI job.
|
|
126
|
+
print(
|
|
127
|
+
"error: pass --general/--engineer/--scout/--inspector (tool[:model]) "
|
|
128
|
+
"or run interactively",
|
|
129
|
+
file=sys.stderr,
|
|
130
|
+
)
|
|
131
|
+
return 2
|
|
132
|
+
bindings = _interactive_bindings()
|
|
133
|
+
try:
|
|
134
|
+
cfg = create_company(
|
|
135
|
+
bindings,
|
|
136
|
+
a.allow_soft_engineer,
|
|
137
|
+
a.allow_single_provider,
|
|
138
|
+
allow_soft_general=a.allow_soft_general,
|
|
139
|
+
)
|
|
140
|
+
path = write_company(cfg, a.config, a.force)
|
|
141
|
+
except CompanyError as e:
|
|
142
|
+
print(f"error: {e}", file=sys.stderr)
|
|
143
|
+
return 1
|
|
144
|
+
print(f"Wrote {path}\nNext: caokit install")
|
|
145
|
+
return 0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def cmd_install(a: argparse.Namespace) -> int:
|
|
149
|
+
"""Run ``caokit install``.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
a: Parsed arguments (``config``, ``project``, ``replace_legacy``,
|
|
153
|
+
``remove_legacy``).
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
0 on success, 1 on a config/roster error or if any profile failed to install.
|
|
157
|
+
"""
|
|
158
|
+
try:
|
|
159
|
+
cfg = load_config(a.config)
|
|
160
|
+
if a.project:
|
|
161
|
+
# Project mode: the user-level config plus the project's overrides, installed
|
|
162
|
+
# under <project>_<role>-<tool> names so other projects are not affected.
|
|
163
|
+
cfg = effective_config(Path(a.project), cfg)
|
|
164
|
+
home = cao_home()
|
|
165
|
+
legacy = [] if a.project else find_legacy(home) # legacy cleanup is machine-wide only
|
|
166
|
+
res = install_profiles(cfg, home, replace_legacy=a.replace_legacy)
|
|
167
|
+
except (ConfigError, InstallError) as e:
|
|
168
|
+
print(f"error: {e}", file=sys.stderr)
|
|
169
|
+
return 1
|
|
170
|
+
if res.failed:
|
|
171
|
+
print(f"FAILED: {', '.join(res.failed)}", file=sys.stderr)
|
|
172
|
+
print(
|
|
173
|
+
'If the error mentions "without CAO source provenance", rerun with --replace-legacy.',
|
|
174
|
+
file=sys.stderr,
|
|
175
|
+
)
|
|
176
|
+
return 1
|
|
177
|
+
if legacy:
|
|
178
|
+
print(f"Legacy profiles still installed: {', '.join(legacy)}")
|
|
179
|
+
do = a.remove_legacy
|
|
180
|
+
# Only ask interactively; in a pipe, never move files without the explicit flag.
|
|
181
|
+
if not do and sys.stdin.isatty():
|
|
182
|
+
do = input(f"Move them to a backup folder under {home}? [y/N] ").strip().lower() == "y"
|
|
183
|
+
if do:
|
|
184
|
+
print(f"Moved to {remove_legacy(home, legacy)}")
|
|
185
|
+
else:
|
|
186
|
+
print("Left in place. Run `caokit install --remove-legacy` to move them to a backup.")
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def cmd_uninstall(a: argparse.Namespace) -> int:
|
|
191
|
+
"""Run ``caokit uninstall [--project REPO] [--yes]``: remove the roster's CAO profiles.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
a: Parsed arguments (``config``, ``project``, ``yes``).
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
0 on success or on a dry run, 1 if the config is invalid or a removal failed.
|
|
198
|
+
"""
|
|
199
|
+
try:
|
|
200
|
+
cfg = load_config(a.config)
|
|
201
|
+
if a.project:
|
|
202
|
+
cfg = effective_config(Path(a.project), cfg)
|
|
203
|
+
res = uninstall_profiles(cfg, apply=a.yes)
|
|
204
|
+
except (ConfigError, InstallError, AdapterError) as e:
|
|
205
|
+
print(f"error: {e}", file=sys.stderr)
|
|
206
|
+
return 1
|
|
207
|
+
verb = "Removed" if a.yes else "Would remove"
|
|
208
|
+
print(f"{verb}: {', '.join(res.removed) or 'nothing'}")
|
|
209
|
+
if res.missing:
|
|
210
|
+
print(f"Not installed: {', '.join(res.missing)}")
|
|
211
|
+
if res.backup:
|
|
212
|
+
print(f"Leftover agent-context copies moved to {res.backup}")
|
|
213
|
+
if res.failed:
|
|
214
|
+
print(f"FAILED: {', '.join(res.failed)}", file=sys.stderr)
|
|
215
|
+
return 1
|
|
216
|
+
if res.removed and not a.yes:
|
|
217
|
+
print("Dry run only. Rerun with --yes to remove them.")
|
|
218
|
+
return 0
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def cmd_init(a: argparse.Namespace) -> int:
|
|
222
|
+
"""Run ``caokit init <repo>``.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
a: Parsed arguments (``repo``, ``config``, ``name``, ``override``,
|
|
226
|
+
``force_project``).
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
0 on success, 1 if the config or overrides are bad, or the target is not a git
|
|
230
|
+
repo.
|
|
231
|
+
"""
|
|
232
|
+
try:
|
|
233
|
+
base = load_config(a.config)
|
|
234
|
+
repo = Path(a.repo)
|
|
235
|
+
cfg, new_overrides = resolve_project(repo, base, a.name, a.override)
|
|
236
|
+
if cfg.project:
|
|
237
|
+
errors, warnings = check_effective(cfg, new_overrides)
|
|
238
|
+
for w in warnings:
|
|
239
|
+
print("WARNING: " + w)
|
|
240
|
+
if errors:
|
|
241
|
+
print("error: " + "\n ".join(errors), file=sys.stderr)
|
|
242
|
+
return 1
|
|
243
|
+
res = init_project(repo, cfg, overrides=new_overrides, force_project=a.force_project)
|
|
244
|
+
except (ConfigError, InitError) as e:
|
|
245
|
+
print(f"error: {e}", file=sys.stderr)
|
|
246
|
+
return 1
|
|
247
|
+
print(f"Created {len(res.created)} file(s), kept {len(res.kept)} existing.")
|
|
248
|
+
for k in res.kept:
|
|
249
|
+
print(f" kept: {k}")
|
|
250
|
+
for w in res.warnings:
|
|
251
|
+
print("WARNING: " + w)
|
|
252
|
+
if res.suggestion:
|
|
253
|
+
shown = res.suggestion.command or "(none) " + res.suggestion.reason
|
|
254
|
+
print(f"Suggested validation command (confirm at task approval): {shown}")
|
|
255
|
+
if res.suggestion.note:
|
|
256
|
+
print(" note: " + res.suggestion.note)
|
|
257
|
+
else:
|
|
258
|
+
print("No validation command detected; supply one at task approval.")
|
|
259
|
+
print(
|
|
260
|
+
".gitignore updated (caokit block). Review `git status`, then commit contracts/ yourself."
|
|
261
|
+
)
|
|
262
|
+
if cfg.project:
|
|
263
|
+
general = profile_name("general", cfg.roles["general"].tool, cfg.project)
|
|
264
|
+
repo_abs = Path(a.repo).resolve()
|
|
265
|
+
print(f"Project '{cfg.project}': profiles are namespaced. Next:")
|
|
266
|
+
print(f" caokit install --project {repo_abs}")
|
|
267
|
+
print(
|
|
268
|
+
f" cao launch --agents {general} --session-name {cfg.project} "
|
|
269
|
+
f"--working-directory {repo_abs}"
|
|
270
|
+
)
|
|
271
|
+
return 0
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def cmd_tasks(a: argparse.Namespace) -> int:
|
|
275
|
+
"""Run ``caokit tasks [list|archive]``.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
a: Parsed arguments (``action``, ``repo``, ``all``, ``delete``).
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
0 on success, 1 if the folder is missing or the dependencies loop.
|
|
282
|
+
"""
|
|
283
|
+
action, repo_arg = a.action, a.repo
|
|
284
|
+
if action not in ("list", "archive", "prune", "policy", "schedule"): # bare repo means list
|
|
285
|
+
action, repo_arg = "list", action
|
|
286
|
+
repo = Path(repo_arg).resolve()
|
|
287
|
+
try:
|
|
288
|
+
if action in ("prune", "policy", "schedule"):
|
|
289
|
+
return _tasks_retention(action, repo, a)
|
|
290
|
+
if action == "archive":
|
|
291
|
+
done = archive_finished(repo, delete=a.delete)
|
|
292
|
+
verb = "Deleted" if a.delete else "Archived"
|
|
293
|
+
print(f"{verb} {len(done)} finished task(s).")
|
|
294
|
+
for t in done:
|
|
295
|
+
print(f" {t.id} ({t.state})")
|
|
296
|
+
return 0
|
|
297
|
+
print(format_tasks(with_blockers(load_tasks(repo)), show_all=a.all))
|
|
298
|
+
except (TaskError, ConfigError, schedule.ScheduleError, ValueError) as e:
|
|
299
|
+
print(f"error: {e}", file=sys.stderr)
|
|
300
|
+
return 1
|
|
301
|
+
return 0
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _run(argv: Sequence[str]) -> int:
|
|
305
|
+
"""Run ``argv`` with the terminal attached and return its exit code (127 if not found)."""
|
|
306
|
+
try:
|
|
307
|
+
return subprocess.call(list(argv))
|
|
308
|
+
except FileNotFoundError:
|
|
309
|
+
print(f"error: command not found: {argv[0]}", file=sys.stderr)
|
|
310
|
+
return 127
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _session_of(a: argparse.Namespace) -> tuple[Path, str, str]:
|
|
314
|
+
"""Resolve ``(project, effective_general_profile, session_name)`` for a repo argument.
|
|
315
|
+
|
|
316
|
+
Raises:
|
|
317
|
+
ConfigError: If the user-level or project config is invalid.
|
|
318
|
+
"""
|
|
319
|
+
repo = Path(a.repo).resolve()
|
|
320
|
+
cfg, _ = resolve_project(repo, load_config(a.config), None, [])
|
|
321
|
+
general = profile_name("general", cfg.roles["general"].tool, cfg.project)
|
|
322
|
+
return repo, general, session_name(repo, cfg.project)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def cmd_launch(a: argparse.Namespace) -> int:
|
|
326
|
+
"""Run ``caokit launch [repo]``: start the project's General in a CAO session.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
a: Parsed arguments (``repo``, ``prompt_file``, ``headless``, ``auto_approve``).
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
The exit code of ``cao launch``, or 1 if the project or prompt file is unusable.
|
|
333
|
+
"""
|
|
334
|
+
try:
|
|
335
|
+
repo, general, name = _session_of(a)
|
|
336
|
+
if worktrees_enabled(repo):
|
|
337
|
+
errors, warnings = worktrees.problems(repo)
|
|
338
|
+
if errors:
|
|
339
|
+
raise ValueError("worktrees are on but unusable:\n " + "\n ".join(errors))
|
|
340
|
+
for w in warnings:
|
|
341
|
+
print(f"warning: {w}", file=sys.stderr)
|
|
342
|
+
prompt = None
|
|
343
|
+
if a.prompt_file:
|
|
344
|
+
prompt = Path(a.prompt_file).read_text(encoding="utf-8")
|
|
345
|
+
if not prompt.strip():
|
|
346
|
+
raise ValueError(f"prompt file is empty: {a.prompt_file}")
|
|
347
|
+
argv = build_launch_argv(
|
|
348
|
+
repo,
|
|
349
|
+
general,
|
|
350
|
+
name,
|
|
351
|
+
prompt=prompt,
|
|
352
|
+
headless=a.headless,
|
|
353
|
+
auto_approve=a.auto_approve,
|
|
354
|
+
wait=not a.no_wait,
|
|
355
|
+
)
|
|
356
|
+
except (ConfigError, ValueError, OSError) as e:
|
|
357
|
+
print(f"error: {e}", file=sys.stderr)
|
|
358
|
+
return 1
|
|
359
|
+
return _run(argv)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def cmd_status(a: argparse.Namespace) -> int:
|
|
363
|
+
"""Run ``caokit status [repo]``: list sessions and show this project's workers."""
|
|
364
|
+
try:
|
|
365
|
+
_, _, name = _session_of(a)
|
|
366
|
+
except ConfigError as e:
|
|
367
|
+
print(f"error: {e}", file=sys.stderr)
|
|
368
|
+
return 1
|
|
369
|
+
codes = [_run(argv) for argv in build_status_argv(session_id(name))]
|
|
370
|
+
return max(codes)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def cmd_send(a: argparse.Namespace) -> int:
|
|
374
|
+
"""Run ``caokit send MESSAGE [--repo R]``: message the project's General."""
|
|
375
|
+
try:
|
|
376
|
+
_, _, name = _session_of(a)
|
|
377
|
+
except ConfigError as e:
|
|
378
|
+
print(f"error: {e}", file=sys.stderr)
|
|
379
|
+
return 1
|
|
380
|
+
return _run(build_send_argv(session_id(name), a.message, a.timeout))
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def cmd_stop(a: argparse.Namespace) -> int:
|
|
384
|
+
"""Run ``caokit stop [repo]``: shut the project's session down (no confirmation prompt)."""
|
|
385
|
+
try:
|
|
386
|
+
_, _, name = _session_of(a)
|
|
387
|
+
except ConfigError as e:
|
|
388
|
+
print(f"error: {e}", file=sys.stderr)
|
|
389
|
+
return 1
|
|
390
|
+
return _run(build_stop_argv(session_id(name)))
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def cmd_reset(a: argparse.Namespace) -> int:
|
|
394
|
+
"""Run ``caokit reset``: back up CAO's state and reinstall CAO and the profiles.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
a: Parsed arguments (``yes``, ``config``).
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
0 on success or on a dry run, 1 if the config is invalid or a step fails.
|
|
401
|
+
"""
|
|
402
|
+
try:
|
|
403
|
+
cfg = load_config(a.config)
|
|
404
|
+
except ConfigError as e:
|
|
405
|
+
print(f"error: {e}", file=sys.stderr)
|
|
406
|
+
return 1
|
|
407
|
+
home = cao_home()
|
|
408
|
+
print("This will:")
|
|
409
|
+
for i, step in enumerate(plan(cfg.expected_cao_version, home), 1):
|
|
410
|
+
print(f" {i}. {step}")
|
|
411
|
+
if not a.yes:
|
|
412
|
+
print("Dry run only. Nothing was changed; rerun with --yes to do it.")
|
|
413
|
+
return 0
|
|
414
|
+
|
|
415
|
+
def reinstall() -> None:
|
|
416
|
+
try:
|
|
417
|
+
res = install_profiles(cfg, home)
|
|
418
|
+
except (InstallError, AdapterError) as e:
|
|
419
|
+
raise ResetError(f"profile install failed: {e}") from e
|
|
420
|
+
if res.failed:
|
|
421
|
+
raise ResetError("profile install failed: " + ", ".join(res.failed))
|
|
422
|
+
|
|
423
|
+
# Check everything that can be checked now: reset moves CAO's state and uninstalls CAO,
|
|
424
|
+
# so a bad roster or a missing uv must stop it before the first destructive step.
|
|
425
|
+
try:
|
|
426
|
+
problems = check_roster(cfg).errors
|
|
427
|
+
except AdapterError as e:
|
|
428
|
+
problems = [str(e)]
|
|
429
|
+
if not shutil.which("uv"):
|
|
430
|
+
problems.append("`uv` is not on PATH, so CAO could not be reinstalled")
|
|
431
|
+
if problems:
|
|
432
|
+
print("error: not resetting:\n " + "\n ".join(problems), file=sys.stderr)
|
|
433
|
+
return 1
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
res = run_reset(
|
|
437
|
+
cfg.expected_cao_version,
|
|
438
|
+
home,
|
|
439
|
+
Path.home() / ".gemini" / "config" / "mcp_config.json",
|
|
440
|
+
Path.home(),
|
|
441
|
+
install_profiles=reinstall,
|
|
442
|
+
slow_runner=functools.partial(default_runner, timeout=900),
|
|
443
|
+
)
|
|
444
|
+
except ResetError as e:
|
|
445
|
+
print(f"error: {e}", file=sys.stderr)
|
|
446
|
+
return 1
|
|
447
|
+
for step in res.steps:
|
|
448
|
+
print(" " + step)
|
|
449
|
+
print(f"Reset complete. Backup: {res.backup}")
|
|
450
|
+
return 0
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def cmd_worktrees(a: argparse.Namespace) -> int:
|
|
454
|
+
"""Run ``caokit worktrees [repo]``: show the branches and checkouts CAO made for tasks.
|
|
455
|
+
|
|
456
|
+
Nothing is removed: a branch with unmerged commits is work the human has not merged yet.
|
|
457
|
+
"""
|
|
458
|
+
repo = Path(a.repo).resolve()
|
|
459
|
+
try:
|
|
460
|
+
on = worktrees_enabled(repo)
|
|
461
|
+
except ConfigError as e:
|
|
462
|
+
print(f"error: {e}", file=sys.stderr)
|
|
463
|
+
return 1
|
|
464
|
+
print(f"worktrees: {'on' if on else 'off'}")
|
|
465
|
+
found = worktrees.list_task_worktrees(repo)
|
|
466
|
+
if not found:
|
|
467
|
+
print("No cao/* branches.")
|
|
468
|
+
for w in found:
|
|
469
|
+
state = f"{w.ahead} unmerged commit(s)" if w.ahead else "nothing to merge"
|
|
470
|
+
where = f"checkout {w.path}" if w.path else "checkout removed"
|
|
471
|
+
flag = ", UNCOMMITTED changes (lost when its terminal is deleted)" if w.dirty else ""
|
|
472
|
+
print(f" {w.branch}: {state}; {where}{flag}")
|
|
473
|
+
if any(w.ahead for w in found):
|
|
474
|
+
print("Merge a branch yourself (`git merge cao/<id>`), then `git branch -d cao/<id>`.")
|
|
475
|
+
return 0
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def cmd_server(a: argparse.Namespace) -> int:
|
|
479
|
+
"""Run ``caokit server``: start the CAO server in the foreground (Ctrl-C stops it)."""
|
|
480
|
+
print("Starting the CAO server (tmux backend). Leave this terminal running.")
|
|
481
|
+
return _run(["cao-server"])
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _caokit_exe() -> str:
|
|
485
|
+
"""Return the absolute path of the ``caokit`` executable, for a cron entry.
|
|
486
|
+
|
|
487
|
+
Raises:
|
|
488
|
+
ValueError: ``caokit`` is not on PATH and the running script is not a usable file.
|
|
489
|
+
"""
|
|
490
|
+
found = shutil.which("caokit")
|
|
491
|
+
if found:
|
|
492
|
+
return str(Path(found).absolute())
|
|
493
|
+
me = Path(sys.argv[0])
|
|
494
|
+
if me.is_file() and me.suffix != ".py" and os.access(me, os.X_OK):
|
|
495
|
+
return str(me.absolute())
|
|
496
|
+
raise ValueError("cannot find the caokit executable on PATH; install it with `uv tool install`")
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _set_worktrees(repo: Path, on: bool) -> int:
|
|
500
|
+
"""Handle ``tasks policy --worktrees on|off``; turning it on needs a usable git repo."""
|
|
501
|
+
if on:
|
|
502
|
+
errors, warnings = worktrees.problems(repo)
|
|
503
|
+
if errors:
|
|
504
|
+
print("error: cannot use worktrees:\n " + "\n ".join(errors), file=sys.stderr)
|
|
505
|
+
return 1
|
|
506
|
+
for w in warnings:
|
|
507
|
+
print(f"warning: {w}", file=sys.stderr)
|
|
508
|
+
print(f"Wrote {set_worktrees(repo, on)}")
|
|
509
|
+
print(
|
|
510
|
+
"worktrees: on. Reinstall the profiles (`caokit install`) so the roles carry the worktree "
|
|
511
|
+
"rules, then relaunch."
|
|
512
|
+
if on
|
|
513
|
+
else "worktrees: off"
|
|
514
|
+
)
|
|
515
|
+
return 0
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _tasks_retention(action: str, repo: Path, a: argparse.Namespace) -> int:
|
|
519
|
+
"""Handle ``tasks prune``, ``tasks policy`` and ``tasks schedule`` for one project.
|
|
520
|
+
|
|
521
|
+
Raises:
|
|
522
|
+
TaskError: The project has no tasks folder.
|
|
523
|
+
ConfigError: The project file is missing or invalid.
|
|
524
|
+
schedule.ScheduleError: The crontab could not be written.
|
|
525
|
+
"""
|
|
526
|
+
if action == "policy":
|
|
527
|
+
if a.worktrees is not None:
|
|
528
|
+
return _set_worktrees(repo, a.worktrees == "on")
|
|
529
|
+
if a.keep_archived_days is None:
|
|
530
|
+
ov = load_overrides(repo)
|
|
531
|
+
days = ov.keep_archived_days
|
|
532
|
+
print(f"keep_archived_days: {days if days else 'not set (archived tasks are kept)'}")
|
|
533
|
+
print(f"worktrees: {'on' if ov.worktrees else 'off'}")
|
|
534
|
+
return 0
|
|
535
|
+
print(f"Wrote {set_keep_archived_days(repo, a.keep_archived_days)}")
|
|
536
|
+
return 0
|
|
537
|
+
if action == "prune":
|
|
538
|
+
days = a.days
|
|
539
|
+
if days is not None and days < 1:
|
|
540
|
+
raise ValueError("--days must be a whole number of days, at least 1")
|
|
541
|
+
if days is None:
|
|
542
|
+
days = load_overrides(repo).keep_archived_days
|
|
543
|
+
if days is None:
|
|
544
|
+
print(
|
|
545
|
+
"error: no retention set. Pass --days N, or run "
|
|
546
|
+
"`caokit tasks policy --keep-archived-days N`.",
|
|
547
|
+
file=sys.stderr,
|
|
548
|
+
)
|
|
549
|
+
return 1
|
|
550
|
+
old = prune_archive(repo, days, apply=a.yes)
|
|
551
|
+
verb = "Deleted" if a.yes else "Would delete"
|
|
552
|
+
print(f"{verb} {len(old)} archived task(s) older than {days} day(s).")
|
|
553
|
+
for p in old:
|
|
554
|
+
print(f" {p.name}")
|
|
555
|
+
if old and not a.yes:
|
|
556
|
+
print("Dry run only. Rerun with --yes to delete them.")
|
|
557
|
+
return 0
|
|
558
|
+
# schedule
|
|
559
|
+
if a.remove:
|
|
560
|
+
removed = schedule.remove(repo)
|
|
561
|
+
print("Removed the cron entry." if removed else "No cron entry for this project.")
|
|
562
|
+
return 0
|
|
563
|
+
line = schedule.cron_line(_caokit_exe(), repo, a.at)
|
|
564
|
+
if a.install:
|
|
565
|
+
if a.keep_archived_days is None and load_overrides(repo).keep_archived_days is None:
|
|
566
|
+
print(
|
|
567
|
+
"error: set a retention first with `caokit tasks policy --keep-archived-days N`; "
|
|
568
|
+
"the scheduled prune fails without one.",
|
|
569
|
+
file=sys.stderr,
|
|
570
|
+
)
|
|
571
|
+
return 1
|
|
572
|
+
if a.keep_archived_days is not None: # the cron line carries no --days, so save it
|
|
573
|
+
print(f"Wrote {set_keep_archived_days(repo, a.keep_archived_days)}")
|
|
574
|
+
schedule.install(line, repo)
|
|
575
|
+
print("Installed daily cron entry:")
|
|
576
|
+
else:
|
|
577
|
+
print("Cron entry (add it with --install):")
|
|
578
|
+
print(" " + line)
|
|
579
|
+
return 0
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
583
|
+
"""Entry point for the ``caokit`` console script.
|
|
584
|
+
|
|
585
|
+
Args:
|
|
586
|
+
argv: Arguments without the program name; ``sys.argv[1:]`` if None.
|
|
587
|
+
|
|
588
|
+
Returns:
|
|
589
|
+
The process exit code of the chosen subcommand.
|
|
590
|
+
"""
|
|
591
|
+
p = argparse.ArgumentParser(prog="caokit", description="CAO chain-of-command agent company kit")
|
|
592
|
+
p.add_argument("--version", action="version", version="caokit " + __version__)
|
|
593
|
+
p.add_argument(
|
|
594
|
+
"--config",
|
|
595
|
+
type=Path,
|
|
596
|
+
default=None,
|
|
597
|
+
help=f"path to caokit.toml (default: {default_path()})",
|
|
598
|
+
)
|
|
599
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
600
|
+
sub.add_parser("doctor", help="check CLIs, CAO and config").set_defaults(fn=cmd_doctor)
|
|
601
|
+
co = sub.add_parser("company", help="manage the company roster").add_subparsers(
|
|
602
|
+
dest="sub", required=True
|
|
603
|
+
)
|
|
604
|
+
cc = co.add_parser("create", help="write caokit.toml")
|
|
605
|
+
for role in ROLES:
|
|
606
|
+
cc.add_argument("--" + role, metavar="TOOL[:MODEL]")
|
|
607
|
+
cc.add_argument("--allow-soft-engineer", action="store_true")
|
|
608
|
+
cc.add_argument("--allow-soft-general", action="store_true")
|
|
609
|
+
cc.add_argument("--allow-single-provider", action="store_true")
|
|
610
|
+
cc.add_argument("--force", action="store_true")
|
|
611
|
+
cc.set_defaults(fn=cmd_company_create)
|
|
612
|
+
ins = sub.add_parser("install", help="render and install CAO profiles")
|
|
613
|
+
ins.add_argument(
|
|
614
|
+
"--replace-legacy", action="store_true", help="back up stale CAO copies and retry"
|
|
615
|
+
)
|
|
616
|
+
ins.add_argument(
|
|
617
|
+
"--remove-legacy", action="store_true", help="move old pre-v1 profiles to a backup"
|
|
618
|
+
)
|
|
619
|
+
ins.add_argument(
|
|
620
|
+
"--project",
|
|
621
|
+
metavar="REPO",
|
|
622
|
+
help="install this project's namespaced profiles (needs .company/caokit.toml)",
|
|
623
|
+
)
|
|
624
|
+
ins.set_defaults(fn=cmd_install)
|
|
625
|
+
un = sub.add_parser("uninstall", help="remove the roster's profiles from CAO")
|
|
626
|
+
un.add_argument("--project", metavar="REPO", help="remove this project's namespaced profiles")
|
|
627
|
+
un.add_argument("--yes", action="store_true", help="do it (default is a dry run)")
|
|
628
|
+
un.set_defaults(fn=cmd_uninstall)
|
|
629
|
+
ini = sub.add_parser("init", help="scaffold .company/ in a git repo")
|
|
630
|
+
ini.add_argument("repo")
|
|
631
|
+
ini.add_argument("--name", metavar="PROJECT", help="project slug; namespaces its profiles")
|
|
632
|
+
ini.add_argument(
|
|
633
|
+
"--override",
|
|
634
|
+
action="append",
|
|
635
|
+
default=[],
|
|
636
|
+
metavar="ROLE=TOOL[:MODEL]",
|
|
637
|
+
help="override a role for this project (repeatable); ROLE=:MODEL keeps the tool",
|
|
638
|
+
)
|
|
639
|
+
ini.add_argument(
|
|
640
|
+
"--force-project", action="store_true", help="overwrite an existing .company/caokit.toml"
|
|
641
|
+
)
|
|
642
|
+
ini.set_defaults(fn=cmd_init)
|
|
643
|
+
tk = sub.add_parser("tasks", help="list open tasks and blockers, or archive finished ones")
|
|
644
|
+
tk.add_argument(
|
|
645
|
+
"action",
|
|
646
|
+
nargs="?",
|
|
647
|
+
default="list",
|
|
648
|
+
help="list (default), archive, prune, policy or schedule",
|
|
649
|
+
)
|
|
650
|
+
tk.add_argument("repo", nargs="?", default=".")
|
|
651
|
+
tk.add_argument("--all", action="store_true", help="list: include finished tasks")
|
|
652
|
+
tk.add_argument("--delete", action="store_true", help="archive: delete instead of moving")
|
|
653
|
+
tk.add_argument("--days", type=int, help="prune: keep archived tasks this many days")
|
|
654
|
+
tk.add_argument("--yes", action="store_true", help="prune: delete (default is a dry run)")
|
|
655
|
+
tk.add_argument(
|
|
656
|
+
"--keep-archived-days", type=int, metavar="N", help="policy: set the retention in days"
|
|
657
|
+
)
|
|
658
|
+
tk.add_argument(
|
|
659
|
+
"--worktrees",
|
|
660
|
+
choices=("on", "off"),
|
|
661
|
+
help="policy: give each source-changing task its own git worktree (CAO use_worktree)",
|
|
662
|
+
)
|
|
663
|
+
tk.add_argument("--install", action="store_true", help="schedule: add the daily cron entry")
|
|
664
|
+
tk.add_argument("--remove", action="store_true", help="schedule: remove the cron entry")
|
|
665
|
+
tk.add_argument("--at", default=schedule.DEFAULT_TIME, help="schedule: time of day, HH:MM")
|
|
666
|
+
tk.set_defaults(fn=cmd_tasks)
|
|
667
|
+
lc = sub.add_parser("launch", help="start the project's General in a CAO session")
|
|
668
|
+
lc.add_argument("repo", nargs="?", default=".")
|
|
669
|
+
lc.add_argument("--prompt-file", metavar="FILE", help="send this file as the first message")
|
|
670
|
+
lc.add_argument("--headless", action="store_true", help="start detached")
|
|
671
|
+
lc.add_argument("--auto-approve", action="store_true", help="skip CAO's launch confirmation")
|
|
672
|
+
lc.add_argument(
|
|
673
|
+
"--async",
|
|
674
|
+
dest="no_wait",
|
|
675
|
+
action="store_true",
|
|
676
|
+
help="with --headless and --prompt-file: return once the prompt is delivered "
|
|
677
|
+
"instead of waiting up to 300s for the reply",
|
|
678
|
+
)
|
|
679
|
+
lc.set_defaults(fn=cmd_launch)
|
|
680
|
+
wt = sub.add_parser("worktrees", help="list the task branches and worktrees CAO created")
|
|
681
|
+
wt.add_argument("repo", nargs="?", default=".")
|
|
682
|
+
wt.set_defaults(fn=cmd_worktrees)
|
|
683
|
+
st = sub.add_parser("status", help="show sessions and this project's workers")
|
|
684
|
+
st.add_argument("repo", nargs="?", default=".")
|
|
685
|
+
st.set_defaults(fn=cmd_status)
|
|
686
|
+
sd = sub.add_parser("send", help="send a message to the project's General")
|
|
687
|
+
sd.add_argument("message")
|
|
688
|
+
sd.add_argument("--repo", default=".")
|
|
689
|
+
sd.add_argument("--timeout", type=int, default=600)
|
|
690
|
+
sd.set_defaults(fn=cmd_send)
|
|
691
|
+
sp = sub.add_parser("stop", help="shut the project's session down")
|
|
692
|
+
sp.add_argument("repo", nargs="?", default=".")
|
|
693
|
+
sp.set_defaults(fn=cmd_stop)
|
|
694
|
+
rs = sub.add_parser("reset", help="back up CAO state, then reinstall CAO and the profiles")
|
|
695
|
+
rs.add_argument("--yes", action="store_true", help="do it (default is a dry run)")
|
|
696
|
+
rs.set_defaults(fn=cmd_reset)
|
|
697
|
+
sub.add_parser("server", help="start the CAO server").set_defaults(fn=cmd_server)
|
|
698
|
+
a = p.parse_args(argv)
|
|
699
|
+
code: int = a.fn(a) # each subcommand registered its handler via set_defaults(fn=...)
|
|
700
|
+
return code
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
if __name__ == "__main__":
|
|
704
|
+
sys.exit(main())
|