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/init_project.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""`caokit init <repo>`: scaffold .company/, write ROSTER.md and merge .gitignore."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from importlib import resources
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from .adapters import ROLES, Runner, default_runner, load_adapter
|
|
10
|
+
from .config import Config
|
|
11
|
+
from .gitignore import BEGIN, merge_gitignore
|
|
12
|
+
from .project import PROJECT_FILE, Overrides, dump_overrides
|
|
13
|
+
from .render import profile_name
|
|
14
|
+
from .stack import Suggestion, suggest_validation
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING: # typing-only: importlib.abc.Traversable is not importable at runtime on 3.14+
|
|
17
|
+
from importlib.abc import Traversable
|
|
18
|
+
|
|
19
|
+
# Directories agents write into. Created even though git does not track empty
|
|
20
|
+
# directories, because the whole of .company/ except contracts/ is ignored anyway.
|
|
21
|
+
EMPTY_DIRS = ["artifacts", "decisions", "handoffs", "research", "reviews", "tasks"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InitError(Exception):
|
|
25
|
+
"""Raised when a project cannot be initialised (not a git repo, bad path)."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class InitResult:
|
|
30
|
+
"""What ``init_project`` did, for the CLI to report."""
|
|
31
|
+
|
|
32
|
+
created: list[str] = field(default_factory=list) # scaffold files newly written
|
|
33
|
+
kept: list[str] = field(default_factory=list) # existing files left untouched
|
|
34
|
+
warnings: list[str] = field(default_factory=list)
|
|
35
|
+
suggestion: Suggestion | None = None # detected validation command, if any
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def render_roster_md(cfg: Config, suggestion: Suggestion | None) -> str:
|
|
39
|
+
"""Render ``.company/ROSTER.md``, the file the General reads to dispatch.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
cfg: The company configuration; one table row per role.
|
|
43
|
+
suggestion: Detected validation command, or None when nothing was detected.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
The Markdown text, ending in a newline.
|
|
47
|
+
"""
|
|
48
|
+
rows = []
|
|
49
|
+
for role in ROLES:
|
|
50
|
+
b = cfg.roles[role]
|
|
51
|
+
a = load_adapter(b.tool)
|
|
52
|
+
model = b.model or "(tool default)"
|
|
53
|
+
rows.append(
|
|
54
|
+
f"| {role} | `{profile_name(role, b.tool, cfg.project)}` | {b.tool} | {model} "
|
|
55
|
+
f"| {a.enforcement} |"
|
|
56
|
+
)
|
|
57
|
+
lines = [
|
|
58
|
+
"# Roster",
|
|
59
|
+
"",
|
|
60
|
+
f"Generated by `caokit init` for project `{cfg.project}` from the user-level "
|
|
61
|
+
"`caokit.toml` plus `.company/caokit.toml` overrides. Regenerate; do not hand-edit."
|
|
62
|
+
if cfg.project
|
|
63
|
+
else "Generated by `caokit init` from `caokit.toml`. Regenerate; do not hand-edit.",
|
|
64
|
+
"",
|
|
65
|
+
"Dispatch **only** to the profiles listed here, using the name verbatim. "
|
|
66
|
+
"Never guess a profile name.",
|
|
67
|
+
"",
|
|
68
|
+
"| Role | CAO profile | Tool | Model | Enforcement |",
|
|
69
|
+
"|---|---|---|---|---|",
|
|
70
|
+
*rows,
|
|
71
|
+
"",
|
|
72
|
+
"`soft` enforcement means the tool's limits are advisory only.",
|
|
73
|
+
"",
|
|
74
|
+
"## Suggested validation command (unconfirmed)",
|
|
75
|
+
"",
|
|
76
|
+
]
|
|
77
|
+
if suggestion is None:
|
|
78
|
+
lines.append(
|
|
79
|
+
"None detected. The human must supply the authorised validation command "
|
|
80
|
+
"at task approval."
|
|
81
|
+
)
|
|
82
|
+
else:
|
|
83
|
+
if suggestion.command: # CI-only hints carry a reason but no command
|
|
84
|
+
lines += [f"`{suggestion.command}`", ""]
|
|
85
|
+
lines.append(f"Detected because: {suggestion.reason}.")
|
|
86
|
+
if suggestion.note:
|
|
87
|
+
lines += ["", f"Note: {suggestion.note}"]
|
|
88
|
+
lines += [
|
|
89
|
+
"",
|
|
90
|
+
"The human must confirm this at task approval; "
|
|
91
|
+
"never treat it as authorised until then.",
|
|
92
|
+
]
|
|
93
|
+
return "\n".join(lines) + "\n"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _walk(node: "Traversable", rel: str = "") -> Iterator[tuple[str, "Traversable"]]:
|
|
97
|
+
"""Yield ``(relative/posix/path, file)`` for every file under ``node``, sorted."""
|
|
98
|
+
for child in sorted(node.iterdir(), key=lambda p: p.name):
|
|
99
|
+
r = rel + child.name
|
|
100
|
+
if child.is_dir():
|
|
101
|
+
yield from _walk(child, r + "/")
|
|
102
|
+
else:
|
|
103
|
+
yield r, child
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _ignores_whole_company(text: str) -> bool:
|
|
107
|
+
"""Return True if the user's own rules ignore all of ``.company/``.
|
|
108
|
+
|
|
109
|
+
Only text before our managed block counts; our block deliberately ignores
|
|
110
|
+
``.company/*`` while re-including ``contracts/``.
|
|
111
|
+
"""
|
|
112
|
+
before = text.split(BEGIN)[0]
|
|
113
|
+
return any(
|
|
114
|
+
line.strip() in (".company", ".company/", "/.company", "/.company/")
|
|
115
|
+
for line in before.splitlines()
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def init_project(
|
|
120
|
+
repo: Path,
|
|
121
|
+
cfg: Config,
|
|
122
|
+
runner: Runner = default_runner,
|
|
123
|
+
overrides: Overrides | None = None,
|
|
124
|
+
force_project: bool = False,
|
|
125
|
+
) -> InitResult:
|
|
126
|
+
"""Scaffold ``.company/`` in a git repo, write ROSTER.md and merge .gitignore.
|
|
127
|
+
|
|
128
|
+
Existing scaffold files are never overwritten, so re-running is safe.
|
|
129
|
+
ROSTER.md is the exception: it is generated output and is rewritten.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
repo: Root of the target git repository.
|
|
133
|
+
cfg: The company configuration used to render ROSTER.md.
|
|
134
|
+
runner: Command runner used by stack detection (injectable for tests).
|
|
135
|
+
overrides: New project overrides to save as ``.company/caokit.toml``, or None
|
|
136
|
+
when the project file already exists or the project is not namespaced.
|
|
137
|
+
force_project: Overwrite an existing ``.company/caokit.toml``.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
A summary of created and kept files, warnings and the detected
|
|
141
|
+
validation command.
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
InitError: ``repo`` is not a directory or has no ``.git``, or a project file
|
|
145
|
+
exists and ``force_project`` is not set.
|
|
146
|
+
"""
|
|
147
|
+
repo = Path(repo).resolve()
|
|
148
|
+
if not repo.is_dir():
|
|
149
|
+
raise InitError(f"not a directory: {repo}")
|
|
150
|
+
# `.git` may be a file (worktrees, submodules), so test existence, not is_dir.
|
|
151
|
+
if not (repo / ".git").exists():
|
|
152
|
+
raise InitError(f"{repo} is not a git repository root (no .git); run `git init` first")
|
|
153
|
+
project_file = repo / PROJECT_FILE
|
|
154
|
+
if overrides is not None and project_file.exists() and not force_project:
|
|
155
|
+
raise InitError(f"{project_file} already exists; edit it, or pass --force-project")
|
|
156
|
+
res = InitResult()
|
|
157
|
+
company = repo / ".company"
|
|
158
|
+
root = resources.files("caokit") / "data" / "scaffold" / "company"
|
|
159
|
+
for rel, src in _walk(root):
|
|
160
|
+
dest = company / rel
|
|
161
|
+
if dest.exists():
|
|
162
|
+
res.kept.append(".company/" + rel) # never clobber the project's own edits
|
|
163
|
+
continue
|
|
164
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
dest.write_bytes(src.read_bytes())
|
|
166
|
+
res.created.append(".company/" + rel)
|
|
167
|
+
for d in EMPTY_DIRS:
|
|
168
|
+
(company / d).mkdir(parents=True, exist_ok=True)
|
|
169
|
+
if overrides is not None:
|
|
170
|
+
project_file.write_text(dump_overrides(overrides), encoding="utf-8")
|
|
171
|
+
res.created.append(str(PROJECT_FILE))
|
|
172
|
+
res.suggestion = suggest_validation(repo, runner=runner)
|
|
173
|
+
(company / "ROSTER.md").write_text(render_roster_md(cfg, res.suggestion), encoding="utf-8")
|
|
174
|
+
gi = repo / ".gitignore"
|
|
175
|
+
existing = gi.read_text(encoding="utf-8") if gi.exists() else ""
|
|
176
|
+
if _ignores_whole_company(existing):
|
|
177
|
+
res.warnings.append(
|
|
178
|
+
".gitignore already ignores all of .company/; git cannot re-include "
|
|
179
|
+
".company/contracts/ under an ignored parent. Remove that line so "
|
|
180
|
+
"contracts are tracked."
|
|
181
|
+
)
|
|
182
|
+
gi.write_text(merge_gitignore(existing), encoding="utf-8")
|
|
183
|
+
return res
|
caokit/install.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Render profiles from caokit.toml and install them into CAO."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import tempfile
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .adapters import ROLES, ProcessResult, Runner, default_runner, load_adapter
|
|
12
|
+
from .config import Config
|
|
13
|
+
from .render import profile_name, render_profile
|
|
14
|
+
from .roster import check_roster
|
|
15
|
+
|
|
16
|
+
# Profile names used by the pre-v1 bash kit. They stay in CAO's global store
|
|
17
|
+
# after migrating, so `install` offers to move them away.
|
|
18
|
+
LEGACY_NAMES = [
|
|
19
|
+
"startup_captain",
|
|
20
|
+
"claude_engineer",
|
|
21
|
+
"claude_reviewer",
|
|
22
|
+
"agy_researcher",
|
|
23
|
+
"agy_reviewer",
|
|
24
|
+
"codex_engineer",
|
|
25
|
+
"codex_reviewer",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class InstallError(Exception):
|
|
30
|
+
"""Raised when profiles cannot even be attempted (for example an invalid roster)."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class InstallResult:
|
|
35
|
+
"""Per-profile outcome of an install run."""
|
|
36
|
+
|
|
37
|
+
installed: list[str] = field(default_factory=list) # installed and verified
|
|
38
|
+
failed: list[str] = field(default_factory=list) # validate, install or verify failed
|
|
39
|
+
backup_dir: Path | None = None # set when stale copies were moved aside
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def cao_home() -> Path:
|
|
43
|
+
"""Return CAO's home directory.
|
|
44
|
+
|
|
45
|
+
Honors ``$CAO_HOME_DIR``, else ``~/.aws/cli-agent-orchestrator``. Because
|
|
46
|
+
``~`` follows ``$HOME``, tests can redirect everything with a temp HOME.
|
|
47
|
+
"""
|
|
48
|
+
env = os.environ.get("CAO_HOME_DIR")
|
|
49
|
+
return Path(env) if env else Path.home() / ".aws" / "cli-agent-orchestrator"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def render_all(cfg: Config) -> dict[str, str]:
|
|
53
|
+
"""Render every roster profile.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
cfg: The company configuration.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Mapping of concrete profile name to full profile text. Names are
|
|
60
|
+
``<role>-<tool>``, or ``<project>_<role>-<tool>`` for a project-scoped config.
|
|
61
|
+
"""
|
|
62
|
+
out = {}
|
|
63
|
+
for role in ROLES:
|
|
64
|
+
b = cfg.roles[role]
|
|
65
|
+
name = profile_name(role, b.tool, cfg.project)
|
|
66
|
+
out[name] = render_profile(role, load_adapter(b.tool), b.model, cfg.project)
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def find_legacy(home: Path) -> list[str]:
|
|
71
|
+
"""Return the legacy profile names that still have a copy under ``home``."""
|
|
72
|
+
return [
|
|
73
|
+
n
|
|
74
|
+
for n in LEGACY_NAMES
|
|
75
|
+
if (home / "agent-store" / (n + ".md")).exists()
|
|
76
|
+
or (home / "agent-context" / (n + ".md")).exists()
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _backup(home: Path, names: list[str], backup: Path | None) -> Path | None:
|
|
81
|
+
"""Move installed copies of ``names`` into a backup folder under ``home``.
|
|
82
|
+
|
|
83
|
+
Files are moved, never deleted, so a wrong guess is recoverable by hand.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
home: CAO home directory.
|
|
87
|
+
names: Profile names whose ``agent-context`` and ``agent-store`` copies to move.
|
|
88
|
+
backup: Existing backup folder to reuse so one run produces one folder,
|
|
89
|
+
or None to create a timestamped one on first move.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
The backup folder (None if nothing was moved and none was supplied).
|
|
93
|
+
"""
|
|
94
|
+
for n in names:
|
|
95
|
+
for sub in ("agent-context", "agent-store"):
|
|
96
|
+
src = home / sub / (n + ".md")
|
|
97
|
+
if src.exists():
|
|
98
|
+
if backup is None: # created lazily so a no-op leaves no empty folder
|
|
99
|
+
backup = home / ("caokit-backup-" + time.strftime("%Y%m%d%H%M%S"))
|
|
100
|
+
(backup / sub).mkdir(parents=True, exist_ok=True)
|
|
101
|
+
shutil.move(str(src), str(backup / sub / (n + ".md")))
|
|
102
|
+
return backup
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def remove_legacy(home: Path, names: list[str]) -> Path | None:
|
|
106
|
+
"""Move legacy profile copies to a backup folder (nothing is deleted).
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
home: CAO home directory.
|
|
110
|
+
names: Legacy profile names, as returned by :func:`find_legacy`.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
The backup folder, or None if there was nothing to move.
|
|
114
|
+
"""
|
|
115
|
+
return _backup(home, names, None)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _failed_output(r: ProcessResult) -> bool:
|
|
119
|
+
"""Return True if a ``cao`` call failed.
|
|
120
|
+
|
|
121
|
+
CAO 2.5.x prints ``Error: ...`` but exits 0 when it refuses to overwrite a
|
|
122
|
+
profile copy from an older CAO, so the exit code alone is not trustworthy.
|
|
123
|
+
"""
|
|
124
|
+
return r.returncode != 0 or "Error:" in ((r.stdout or "") + (r.stderr or ""))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def install_profiles(
|
|
128
|
+
cfg: Config,
|
|
129
|
+
home: Path | None = None,
|
|
130
|
+
runner: Runner = default_runner,
|
|
131
|
+
replace_legacy: bool = False,
|
|
132
|
+
out: Callable[[str], None] = print,
|
|
133
|
+
) -> InstallResult:
|
|
134
|
+
"""Render, validate, install and verify every profile in the roster.
|
|
135
|
+
|
|
136
|
+
Each profile is verified by comparing CAO's stored copy byte for byte with
|
|
137
|
+
what was rendered, because CAO stores profiles verbatim and a mismatch means
|
|
138
|
+
a stale or altered copy.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
cfg: The company configuration.
|
|
142
|
+
home: CAO home directory (default: :func:`cao_home`).
|
|
143
|
+
runner: Command runner used to call ``cao`` (injectable for tests).
|
|
144
|
+
replace_legacy: On a refused install, move the stale copies to a
|
|
145
|
+
backup folder and retry once.
|
|
146
|
+
out: Sink for progress and failure messages.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Which profiles were installed and which failed.
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
InstallError: The roster violates a guardrail; nothing is attempted.
|
|
153
|
+
"""
|
|
154
|
+
home = home or cao_home()
|
|
155
|
+
roster = check_roster(cfg)
|
|
156
|
+
if roster.errors:
|
|
157
|
+
raise InstallError("roster is invalid:\n " + "\n ".join(roster.errors))
|
|
158
|
+
profiles = render_all(cfg)
|
|
159
|
+
res = InstallResult()
|
|
160
|
+
with tempfile.TemporaryDirectory(prefix="caokit-") as stage:
|
|
161
|
+
for name, text in profiles.items():
|
|
162
|
+
# `cao install <file>` names the profile after the file stem, so the
|
|
163
|
+
# staged filename must be exactly the concrete profile name.
|
|
164
|
+
path = Path(stage) / (name + ".md")
|
|
165
|
+
path.write_text(text, encoding="utf-8")
|
|
166
|
+
v = runner(["cao", "profile", "validate", str(path)])
|
|
167
|
+
if v.returncode != 0: # validate exits 1 on schema errors; warnings exit 0
|
|
168
|
+
out(f"validate FAILED for {name}:\n{(v.stdout or '') + (v.stderr or '')}")
|
|
169
|
+
res.failed.append(name)
|
|
170
|
+
continue
|
|
171
|
+
r = runner(["cao", "install", str(path)])
|
|
172
|
+
if _failed_output(r) and replace_legacy:
|
|
173
|
+
res.backup_dir = _backup(home, [name], res.backup_dir)
|
|
174
|
+
out(f" moved stale copies of {name} to {res.backup_dir}; retrying")
|
|
175
|
+
r = runner(["cao", "install", str(path)])
|
|
176
|
+
if _failed_output(r):
|
|
177
|
+
detail = ((r.stdout or "") + (r.stderr or "")).strip()
|
|
178
|
+
out(f"install FAILED for {name}:\n{detail}")
|
|
179
|
+
res.failed.append(name)
|
|
180
|
+
continue
|
|
181
|
+
stored = home / "agent-store" / (name + ".md")
|
|
182
|
+
if not stored.is_file() or stored.read_bytes() != text.encode("utf-8"):
|
|
183
|
+
out(
|
|
184
|
+
f"install verification FAILED for {name}: "
|
|
185
|
+
"stored copy differs from rendered profile"
|
|
186
|
+
)
|
|
187
|
+
res.failed.append(name)
|
|
188
|
+
continue
|
|
189
|
+
out(f" installed {name} (verified byte for byte)")
|
|
190
|
+
res.installed.append(name)
|
|
191
|
+
return res
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass
|
|
195
|
+
class UninstallResult:
|
|
196
|
+
"""Per-profile outcome of an uninstall run."""
|
|
197
|
+
|
|
198
|
+
removed: list[str] = field(default_factory=list)
|
|
199
|
+
missing: list[str] = field(default_factory=list) # no copy anywhere in CAO's home
|
|
200
|
+
failed: list[str] = field(default_factory=list)
|
|
201
|
+
backup: Path | None = None # where leftover ``agent-context`` copies were moved
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def uninstall_profiles(
|
|
205
|
+
cfg: Config,
|
|
206
|
+
home: Path | None = None,
|
|
207
|
+
runner: Runner = default_runner,
|
|
208
|
+
apply: bool = False,
|
|
209
|
+
) -> UninstallResult:
|
|
210
|
+
"""Remove the roster's profiles from CAO.
|
|
211
|
+
|
|
212
|
+
``cao profile remove`` deletes only the ``agent-store`` copy. CAO also keeps an
|
|
213
|
+
``agent-context`` copy that keeps the profile listed as installed, so that copy is
|
|
214
|
+
moved to a backup folder (never deleted). Only the names this config renders are
|
|
215
|
+
touched, so one project's ``<project>_*`` profiles go and no other project's do.
|
|
216
|
+
Reinstalling restores them.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
cfg: The configuration whose profiles to remove.
|
|
220
|
+
home: CAO home directory (default :func:`cao_home`).
|
|
221
|
+
runner: Command runner (injectable for tests).
|
|
222
|
+
apply: Actually remove. When False nothing is run and every profile with a copy
|
|
223
|
+
is reported as ``removed``, so the caller can show what would happen.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
Which profiles were (or would be) removed, were not installed, or failed.
|
|
227
|
+
"""
|
|
228
|
+
home = home or cao_home()
|
|
229
|
+
res = UninstallResult()
|
|
230
|
+
for name in render_all(cfg):
|
|
231
|
+
in_store = (home / "agent-store" / (name + ".md")).exists()
|
|
232
|
+
in_context = (home / "agent-context" / (name + ".md")).exists()
|
|
233
|
+
if not (in_store or in_context):
|
|
234
|
+
res.missing.append(name)
|
|
235
|
+
elif not apply:
|
|
236
|
+
res.removed.append(name)
|
|
237
|
+
elif in_store and _failed_output(runner(["cao", "profile", "remove", name, "--yes"])):
|
|
238
|
+
res.failed.append(name)
|
|
239
|
+
else:
|
|
240
|
+
res.backup = _backup(home, [name], res.backup)
|
|
241
|
+
res.removed.append(name)
|
|
242
|
+
return res
|
caokit/launch.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Build the ``cao`` argv for launching, inspecting, messaging and stopping a company session.
|
|
2
|
+
|
|
3
|
+
Every command is a pure function from arguments to an argv list, so tests can check exactly
|
|
4
|
+
what would run. The CLI layer is the only caller that executes them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
SESSION_PREFIX = "cao-" # CAO prepends this to the --session-name it is given
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def session_name(project: Path, slug: str | None) -> str:
|
|
15
|
+
"""Pick the CAO session name for a project.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
project: Project directory.
|
|
19
|
+
slug: The project's namespace slug if it has one.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
``slug`` if given, else the directory name reduced to lowercase letters, digits and
|
|
23
|
+
``-`` plus a short hash of the full path, so two checkouts that share a directory
|
|
24
|
+
name (``a/app`` and ``b/app``) still get different sessions.
|
|
25
|
+
"""
|
|
26
|
+
if slug:
|
|
27
|
+
return slug
|
|
28
|
+
root = Path(project).resolve()
|
|
29
|
+
name = re.sub(r"[^a-z0-9]+", "-", root.name.lower()).strip("-")[:40].strip("-") or "company"
|
|
30
|
+
return f"{name}-{hashlib.sha1(str(root).encode()).hexdigest()[:6]}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def session_id(name: str) -> str:
|
|
34
|
+
"""Return the ID CAO gives a session started with ``--session-name name``.
|
|
35
|
+
|
|
36
|
+
CAO adds its prefix only when the name does not already start with it, so a project
|
|
37
|
+
called ``cao-foo`` is the session ``cao-foo``, not ``cao-cao-foo``.
|
|
38
|
+
"""
|
|
39
|
+
return name if name.startswith(SESSION_PREFIX) else SESSION_PREFIX + name
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_launch_argv(
|
|
43
|
+
project: Path,
|
|
44
|
+
profile: str,
|
|
45
|
+
session: str | None = None,
|
|
46
|
+
prompt: str | None = None,
|
|
47
|
+
headless: bool = False,
|
|
48
|
+
auto_approve: bool = False,
|
|
49
|
+
wait: bool = True,
|
|
50
|
+
) -> list[str]:
|
|
51
|
+
"""Build the argv that launches a CAO session for ``profile`` in ``project``.
|
|
52
|
+
|
|
53
|
+
The working directory is always explicit and absolute: without it CAO uses
|
|
54
|
+
the caller's cwd, which once led the General to start in the kit repo
|
|
55
|
+
instead of the project.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
project: Project directory the session must run in (relative paths are resolved).
|
|
59
|
+
profile: Concrete CAO profile name, e.g. ``general-claude``.
|
|
60
|
+
session: Optional CAO session name.
|
|
61
|
+
prompt: Optional first message for the General.
|
|
62
|
+
headless: Start detached instead of attaching to the terminal.
|
|
63
|
+
auto_approve: Skip CAO's launch confirmation. Never grants extra tool access.
|
|
64
|
+
wait: With ``headless`` and a prompt, wait up to 300s for the General's reply. False
|
|
65
|
+
passes ``--async``: return once the prompt is delivered.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The argv list, starting with ``cao launch``. Never includes ``--yolo``.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
ValueError: If ``project`` is not an existing directory.
|
|
72
|
+
"""
|
|
73
|
+
root = Path(project).resolve()
|
|
74
|
+
if not root.is_dir():
|
|
75
|
+
raise ValueError(f"project directory does not exist: {root}")
|
|
76
|
+
argv = ["cao", "launch", "--agents", profile, "--working-directory", str(root)]
|
|
77
|
+
if session:
|
|
78
|
+
argv += ["--session-name", session]
|
|
79
|
+
if headless:
|
|
80
|
+
argv.append("--headless")
|
|
81
|
+
if auto_approve:
|
|
82
|
+
argv.append("--auto-approve")
|
|
83
|
+
if not wait:
|
|
84
|
+
argv.append("--async")
|
|
85
|
+
if prompt:
|
|
86
|
+
argv += ["--", prompt] # "--" so a prompt starting with "-" is not read as an option
|
|
87
|
+
return argv
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def build_status_argv(session: str) -> list[list[str]]:
|
|
91
|
+
"""Build the commands that show every session and one session's workers."""
|
|
92
|
+
return [["cao", "session", "list"], ["cao", "session", "status", session, "--workers"]]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def build_send_argv(session: str, message: str, timeout: int = 600) -> list[str]:
|
|
96
|
+
"""Build the command that sends ``message`` to a session's General."""
|
|
97
|
+
return ["cao", "session", "send", "--timeout", str(timeout), "--", session, message]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def build_stop_argv(session: str) -> list[str]:
|
|
101
|
+
"""Build the command that shuts one session down. It asks no confirmation."""
|
|
102
|
+
return ["cao", "shutdown", "--session", session]
|