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.
Files changed (46) hide show
  1. caokit/__init__.py +3 -0
  2. caokit/adapters.py +259 -0
  3. caokit/cli.py +704 -0
  4. caokit/company.py +115 -0
  5. caokit/config.py +191 -0
  6. caokit/data/adapters/antigravity.toml +35 -0
  7. caokit/data/adapters/claude.toml +38 -0
  8. caokit/data/adapters/codex.toml +33 -0
  9. caokit/data/roles/engineer.md +90 -0
  10. caokit/data/roles/general.md +177 -0
  11. caokit/data/roles/inspector.md +55 -0
  12. caokit/data/roles/scout.md +77 -0
  13. caokit/data/scaffold/company/BACKLOG.md +5 -0
  14. caokit/data/scaffold/company/COMPANY.md +28 -0
  15. caokit/data/scaffold/company/DECISIONS.md +5 -0
  16. caokit/data/scaffold/company/README.md +16 -0
  17. caokit/data/scaffold/company/TEAM.md +7 -0
  18. caokit/data/scaffold/company/contracts/ARTIFACT_CONTRACT.md +44 -0
  19. caokit/data/scaffold/company/contracts/COMMUNICATION_CONTRACT.md +38 -0
  20. caokit/data/scaffold/company/contracts/COMPANY_CONTRACT.md +42 -0
  21. caokit/data/scaffold/company/contracts/REVIEW_CONTRACT.md +36 -0
  22. caokit/data/scaffold/company/contracts/SAFETY_CONTRACT.md +40 -0
  23. caokit/data/scaffold/company/contracts/STATE_CONTRACT.md +31 -0
  24. caokit/data/scaffold/company/contracts/TASK_CONTRACT.md +26 -0
  25. caokit/data/scaffold/company/prompts/first-run.md +18 -0
  26. caokit/data/scaffold/company/tasks/TASK-0001-smoke-test.md +109 -0
  27. caokit/data/scaffold/company/tasks/TASK_TEMPLATE.md +73 -0
  28. caokit/doctor.py +287 -0
  29. caokit/gitignore.py +36 -0
  30. caokit/init_project.py +183 -0
  31. caokit/install.py +242 -0
  32. caokit/launch.py +102 -0
  33. caokit/project.py +359 -0
  34. caokit/render.py +129 -0
  35. caokit/reset.py +208 -0
  36. caokit/roster.py +95 -0
  37. caokit/schedule.py +104 -0
  38. caokit/stack.py +117 -0
  39. caokit/tasks.py +180 -0
  40. caokit/worktrees.py +107 -0
  41. caokit-0.3.0.dev0.dist-info/METADATA +382 -0
  42. caokit-0.3.0.dev0.dist-info/RECORD +46 -0
  43. caokit-0.3.0.dev0.dist-info/WHEEL +5 -0
  44. caokit-0.3.0.dev0.dist-info/entry_points.txt +2 -0
  45. caokit-0.3.0.dev0.dist-info/licenses/LICENSE +21 -0
  46. caokit-0.3.0.dev0.dist-info/top_level.txt +1 -0
caokit/project.py ADDED
@@ -0,0 +1,359 @@
1
+ """Project-level configuration: per-project overrides layered on the user-level company.
2
+
3
+ The user-level ``caokit.toml`` (``~/.config/caokit``) defines the default roster. A
4
+ project may add ``<repo>/.company/caokit.toml`` with a project name and overrides for
5
+ individual roles. The *effective* config is the user-level one with those overrides
6
+ applied, and its profiles are named ``<project>_<role>-<tool>`` so projects never
7
+ overwrite each other in CAO's machine-global profile store.
8
+
9
+ Example project file::
10
+
11
+ schema = 1
12
+ [project]
13
+ name = "myapp"
14
+ [policy]
15
+ allow_soft_engineer = false
16
+ [roles.engineer]
17
+ model = "sonnet" # tool omitted: keep the user-level tool
18
+ """
19
+
20
+ import json
21
+ import tomllib
22
+ from collections.abc import Sequence
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from .adapters import ROLES, AdapterError, Runner, default_runner, load_adapter, validate_model
28
+ from .config import POLICY_KEYS, SCHEMA, Binding, Config, ConfigError, _only
29
+ from .render import validate_project_name
30
+ from .roster import check_roster
31
+
32
+ # Lives under .company/, which the managed .gitignore block ignores (except contracts/).
33
+ PROJECT_FILE = Path(".company") / "caokit.toml"
34
+ _POLICY_KEYS = POLICY_KEYS
35
+
36
+
37
+ @dataclass
38
+ class Overrides:
39
+ """A project's name and its per-role / policy overrides.
40
+
41
+ Attributes:
42
+ name: Project slug used to namespace profile names.
43
+ roles: For each overridden role, only the keys given: ``tool`` and/or ``model``.
44
+ policy: Policy switches the project sets explicitly.
45
+ keep_archived_days: Days an archived task file is kept before ``tasks prune``
46
+ deletes it; None keeps archived tasks forever.
47
+ worktrees: Give each source-changing task its own git worktree (CAO ``use_worktree``).
48
+ """
49
+
50
+ name: str
51
+ roles: dict[str, dict[str, str]] = field(default_factory=dict)
52
+ policy: dict[str, bool] = field(default_factory=dict)
53
+ keep_archived_days: int | None = None
54
+ worktrees: bool = False
55
+
56
+
57
+ def parse_overrides(d: dict[str, Any]) -> Overrides:
58
+ """Validate a parsed project ``caokit.toml`` and build :class:`Overrides`.
59
+
60
+ Args:
61
+ d: The dict produced by parsing the project file.
62
+
63
+ Returns:
64
+ The validated overrides.
65
+
66
+ Raises:
67
+ ConfigError: Wrong schema, missing/invalid project name, unknown key or role,
68
+ or a value of the wrong type.
69
+ """
70
+ _only(d, {"schema", "project", "policy", "roles", "tasks"}, "")
71
+ if d.get("schema") != SCHEMA:
72
+ raise ConfigError(f"schema must be {SCHEMA} (got {d.get('schema')!r})")
73
+ proj = d.get("project")
74
+ if not isinstance(proj, dict) or not isinstance(proj.get("name"), str):
75
+ raise ConfigError("project.name is required (a string)")
76
+ _only(proj, {"name"}, "project")
77
+ try:
78
+ name = validate_project_name(proj["name"])
79
+ except ValueError as e:
80
+ raise ConfigError(str(e)) from e
81
+ pol = d.get("policy", {})
82
+ _only(pol, _POLICY_KEYS, "policy")
83
+ for k, v in pol.items():
84
+ if not isinstance(v, bool):
85
+ raise ConfigError(f"policy.{k} must be true or false")
86
+ roles: dict[str, dict[str, str]] = {}
87
+ for role, o in d.get("roles", {}).items():
88
+ if role not in ROLES:
89
+ raise ConfigError(f"unknown role: roles.{role} (valid: {', '.join(ROLES)})")
90
+ _only(o, {"tool", "model"}, f"roles.{role}")
91
+ for k, v in o.items():
92
+ if not isinstance(v, str) or not v:
93
+ raise ConfigError(f"roles.{role}.{k} must be a non-empty string")
94
+ roles[role] = dict(o)
95
+ tasks = d.get("tasks", {})
96
+ _only(tasks, {"keep_archived_days", "worktrees"}, "tasks")
97
+ if not isinstance(tasks.get("worktrees", False), bool):
98
+ raise ConfigError("tasks.worktrees must be true or false")
99
+ days = tasks.get("keep_archived_days")
100
+ if days is not None and (isinstance(days, bool) or not isinstance(days, int) or days < 1):
101
+ raise ConfigError("tasks.keep_archived_days must be a whole number of days, at least 1")
102
+ return Overrides(
103
+ name=name,
104
+ roles=roles,
105
+ policy=dict(pol),
106
+ keep_archived_days=days,
107
+ worktrees=tasks.get("worktrees", False),
108
+ )
109
+
110
+
111
+ def load_overrides(repo: Path) -> Overrides:
112
+ """Read and validate ``<repo>/.company/caokit.toml``.
113
+
114
+ Args:
115
+ repo: Project root.
116
+
117
+ Returns:
118
+ The validated overrides.
119
+
120
+ Raises:
121
+ ConfigError: The file is missing, is not valid TOML, or fails validation.
122
+ """
123
+ path = Path(repo) / PROJECT_FILE
124
+ if not path.is_file():
125
+ raise ConfigError(
126
+ f"no project config at {path}; run `caokit init {repo} --name <project>` first"
127
+ )
128
+ try:
129
+ return parse_overrides(tomllib.loads(path.read_text(encoding="utf-8")))
130
+ except ValueError as e: # TOMLDecodeError subclasses ValueError
131
+ raise ConfigError(f"invalid TOML in {path}: {e}") from e
132
+
133
+
134
+ def worktrees_enabled(repo: Path) -> bool:
135
+ """Return True if the project turned per-task worktrees on (False without a project file).
136
+
137
+ Raises:
138
+ ConfigError: The project file exists but is invalid.
139
+ """
140
+ if not (Path(repo) / PROJECT_FILE).is_file():
141
+ return False
142
+ return load_overrides(repo).worktrees
143
+
144
+
145
+ def dump_overrides(ov: Overrides) -> str:
146
+ """Serialise overrides to TOML text.
147
+
148
+ Args:
149
+ ov: The overrides to write.
150
+
151
+ Returns:
152
+ TOML text that :func:`parse_overrides` round-trips.
153
+ """
154
+ q = json.dumps # JSON string escaping is valid TOML basic-string escaping
155
+ out = [f"schema = {SCHEMA}", "", "[project]", f"name = {q(ov.name)}"]
156
+ if ov.policy:
157
+ out += ["", "[policy]"] + [f"{k} = {str(v).lower()}" for k, v in ov.policy.items()]
158
+ for role in ROLES: # fixed order keeps the file stable across runs
159
+ if role in ov.roles:
160
+ out += ["", f"[roles.{role}]"] + [f"{k} = {q(v)}" for k, v in ov.roles[role].items()]
161
+ tasks = []
162
+ if ov.keep_archived_days is not None:
163
+ tasks.append(f"keep_archived_days = {ov.keep_archived_days}")
164
+ if ov.worktrees:
165
+ tasks.append("worktrees = true")
166
+ if tasks:
167
+ out += ["", "[tasks]", *tasks]
168
+ return "\n".join(out) + "\n"
169
+
170
+
171
+ def apply_overrides(base: Config, ov: Overrides) -> Config:
172
+ """Layer a project's overrides on the user-level config.
173
+
174
+ Merge rules, per role: a given ``tool``/``model`` replaces the base value. If the
175
+ tool changes and no model is given, the base model is dropped (a model ID from one
176
+ tool is meaningless for another), so the tool default applies until one is set.
177
+
178
+ Args:
179
+ base: The user-level configuration.
180
+ ov: The project's overrides.
181
+
182
+ Returns:
183
+ A new config named for the project; ``base`` is not modified.
184
+ """
185
+ roles = {r: Binding(b.tool, b.model) for r, b in base.roles.items()}
186
+ for role, o in ov.roles.items():
187
+ b = base.roles[role]
188
+ tool = o.get("tool", b.tool)
189
+ if "model" in o:
190
+ model: str | None = o["model"]
191
+ elif tool != b.tool:
192
+ model = None
193
+ else:
194
+ model = b.model
195
+ roles[role] = Binding(tool, model)
196
+ return Config(
197
+ roles=roles,
198
+ allow_soft_engineer=ov.policy.get("allow_soft_engineer", base.allow_soft_engineer),
199
+ allow_soft_general=ov.policy.get("allow_soft_general", base.allow_soft_general),
200
+ allow_single_provider=ov.policy.get("allow_single_provider", base.allow_single_provider),
201
+ expected_cao_version=base.expected_cao_version,
202
+ project=ov.name,
203
+ )
204
+
205
+
206
+ def parse_override_specs(specs: Sequence[str]) -> dict[str, dict[str, str]]:
207
+ """Parse ``--override`` CLI values into per-role overrides.
208
+
209
+ Accepted forms: ``role=tool``, ``role=tool:model`` and ``role=:model`` (model only,
210
+ keeping the user-level tool).
211
+
212
+ Args:
213
+ specs: Values such as ``engineer=claude:sonnet``.
214
+
215
+ Returns:
216
+ Mapping of role to the keys given for it.
217
+
218
+ Raises:
219
+ ConfigError: A spec is malformed or names an unknown role.
220
+ """
221
+ out: dict[str, dict[str, str]] = {}
222
+ for spec in specs:
223
+ role, sep, rest = spec.partition("=")
224
+ if not sep or role not in ROLES or not rest:
225
+ raise ConfigError(
226
+ f"bad --override {spec!r}: use role=tool[:model] or role=:model "
227
+ f"(roles: {', '.join(ROLES)})"
228
+ )
229
+ tool, _, model = rest.partition(":")
230
+ entry: dict[str, str] = {}
231
+ if tool:
232
+ entry["tool"] = tool
233
+ if model:
234
+ entry["model"] = model
235
+ out[role] = {**out.get(role, {}), **entry}
236
+ return out
237
+
238
+
239
+ def resolve_project(
240
+ repo: Path, base: Config, name: str | None, specs: Sequence[str]
241
+ ) -> tuple[Config, Overrides | None]:
242
+ """Work out the effective config for a project.
243
+
244
+ With ``name`` or ``specs``, build new overrides (to be written by the caller).
245
+ Otherwise load the project's existing file. With neither and no file, the project
246
+ is not namespaced and the user-level config applies unchanged.
247
+
248
+ Args:
249
+ repo: Project root.
250
+ base: The user-level configuration.
251
+ name: Project slug from ``--name``, if given.
252
+ specs: ``--override`` values, if any.
253
+
254
+ Returns:
255
+ ``(effective_config, new_overrides)``. ``new_overrides`` is None when nothing
256
+ new needs writing.
257
+
258
+ Raises:
259
+ ConfigError: Overrides without a name, bad specs, or an invalid project file.
260
+ """
261
+ if name or specs:
262
+ if not name:
263
+ raise ConfigError("--override needs --name <project> so the profiles are namespaced")
264
+ try:
265
+ validate_project_name(name)
266
+ except ValueError as e:
267
+ raise ConfigError(str(e)) from e
268
+ ov = Overrides(name=name, roles=parse_override_specs(specs))
269
+ return apply_overrides(base, ov), ov
270
+ if (Path(repo) / PROJECT_FILE).is_file():
271
+ return apply_overrides(base, load_overrides(repo)), None
272
+ return base, None
273
+
274
+
275
+ def effective_config(repo: Path, base: Config) -> Config:
276
+ """Return the project's effective config (requires an existing project file).
277
+
278
+ Args:
279
+ repo: Project root.
280
+ base: The user-level configuration.
281
+
282
+ Returns:
283
+ ``base`` with the project's overrides applied and its name set.
284
+
285
+ Raises:
286
+ ConfigError: The project file is missing or invalid.
287
+ """
288
+ return apply_overrides(base, load_overrides(repo))
289
+
290
+
291
+ def check_effective(
292
+ cfg: Config, ov: Overrides | None, runner: Runner = default_runner
293
+ ) -> tuple[list[str], list[str]]:
294
+ """Run the roster rules and validate overridden models against their tools.
295
+
296
+ Args:
297
+ cfg: The effective config.
298
+ ov: The overrides being applied, or None to check the roster rules only.
299
+ runner: Command runner used to ask tools for their model lists.
300
+
301
+ Returns:
302
+ ``(errors, warnings)``.
303
+ """
304
+ res = check_roster(cfg)
305
+ errors, warnings = list(res.errors), list(res.warnings)
306
+ for role in ov.roles if ov else {}:
307
+ b = cfg.roles[role]
308
+ if not b.model:
309
+ continue
310
+ try:
311
+ adapter = load_adapter(b.tool)
312
+ except AdapterError:
313
+ continue # unknown tool is already reported by the roster rules
314
+ e, w = validate_model(adapter, b.model, runner)
315
+ errors += [f"roles.{role}: {x}" for x in e]
316
+ warnings += [f"roles.{role}: {x}" for x in w]
317
+ return errors, warnings
318
+
319
+
320
+ def set_keep_archived_days(repo: Path, days: int) -> Path:
321
+ """Record how long archived tasks are kept in the project's ``caokit.toml``.
322
+
323
+ Args:
324
+ repo: Project root; it must already have a project file (``caokit init --name``).
325
+ days: Days to keep an archived task, at least 1.
326
+
327
+ Returns:
328
+ The path written.
329
+
330
+ Raises:
331
+ ConfigError: No project file, an invalid existing file, or ``days`` below 1.
332
+ """
333
+ if days < 1:
334
+ raise ConfigError("tasks.keep_archived_days must be a whole number of days, at least 1")
335
+ ov = load_overrides(repo)
336
+ ov.keep_archived_days = days
337
+ path = Path(repo) / PROJECT_FILE
338
+ path.write_text(dump_overrides(ov), encoding="utf-8")
339
+ return path
340
+
341
+
342
+ def set_worktrees(repo: Path, on: bool) -> Path:
343
+ """Turn per-task git worktrees on or off in the project's ``caokit.toml``.
344
+
345
+ Args:
346
+ repo: Project root; it must already have a project file (``caokit init --name``).
347
+ on: True to have the General dispatch each engineer into its own worktree.
348
+
349
+ Returns:
350
+ The path written.
351
+
352
+ Raises:
353
+ ConfigError: No project file or an invalid existing one.
354
+ """
355
+ ov = load_overrides(repo)
356
+ ov.worktrees = on
357
+ path = Path(repo) / PROJECT_FILE
358
+ path.write_text(dump_overrides(ov), encoding="utf-8")
359
+ return path
caokit/render.py ADDED
@@ -0,0 +1,129 @@
1
+ """Render concrete CAO profiles from a tool-agnostic role body and a tool adapter."""
2
+
3
+ import json
4
+ import re
5
+ from importlib import resources
6
+
7
+ from .adapters import Adapter
8
+
9
+ # Mirrors CAO 2.5.4 install_service._PROFILE_NAME_RE; dots are NOT allowed.
10
+ PROFILE_NAME_RE = r"^[A-Za-z0-9_-]{1,64}$"
11
+ # Role and tool IDs: lowercase alphanumerics only, so "-" in a name is an unambiguous separator.
12
+ _PART_RE = re.compile(r"^[a-z0-9]+$")
13
+
14
+
15
+ # Project slug: lowercase alphanumerics and "-", at most 24 chars. It must not contain "_" because
16
+ # "_" separates the project from "<role>-<tool>", which keeps the full name unambiguous, and the
17
+ # cap keeps "<project>_<role>-<tool>" well under CAO's 64-character limit.
18
+ PROJECT_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,23}$")
19
+
20
+
21
+ def validate_project_name(project: str) -> str:
22
+ """Check a project slug.
23
+
24
+ Args:
25
+ project: Candidate slug, e.g. ``myapp``.
26
+
27
+ Returns:
28
+ The slug unchanged.
29
+
30
+ Raises:
31
+ ValueError: If it is not 1-24 chars of lowercase letters, digits and ``-``.
32
+ """
33
+ if not PROJECT_RE.match(project):
34
+ raise ValueError(
35
+ f"invalid project name {project!r}: use 1-24 lowercase letters, digits or '-' "
36
+ "(start with a letter or digit)"
37
+ )
38
+ return project
39
+
40
+
41
+ def profile_name(role: str, tool: str, project: str | None = None) -> str:
42
+ """Build the concrete CAO profile name.
43
+
44
+ Shared profiles are ``<role>-<tool>``; project-scoped ones are
45
+ ``<project>_<role>-<tool>`` so projects never overwrite each other in CAO's
46
+ machine-global profile store.
47
+
48
+ Args:
49
+ role: Role ID, e.g. ``inspector``.
50
+ tool: Tool ID, e.g. ``antigravity``.
51
+ project: Project slug, or None for the shared (machine-wide) profile.
52
+
53
+ Returns:
54
+ The profile name, e.g. ``inspector-antigravity`` or ``myapp_inspector-antigravity``.
55
+
56
+ Raises:
57
+ ValueError: If a part is invalid or the result is not a valid CAO profile name.
58
+ """
59
+ for part in (role, tool):
60
+ if not _PART_RE.match(part):
61
+ raise ValueError(f"invalid name part {part!r}")
62
+ name = f"{role}-{tool}"
63
+ if project is not None:
64
+ name = f"{validate_project_name(project)}_{name}"
65
+ if not re.match(PROFILE_NAME_RE, name):
66
+ raise ValueError(f"invalid profile name {name!r}")
67
+ return name
68
+
69
+
70
+ def load_role(role: str) -> str:
71
+ """Read the tool-agnostic role file shipped in the package.
72
+
73
+ Args:
74
+ role: Role ID (validated so it cannot traverse out of ``data/roles``).
75
+
76
+ Returns:
77
+ The full markdown text, frontmatter included.
78
+
79
+ Raises:
80
+ ValueError: If ``role`` is not a valid role ID.
81
+ """
82
+ if not _PART_RE.match(role):
83
+ raise ValueError(f"invalid role {role!r}")
84
+ return (resources.files("caokit") / "data" / "roles" / (role + ".md")).read_text(
85
+ encoding="utf-8"
86
+ )
87
+
88
+
89
+ def render_profile(
90
+ role: str, adapter: Adapter, model: str | None, project: str | None = None
91
+ ) -> str:
92
+ """Combine a role body with a tool adapter into a complete CAO profile.
93
+
94
+ Output is deterministic, since CAO stores profiles byte for byte and the
95
+ installer verifies the stored copy against this text.
96
+
97
+ Args:
98
+ role: Role ID.
99
+ adapter: The tool adapter that fills the role.
100
+ model: Model ID to pin, or None to omit ``model:`` (tool default applies).
101
+ project: Project slug to namespace the profile name with, or None for shared.
102
+
103
+ Returns:
104
+ The profile text (frontmatter + body + optional "Tool notes" section).
105
+
106
+ Raises:
107
+ ValueError: If the role file has no frontmatter.
108
+ """
109
+ text = load_role(role)
110
+ m = re.match(r"^---\n(.*?)\n---\n(.*)$", text, re.S)
111
+ if not m:
112
+ raise ValueError(f"role {role} has no frontmatter")
113
+ fm_lines, body = m.group(1).split("\n"), m.group(2)
114
+ out: list[str] = ["---", "name: " + profile_name(role, adapter.id, project)]
115
+ for line in fm_lines:
116
+ out.append(line)
117
+ # Provider-specific fields are injected right after the CAO ``role:`` line.
118
+ if line.startswith("role:"):
119
+ out.append("provider: " + adapter.cao_provider)
120
+ if model:
121
+ out.append("model: " + json.dumps(model)) # quoted: IDs may contain dots/colons
122
+ for k, v in adapter.frontmatter.items():
123
+ out.append(f"{k}: {v}")
124
+ out.append("---")
125
+ result = "\n".join(out) + "\n" + body
126
+ if adapter.notes:
127
+ # Caveats (e.g. advisory-only limits) go at the end so the role body stays tool-agnostic.
128
+ result = result.rstrip("\n") + "\n\n## Tool notes\n\n" + adapter.notes + "\n"
129
+ return result
caokit/reset.py ADDED
@@ -0,0 +1,208 @@
1
+ """`caokit reset`: back up CAO's state, reinstall the pinned CAO, then reinstall profiles.
2
+
3
+ Nothing is deleted. CAO's state folder is moved and the Antigravity MCP config is copied
4
+ into a timestamped backup folder first. Without ``--yes`` the command only prints the plan.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ import tempfile
13
+ import time
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+
18
+ from .adapters import ProcessResult, Runner, default_runner
19
+ from .doctor import CAO_PKG
20
+
21
+
22
+ class ResetError(Exception):
23
+ """Raised when a reset step fails; the backup folder holds everything moved so far."""
24
+
25
+
26
+ @dataclass
27
+ class ResetResult:
28
+ """What a reset did.
29
+
30
+ Attributes:
31
+ backup: Folder holding the moved CAO state and the copied MCP config.
32
+ steps: One line per completed step.
33
+ """
34
+
35
+ backup: Path
36
+ steps: list[str] = field(default_factory=list)
37
+
38
+
39
+ def plan(version: str, cao_home: Path) -> list[str]:
40
+ """Return the steps a reset would perform, in order, for display."""
41
+ return [
42
+ "stop all CAO sessions, the cao-server process and cao-* tmux sessions",
43
+ f"move {cao_home} into the backup folder",
44
+ "remove CAO entries from the Antigravity MCP config (a copy is kept in the backup)",
45
+ f"uv tool uninstall {CAO_PKG}, then uv tool install {CAO_PKG}=={version}",
46
+ "cao init, then install the company profiles",
47
+ ]
48
+
49
+
50
+ def _drop_cao_servers(config: Path) -> list[str]:
51
+ """Remove CAO MCP servers from an Antigravity config file; return the names removed."""
52
+ try:
53
+ data = json.loads(config.read_text(encoding="utf-8"))
54
+ except (OSError, json.JSONDecodeError):
55
+ return []
56
+ servers = data.get("mcpServers") if isinstance(data, dict) else None
57
+ if not isinstance(servers, dict):
58
+ return []
59
+ drop = [
60
+ k
61
+ for k, v in servers.items()
62
+ if k == "cao" or k.startswith("cao-") or "cao-mcp-server" in json.dumps(v)
63
+ ]
64
+ for k in drop:
65
+ servers.pop(k)
66
+ if drop: # write beside the file, then swap, so a crash cannot leave half a config
67
+ fd, tmp = tempfile.mkstemp(dir=config.parent, prefix=config.name + ".")
68
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
69
+ f.write(json.dumps(data, indent=2))
70
+ os.replace(tmp, config)
71
+ return drop
72
+
73
+
74
+ def _unsafe_home(cao_home: Path) -> str | None:
75
+ """Return why ``cao_home`` must not be moved wholesale, or None if it is fine."""
76
+ if cao_home.is_symlink():
77
+ return f"{cao_home} is a symlink; moving it would move the link, not the data"
78
+ real, home = cao_home.resolve(), Path.home().resolve()
79
+ if real == home or real in home.parents or real.parent == real:
80
+ return f"{cao_home} is your home folder or above it; refusing to move it"
81
+ return None
82
+
83
+
84
+ def _new_backup(root: Path) -> Path:
85
+ """Create a fresh timestamped backup folder, never reusing one from the same second."""
86
+ stamp = "cao-reset-backup-" + time.strftime("%Y%m%d-%H%M%S")
87
+ for n in range(100):
88
+ path = root / (stamp if n == 0 else f"{stamp}-{n}")
89
+ try:
90
+ path.mkdir(parents=True)
91
+ except FileExistsError:
92
+ continue
93
+ return path
94
+ raise ResetError(f"could not create a backup folder under {root}")
95
+
96
+
97
+ def run_reset(
98
+ version: str,
99
+ cao_home: Path,
100
+ agy_mcp: Path,
101
+ backup_root: Path,
102
+ runner: Runner = default_runner,
103
+ install_profiles: Callable[[], None] = lambda: None,
104
+ slow_runner: Runner | None = None,
105
+ ) -> ResetResult:
106
+ """Back up and reinstall CAO.
107
+
108
+ Args:
109
+ version: CAO version to pin.
110
+ cao_home: CAO's state folder (moved, not deleted).
111
+ agy_mcp: Antigravity MCP config path (copied, then edited in place).
112
+ backup_root: Folder in which the timestamped backup folder is created.
113
+ runner: Command runner (injectable for tests).
114
+ install_profiles: Called last to reinstall the company's profiles.
115
+ slow_runner: Runner for the ``uv`` install (a cold download can exceed the default
116
+ runner's timeout); defaults to ``runner``.
117
+
118
+ Returns:
119
+ The backup folder and the steps taken.
120
+
121
+ Raises:
122
+ ResetError: ``cao_home`` is unsafe to move, a command failed or could not run, or the
123
+ installed version is not ``version``. Nothing is moved before the checks pass.
124
+ """
125
+ why = _unsafe_home(cao_home)
126
+ if why:
127
+ raise ResetError(why)
128
+ backup = _new_backup(backup_root)
129
+ res = ResetResult(backup)
130
+ slow = slow_runner or runner
131
+ try:
132
+ _reset_steps(version, cao_home, agy_mcp, backup, res, runner, slow, install_profiles)
133
+ except (OSError, subprocess.TimeoutExpired) as e:
134
+ raise ResetError(f"{e} (backup: {backup})") from e
135
+ return res
136
+
137
+
138
+ def _reset_steps(
139
+ version: str,
140
+ cao_home: Path,
141
+ agy_mcp: Path,
142
+ backup: Path,
143
+ res: ResetResult,
144
+ runner: Runner,
145
+ slow: Runner,
146
+ install_profiles: Callable[[], None],
147
+ ) -> None:
148
+ def must(run: Runner, argv: list[str], what: str) -> str:
149
+ r = run(argv)
150
+ if r.returncode != 0:
151
+ raise ResetError(f"{what} failed: {(r.stderr or r.stdout).strip()} (backup: {backup})")
152
+ return r.stdout or ""
153
+
154
+ def best_effort(argv: list[str]) -> ProcessResult | None:
155
+ try:
156
+ return runner(argv)
157
+ except (OSError, subprocess.TimeoutExpired): # tool missing or hung: nothing to stop
158
+ return None
159
+
160
+ best_effort(["cao", "shutdown", "--all"]) # the server may already be down
161
+ for pid in _cao_server_pids(runner):
162
+ best_effort(["kill", pid])
163
+ listed = best_effort(["tmux", "list-sessions", "-F", "#{session_name}"])
164
+ for name in ((listed.stdout if listed else "") or "").split():
165
+ if name.startswith("cao-"):
166
+ best_effort(["tmux", "kill-session", "-t", name])
167
+ res.steps.append("stopped sessions and server")
168
+ if cao_home.is_dir():
169
+ shutil.move(str(cao_home), str(backup / "cli-agent-orchestrator"))
170
+ res.steps.append(f"moved {cao_home} to {backup}")
171
+ if agy_mcp.is_file():
172
+ shutil.copy2(agy_mcp, backup / "gemini-mcp_config.json")
173
+ removed = _drop_cao_servers(agy_mcp)
174
+ res.steps.append("removed from MCP config: " + (", ".join(removed) or "nothing"))
175
+ best_effort(["uv", "tool", "uninstall", CAO_PKG]) # fine if it was not installed
176
+ must(slow, ["uv", "tool", "install", f"{CAO_PKG}=={version}"], "install of CAO")
177
+ if version not in re.findall(
178
+ r"\d+(?:\.\d+)*", must(runner, ["cao", "--version"], "cao --version")
179
+ ):
180
+ raise ResetError(f"cao is not {version} after install (backup: {backup})")
181
+ must(runner, ["cao", "init"], "cao init")
182
+ res.steps.append(f"installed CAO {version}")
183
+ try:
184
+ install_profiles()
185
+ except ResetError as e:
186
+ raise ResetError(f"{e} (backup: {backup})") from e
187
+ res.steps.append("installed company profiles")
188
+
189
+
190
+ def _cao_server_pids(runner: Runner) -> list[str]:
191
+ """Return PIDs of real ``cao-server`` processes (argv0, or argv1 after a python), not greps."""
192
+ try:
193
+ r = runner(["ps", "-axo", "pid=,command="])
194
+ except (OSError, subprocess.TimeoutExpired):
195
+ return []
196
+ pids: list[str] = []
197
+ for line in (r.stdout or "").splitlines():
198
+ parts = line.split()
199
+ if len(parts) < 2:
200
+ continue
201
+ a1 = parts[1]
202
+ a2 = parts[2] if len(parts) > 2 else ""
203
+ is_server = a1.endswith("cao-server") or (
204
+ "python" in a1.lower() and a2.endswith("cao-server")
205
+ )
206
+ if is_server:
207
+ pids.append(parts[0])
208
+ return pids