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/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
|