dev-autopilot 0.4.0__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.
- autopilot/__init__.py +4 -0
- autopilot/__main__.py +6 -0
- autopilot/backends/__init__.py +159 -0
- autopilot/backends/claude_cli.py +368 -0
- autopilot/backends/command.py +90 -0
- autopilot/cli.py +1614 -0
- autopilot/commands/__init__.py +0 -0
- autopilot/commands/preflight.py +131 -0
- autopilot/config.py +155 -0
- autopilot/context.py +414 -0
- autopilot/dash_term.py +493 -0
- autopilot/dash_web.py +347 -0
- autopilot/dashboard.py +410 -0
- autopilot/decisions.py +85 -0
- autopilot/deploy.py +67 -0
- autopilot/docs.py +301 -0
- autopilot/doctor.py +478 -0
- autopilot/gate.py +383 -0
- autopilot/github_util.py +42 -0
- autopilot/gitops.py +251 -0
- autopilot/growth.py +85 -0
- autopilot/handoff.py +27 -0
- autopilot/layers.py +134 -0
- autopilot/log_scrub.py +11 -0
- autopilot/mcp.py +104 -0
- autopilot/notify.py +98 -0
- autopilot/orchestrator.py +2214 -0
- autopilot/plain.py +238 -0
- autopilot/plan.py +320 -0
- autopilot/proc.py +176 -0
- autopilot/prompts/auditor.md +39 -0
- autopilot/prompts/decide.md +38 -0
- autopilot/prompts/docs.md +48 -0
- autopilot/prompts/fixer.md +26 -0
- autopilot/prompts/onboard.md +43 -0
- autopilot/prompts/plan_writer.md +40 -0
- autopilot/prompts/replanner.md +42 -0
- autopilot/prompts/research.md +25 -0
- autopilot/prompts/resume.md +10 -0
- autopilot/prompts/retry.md +8 -0
- autopilot/prompts/review.md +40 -0
- autopilot/prompts/system.md +16 -0
- autopilot/prompts/task.md +78 -0
- autopilot/prompts/triage.md +30 -0
- autopilot/prompts/unstick.md +37 -0
- autopilot/prompts/verify.md +32 -0
- autopilot/repomap.py +71 -0
- autopilot/report.py +633 -0
- autopilot/rights.py +184 -0
- autopilot/schemas.py +42 -0
- autopilot/state.py +298 -0
- autopilot/templates/__pycache__/cloudflare_apply.cpython-313.pyc +0 -0
- autopilot/templates/agent/BRAIN.md +21 -0
- autopilot/templates/agent/CLAUDE.md.snippet +10 -0
- autopilot/templates/agent/DECISIONS.md +5 -0
- autopilot/templates/agent/HANDOFF.md +3 -0
- autopilot/templates/agent/gitignore +10 -0
- autopilot/templates/agent/ide_rules.md +8 -0
- autopilot/templates/agent/plan.yaml +48 -0
- autopilot/templates/agent/project.yaml +259 -0
- autopilot/templates/cloudflare_apply.py +211 -0
- autopilot/templates/deploy.sh +72 -0
- autopilot/templates/email_verify.py +144 -0
- autopilot/templates/infra/cloudflare.yaml +11 -0
- autopilot/templates/presets/docker.yaml +4 -0
- autopilot/templates/presets/generic.yaml +3 -0
- autopilot/templates/presets/go.yaml +6 -0
- autopilot/templates/presets/java.yaml +4 -0
- autopilot/templates/presets/node.yaml +7 -0
- autopilot/templates/presets/python.yaml +6 -0
- autopilot/templates/presets/rust.yaml +5 -0
- autopilot/templates/presets/static.yaml +3 -0
- autopilot/upgrade.py +192 -0
- dev_autopilot-0.4.0.dist-info/METADATA +148 -0
- dev_autopilot-0.4.0.dist-info/RECORD +79 -0
- dev_autopilot-0.4.0.dist-info/WHEEL +5 -0
- dev_autopilot-0.4.0.dist-info/entry_points.txt +3 -0
- dev_autopilot-0.4.0.dist-info/licenses/LICENSE +21 -0
- dev_autopilot-0.4.0.dist-info/top_level.txt +1 -0
autopilot/doctor.py
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
"""`autopilot doctor`: check this machine and project before a run, and fix what can be fixed without a person.
|
|
2
|
+
Checks are read-only and never start an agent session; fixes run only through `autofix()`."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
import sysconfig
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .config import Config
|
|
13
|
+
from .proc import WINDOWS, run_proc
|
|
14
|
+
|
|
15
|
+
OK, WARN, FAIL = "ok", "WARN", "FAIL"
|
|
16
|
+
NOTIFY_ENVS = ("telegram_token_env", "slack_webhook_env", "webhook_env", "ntfy_topic_env")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _run(cmd: list[str], cwd=None, timeout: float = 30) -> tuple[int, str]:
|
|
20
|
+
try:
|
|
21
|
+
p = run_proc(cmd, cwd=cwd, timeout=timeout)
|
|
22
|
+
except OSError as exc:
|
|
23
|
+
return 127, str(exc)
|
|
24
|
+
return p.rc, (p.stdout + p.stderr).strip()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _env(name: str) -> str:
|
|
28
|
+
return os.environ.get(name) or (os.environ.get("AUTODEV_" + name[10:], "") if name.startswith("AUTOPILOT_") else "")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
LOGIN_FIX = "run `claude auth login`, then start again. A paid Claude plan or an API key is necessary"
|
|
32
|
+
OTHER_AUTH = ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", "CLAUDE_CODE_OAUTH_TOKEN",
|
|
33
|
+
"CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def other_auth(cfg) -> str:
|
|
37
|
+
"""H2: what pays for sessions when there is no Claude login: an API key, a gateway, or usage.billing: api.
|
|
38
|
+
Only the name is returned, never a value."""
|
|
39
|
+
name = next((n for n in OTHER_AUTH if os.environ.get(n)), "")
|
|
40
|
+
return name or ("usage.billing: api" if cfg is not None and cfg.get("usage.billing") == "api" else "")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def check_login(binary: str, version: tuple, cfg, timeout: float = 30) -> tuple[str, str, str]:
|
|
44
|
+
"""H2: FAIL only when a login is surely missing: a CLI that has `auth status`, and no other way to pay."""
|
|
45
|
+
rc, out = _run([binary, "auth", "status"], timeout=timeout) # documented from 2.1.268: exit 0 = logged in
|
|
46
|
+
other = other_auth(cfg)
|
|
47
|
+
if rc == 0:
|
|
48
|
+
# only the sure case: a claude.ai login (not a key, not a gateway) on the free plan, and no other way to pay
|
|
49
|
+
if all(re.search(rf'"{k}"\s*:\s*"{v}"', out, re.I) for k, v in (("subscriptionType", "free"),
|
|
50
|
+
("authMethod", r"claude\.ai"))) and not other:
|
|
51
|
+
return FAIL, "claude login", "the free Claude plan cannot use Claude Code: get a paid plan, or set an API key"
|
|
52
|
+
return OK, "claude login", "logged in"
|
|
53
|
+
if version < (2, 1, 268):
|
|
54
|
+
return WARN, "claude login", "cannot check: this CLI is older than 2.1.268. If sessions fail, run `claude` and /login"
|
|
55
|
+
if other:
|
|
56
|
+
return WARN, "claude login", f"not logged in; sessions use {other}"
|
|
57
|
+
return FAIL, "claude login", f"not logged in: {LOGIN_FIX}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# N3: agent CLIs in the order preflight tries them -> (binary, backend choice, read-only status argv or None,
|
|
61
|
+
# documented key variables). A key variable counts first: no process to start. Never a `login` argument here.
|
|
62
|
+
LOGIN_TIMEOUT = 20 # N10
|
|
63
|
+
RUNNERS = (
|
|
64
|
+
("claude", "claude_cli", None, OTHER_AUTH),
|
|
65
|
+
("kiro-cli", "kiro", ("whoami",), ("KIRO_API_KEY",)),
|
|
66
|
+
("cursor-agent", "cursor", ("status",), ("CURSOR_API_KEY",)),
|
|
67
|
+
("codex", "codex", ("login", "status"), ("CODEX_API_KEY",)),
|
|
68
|
+
("gemini", "gemini", None, ("GEMINI_API_KEY", "GOOGLE_API_KEY")),
|
|
69
|
+
("aider", "aider", None, ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "OPENROUTER_API_KEY",
|
|
70
|
+
"DEEPSEEK_API_KEY")),
|
|
71
|
+
)
|
|
72
|
+
# exit codes of the status commands are not documented: a logged-out CLI may still exit 0
|
|
73
|
+
LOGGED_OUT = re.compile(r"not (logged|signed) in|not authenticated|unauthenticated|logged out|no (active )?"
|
|
74
|
+
r"(session|credentials)|please (log|sign) ?in", re.I)
|
|
75
|
+
# N10: owner rule 2026-10-11, subscription login only. The one interactive login command per runner (never run by
|
|
76
|
+
# Autopilot: it prints the line and stops), and the runners whose only login is an API key (not supported).
|
|
77
|
+
LOGIN_CMD = {"claude_cli": "claude auth login", "codex": "codex login", "cursor": "cursor-agent login",
|
|
78
|
+
"kiro": "kiro-cli login", "copilot": "gh auth login"}
|
|
79
|
+
NO_SUBSCRIPTION = ("gemini", "aider", "opencode", "amp", "qwen", "goose")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def check_runner_login(cfg, env=None) -> tuple[str, str, str]:
|
|
83
|
+
"""N10: the configured runner (a `command` preset) is logged in with its own subscription; stdin closed, CI=1,
|
|
84
|
+
LOGIN_TIMEOUT. FAIL names the one login command. No credential file is read, written or copied."""
|
|
85
|
+
preset = str(cfg.get("agent.preset") or "")
|
|
86
|
+
if cfg.get("agent.command") or not preset:
|
|
87
|
+
return OK, "runner login", "custom command: not checked"
|
|
88
|
+
if preset in NO_SUBSCRIPTION:
|
|
89
|
+
return (WARN, "runner login",
|
|
90
|
+
f"`{preset}` needs an API key, not supported (subscription runners: {', '.join(LOGIN_CMD)})")
|
|
91
|
+
row = next((r for r in RUNNERS if r[1] == preset), None)
|
|
92
|
+
if row is None:
|
|
93
|
+
return WARN, "runner login", f"no login check for preset `{preset}`"
|
|
94
|
+
binary, _, status, keys = row
|
|
95
|
+
env = dict(os.environ if env is None else env)
|
|
96
|
+
exe = shutil.which(binary, path=next((v for k, v in env.items() if k.upper() == "PATH"), None))
|
|
97
|
+
if not exe:
|
|
98
|
+
return FAIL, "runner login", f"`{binary}` is not on PATH"
|
|
99
|
+
if _logged_in(exe, binary, status, keys, env):
|
|
100
|
+
return OK, "runner login", f"{binary} logged in"
|
|
101
|
+
return FAIL, "runner login", f"{binary} is not logged in: run `{LOGIN_CMD.get(preset, binary + ' login')}`, then start again"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _logged_in(path: str, binary: str, status: tuple | None, keys: tuple, env: dict) -> bool:
|
|
105
|
+
"""N3: True when a key variable is set (only its presence is read), or the status command exits 0 within
|
|
106
|
+
LOGIN_TIMEOUT without logged-out wording. A timeout or a program that does not start counts as logged out."""
|
|
107
|
+
if any(env.get(k) for k in keys):
|
|
108
|
+
return True
|
|
109
|
+
if binary == "claude":
|
|
110
|
+
return check_login(path, (0, 0, 0), None, timeout=LOGIN_TIMEOUT)[0] == OK
|
|
111
|
+
if not status:
|
|
112
|
+
return False
|
|
113
|
+
try:
|
|
114
|
+
p = run_proc([path, *status], env={**env, "CI": "1"}, timeout=LOGIN_TIMEOUT) # N10: no prompt, no browser
|
|
115
|
+
except OSError:
|
|
116
|
+
return False
|
|
117
|
+
return p.rc == 0 and not p.timed_out and not LOGGED_OUT.search(p.stdout + p.stderr)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def detect_runner(env=None) -> str | None:
|
|
121
|
+
"""N3: the backend choice (`claude_cli`, or a preset name) of the first agent CLI on PATH that is installed and
|
|
122
|
+
logged in, in RUNNERS order; None when there is none. Read-only: never starts a login."""
|
|
123
|
+
env = dict(os.environ if env is None else env)
|
|
124
|
+
path = next((v for k, v in env.items() if k.upper() == "PATH"), "")
|
|
125
|
+
if not path:
|
|
126
|
+
return None
|
|
127
|
+
for binary, backend, status, keys in RUNNERS:
|
|
128
|
+
exe = shutil.which(binary, path=path)
|
|
129
|
+
if exe and _logged_in(exe, binary, status, keys, env):
|
|
130
|
+
return backend
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def check_claude(cfg=None) -> list[tuple[str, str, str]]:
|
|
135
|
+
from .backends.claude_cli import ClaudeCLIBackend
|
|
136
|
+
binary = ClaudeCLIBackend.resolve_binary()
|
|
137
|
+
rc, out = _run([binary, "--version"])
|
|
138
|
+
first = out.splitlines()[0] if out else ""
|
|
139
|
+
# a broken install can still print a version, just not Claude Code's (seen: "Bun 1.4.3")
|
|
140
|
+
if not (rc == 0 and "claude code" in out.lower() and re.search(r"\d+\.\d+\.\d+", out)):
|
|
141
|
+
return [(FAIL, "claude CLI", f"`{binary} --version` gave rc {rc}: {first[:120] or 'no output'}. Reinstall "
|
|
142
|
+
f"with `{INSTALL[claude_route(binary)][1]}`, or set AUTOPILOT_CLAUDE_BIN to the real binary")]
|
|
143
|
+
version = tuple(int(x) for x in re.search(r"(\d+)\.(\d+)\.(\d+)", out).groups())
|
|
144
|
+
return [(OK, "claude CLI", f"{first} ({binary})"), check_login(binary, version, cfg)]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def scripts_dir() -> Path | None:
|
|
148
|
+
"""The folder pip put the `autopilot` command in: the interpreter's, or the per-user one (`pip install --user`,
|
|
149
|
+
or a system Python whose folder isn't writable)."""
|
|
150
|
+
for scheme in (None, sysconfig.get_preferred_scheme("user")):
|
|
151
|
+
try:
|
|
152
|
+
d = Path(sysconfig.get_path("scripts", scheme) if scheme else sysconfig.get_path("scripts"))
|
|
153
|
+
except KeyError:
|
|
154
|
+
continue
|
|
155
|
+
if (d / "autopilot.exe").is_file() or (d / "autopilot").is_file():
|
|
156
|
+
return d
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def check_path() -> tuple[str, str, str]:
|
|
161
|
+
if shutil.which("autopilot"):
|
|
162
|
+
return OK, "autopilot cmd", "on PATH"
|
|
163
|
+
d = scripts_dir()
|
|
164
|
+
return WARN, "autopilot cmd", (f"installed in {d}, which is not on PATH" if d else "not installed as a command") \
|
|
165
|
+
+ "; `python -m autopilot` works meanwhile"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------- fixes: only what is safe without a person; logins, sudo and paid accounts stay with the owner ----------
|
|
169
|
+
_NATIVE = "irm https://claude.ai/install.ps1 | iex" if WINDOWS else "curl -fsSL https://claude.ai/install.sh | bash"
|
|
170
|
+
# H3: how Claude Code was installed -> (the repair command, the same command as the owner types it)
|
|
171
|
+
INSTALL = {
|
|
172
|
+
"npm": (["npm", "i", "-g", "@anthropic-ai/claude-code"], "npm i -g @anthropic-ai/claude-code"),
|
|
173
|
+
"winget": (None, "winget upgrade Anthropic.ClaudeCode"), # winget installs do not update themselves
|
|
174
|
+
"native": ((["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", _NATIVE] if WINDOWS
|
|
175
|
+
else ["bash", "-c", _NATIVE]), _NATIVE),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def claude_route(binary: str) -> str:
|
|
180
|
+
"""H3: npm, winget or native, from where the program is. A missing program is native: no Node.js is needed."""
|
|
181
|
+
p = os.path.realpath(binary).replace("\\", "/").lower() if os.path.exists(binary) else ""
|
|
182
|
+
if "/node_modules/" in p or p.endswith((".cmd", ".bat")):
|
|
183
|
+
return "npm" if shutil.which("npm") else "native"
|
|
184
|
+
return "winget" if "/winget/" in p else "native"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def fix_claude() -> str:
|
|
188
|
+
"""Reinstall Claude Code the way it was installed. The native route downloads the installer from claude.ai and
|
|
189
|
+
runs it, so the command is shown before it starts. `autofix` does not call this under AUTOPILOT_NO_AUTOFIX=1."""
|
|
190
|
+
from .backends.claude_cli import ClaudeCLIBackend
|
|
191
|
+
route = claude_route(ClaudeCLIBackend.resolve_binary())
|
|
192
|
+
cmd, shown = INSTALL[route]
|
|
193
|
+
if not cmd:
|
|
194
|
+
return f"cannot fix: Claude Code came from winget, which does not update it. Run `{shown}`"
|
|
195
|
+
print(f"fix claude CLI: running `{shown}` (this downloads and installs Claude Code)", flush=True)
|
|
196
|
+
try:
|
|
197
|
+
p = run_proc([shutil.which(cmd[0]) or cmd[0]] + cmd[1:], timeout=900)
|
|
198
|
+
except OSError as exc:
|
|
199
|
+
return f"cannot fix: {exc}. Run `{shown}` yourself"
|
|
200
|
+
return (f"reinstalled ({route})" if p.rc == 0 else
|
|
201
|
+
f"install failed (rc {p.rc}): {(p.stderr or p.stdout)[-300:]}. Run `{shown}` yourself")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def write_launcher() -> str:
|
|
205
|
+
"""A one-line `autopilot` launcher in a user folder that is already on PATH, so the command works at once in every
|
|
206
|
+
terminal, including ones VS Code opened before a PATH change. Only well-known per-user folders, never an
|
|
207
|
+
arbitrary PATH entry. Returns where it was written, or ''."""
|
|
208
|
+
if WINDOWS:
|
|
209
|
+
prefer = [Path(os.environ.get("LOCALAPPDATA", "")) / "Microsoft" / "WindowsApps",
|
|
210
|
+
Path.home() / ".local" / "bin", Path(os.environ.get("APPDATA", "")) / "npm"] # uv, Claude Code, npm
|
|
211
|
+
name, body = "autopilot.cmd", f'@"{sys.executable}" -m autopilot %*\r\n'
|
|
212
|
+
else:
|
|
213
|
+
prefer = [Path.home() / ".local" / "bin", Path.home() / "bin"]
|
|
214
|
+
name, body = "autopilot", f'#!/bin/sh\nexec "{sys.executable}" -m autopilot "$@"\n'
|
|
215
|
+
|
|
216
|
+
def norm(p) -> str:
|
|
217
|
+
return os.path.normcase(str(p)).rstrip("\\/")
|
|
218
|
+
on_path = {norm(p) for p in os.environ.get("PATH", "").split(os.pathsep) if p}
|
|
219
|
+
for d in prefer:
|
|
220
|
+
if str(d) not in ("", ".") and norm(d) in on_path and d.is_dir() and os.access(d, os.W_OK):
|
|
221
|
+
(d / name).write_text(body, encoding="utf-8")
|
|
222
|
+
if not WINDOWS:
|
|
223
|
+
(d / name).chmod(0o755)
|
|
224
|
+
return str(d / name)
|
|
225
|
+
return ""
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def fix_path() -> str:
|
|
229
|
+
launcher = write_launcher()
|
|
230
|
+
now = f"; `autopilot` works now in every terminal via {launcher}" if launcher else ""
|
|
231
|
+
d = scripts_dir()
|
|
232
|
+
if not d:
|
|
233
|
+
return (f"wrote a launcher{now}" if launcher else
|
|
234
|
+
"cannot fix: the autopilot command is not installed (`python -m pip install -e <autopilot repo>`)")
|
|
235
|
+
os.environ["PATH"] = os.environ.get("PATH", "") + os.pathsep + str(d)
|
|
236
|
+
if WINDOWS:
|
|
237
|
+
_add_user_path_windows(d)
|
|
238
|
+
later = ". Open terminals keep their old PATH (VS Code: restart it); until then use `python -m autopilot`"
|
|
239
|
+
return f"added {d} to your user PATH{now or later}"
|
|
240
|
+
profile, line = Path.home() / ".profile", f'export PATH="$PATH:{d}"'
|
|
241
|
+
text = profile.read_text(encoding="utf-8") if profile.exists() else ""
|
|
242
|
+
if line not in text:
|
|
243
|
+
profile.write_text(text + ("" if not text or text.endswith("\n") else "\n") + line + "\n", encoding="utf-8")
|
|
244
|
+
return f"added {d} to PATH in {profile}{now or ' (new login shells)'}"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _add_user_path_windows(d: Path):
|
|
248
|
+
"""HKCU\\Environment Path, appended in place (not setx, which truncates at 1024 characters)."""
|
|
249
|
+
import ctypes
|
|
250
|
+
import winreg
|
|
251
|
+
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment", 0, winreg.KEY_READ | winreg.KEY_WRITE) as k:
|
|
252
|
+
try:
|
|
253
|
+
cur, kind = winreg.QueryValueEx(k, "Path")
|
|
254
|
+
except FileNotFoundError:
|
|
255
|
+
cur, kind = "", winreg.REG_EXPAND_SZ
|
|
256
|
+
parts = [p for p in str(cur).split(";") if p]
|
|
257
|
+
if str(d).rstrip("\\").lower() not in {p.rstrip("\\").lower() for p in parts}:
|
|
258
|
+
winreg.SetValueEx(k, "Path", 0, kind, ";".join(parts + [str(d)]))
|
|
259
|
+
# tell running programs (Explorer, new terminals) that the environment changed
|
|
260
|
+
ctypes.windll.user32.SendMessageTimeoutW(0xFFFF, 0x1A, 0, "Environment", 0x2, 5000, None)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
FIXES = {"claude CLI": fix_claude, "autopilot cmd": fix_path}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def autofix(rows: list[tuple[str, str, str]]) -> list[str]:
|
|
267
|
+
"""Apply the fix for each failed or warned check that has one; returns what was done. AUTOPILOT_NO_AUTOFIX=1
|
|
268
|
+
turns this off (the test suite sets it: a test must never reinstall software or edit PATH)."""
|
|
269
|
+
if os.environ.get("AUTOPILOT_NO_AUTOFIX"):
|
|
270
|
+
return []
|
|
271
|
+
return [f"{name}: {FIXES[name]()}" for level, name, _ in rows if level != OK and name in FIXES]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _config_load_line(cfg: Config) -> str:
|
|
275
|
+
"""I1: what the last session loaded from the Claude config; read-only, '' when there is no state.db or no figure."""
|
|
276
|
+
import json
|
|
277
|
+
import sqlite3
|
|
278
|
+
from .report import config_line
|
|
279
|
+
db = cfg.agent_dir / "state.db"
|
|
280
|
+
if not db.is_file():
|
|
281
|
+
return ""
|
|
282
|
+
try:
|
|
283
|
+
con = sqlite3.connect(f"{db.as_uri()}?mode=ro", uri=True, timeout=5)
|
|
284
|
+
try:
|
|
285
|
+
row = con.execute("SELECT value FROM meta WHERE key='config_load'").fetchone()
|
|
286
|
+
finally:
|
|
287
|
+
con.close()
|
|
288
|
+
return config_line(json.loads(row[0])) if row else ""
|
|
289
|
+
except (sqlite3.Error, ValueError, TypeError):
|
|
290
|
+
return ""
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def check_user_guards(cfg: Config) -> tuple[str, str, str] | None:
|
|
294
|
+
"""I2: sessions skip ~/.claude/settings.json (`agent.user_config: false`), so its deny rules and tool hooks do
|
|
295
|
+
not apply to them. Counts only, never a rule or a command."""
|
|
296
|
+
import json
|
|
297
|
+
if cfg.get("agent.user_config"):
|
|
298
|
+
return None
|
|
299
|
+
home = Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude")
|
|
300
|
+
try:
|
|
301
|
+
s = json.loads((home / "settings.json").read_text(encoding="utf-8"))
|
|
302
|
+
deny = len((s.get("permissions") or {}).get("deny") or [])
|
|
303
|
+
hooks = sum(len((s.get("hooks") or {}).get(k) or []) for k in ("PreToolUse", "PermissionRequest"))
|
|
304
|
+
except (OSError, ValueError, AttributeError, TypeError):
|
|
305
|
+
return None
|
|
306
|
+
if not (deny or hooks):
|
|
307
|
+
return None
|
|
308
|
+
return (WARN, "user guards", f"your ~/.claude/settings.json has {deny} deny rule(s) and {hooks} tool hook(s); "
|
|
309
|
+
"Autopilot sessions do not load them. To keep them, set agent.user_config: true")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _venv_in(root: Path, names=(".venv",)) -> bool:
|
|
313
|
+
return any((root / n / "pyvenv.cfg").is_file() or (root / n / "bin").is_dir() or (root / n / "Scripts").is_dir()
|
|
314
|
+
for n in names)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _nonempty(p: Path) -> bool:
|
|
318
|
+
try:
|
|
319
|
+
return p.is_dir() and any(p.iterdir())
|
|
320
|
+
except OSError:
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _go_cache() -> Path:
|
|
325
|
+
mc = os.environ.get("GOMODCACHE")
|
|
326
|
+
if mc:
|
|
327
|
+
return Path(mc)
|
|
328
|
+
return Path((os.environ.get("GOPATH") or "").split(os.pathsep)[0] or Path.home() / "go") / "pkg" / "mod"
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _cargo_registry() -> Path:
|
|
332
|
+
return Path(os.environ.get("CARGO_HOME") or Path.home() / ".cargo") / "registry"
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def missing_deps(root) -> list[dict]:
|
|
336
|
+
"""N2: pure check, no install. One item {lock, command, tool} per lock or manifest file whose dependency folder
|
|
337
|
+
is missing. Presence checks: node_modules/ (npm, pnpm, yarn); .venv/ or venv/ (uv, poetry; a Poetry venv outside
|
|
338
|
+
the project gives a false WARN); a venv in the project or an active one (requirements); a non-empty Go module
|
|
339
|
+
cache or vendor/ (go.sum); target/ or the cargo registry (Cargo.lock)."""
|
|
340
|
+
root = Path(root)
|
|
341
|
+
out: list[dict] = []
|
|
342
|
+
|
|
343
|
+
def add(lock, command, tool):
|
|
344
|
+
out.append({"lock": lock, "command": command, "tool": tool})
|
|
345
|
+
|
|
346
|
+
def has(n):
|
|
347
|
+
return (root / n).is_file()
|
|
348
|
+
if has("package-lock.json") and not (root / "node_modules").is_dir():
|
|
349
|
+
add("package-lock.json", "npm ci", "npm")
|
|
350
|
+
if has("pnpm-lock.yaml") and not (root / "node_modules").is_dir():
|
|
351
|
+
add("pnpm-lock.yaml", "pnpm install --frozen-lockfile", "pnpm")
|
|
352
|
+
if has("yarn.lock") and not (root / "node_modules").is_dir():
|
|
353
|
+
try:
|
|
354
|
+
berry = "__metadata:" in (root / "yarn.lock").read_text(encoding="utf-8", errors="replace")[:4000]
|
|
355
|
+
except OSError:
|
|
356
|
+
berry = False
|
|
357
|
+
add("yarn.lock", "yarn install --immutable" if berry else "yarn install --frozen-lockfile", "yarn")
|
|
358
|
+
if has("uv.lock") and not _venv_in(root, (".venv", "venv")):
|
|
359
|
+
add("uv.lock", "uv sync", "uv")
|
|
360
|
+
if has("poetry.lock") and not _venv_in(root, (".venv", "venv")):
|
|
361
|
+
add("poetry.lock", "poetry install", "poetry")
|
|
362
|
+
reqs = sorted(p.name for p in root.glob("requirements*.txt") if p.is_file())
|
|
363
|
+
active = bool(os.environ.get("VIRTUAL_ENV")) or sys.prefix != getattr(sys, "base_prefix", sys.prefix)
|
|
364
|
+
if reqs and not active and not _venv_in(root, (".venv", "venv", "env")):
|
|
365
|
+
for r in reqs:
|
|
366
|
+
add(r, f"pip install -r {r}", "python")
|
|
367
|
+
if has("go.sum") and not (_nonempty(_go_cache()) or (root / "vendor").is_dir()):
|
|
368
|
+
add("go.sum", "go mod download", "go")
|
|
369
|
+
if has("Cargo.lock") and not ((root / "target").is_dir() or _nonempty(_cargo_registry())):
|
|
370
|
+
add("Cargo.lock", "cargo fetch", "cargo")
|
|
371
|
+
return out
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
_HINTS = { # tool -> (windows, linux, macos)
|
|
375
|
+
"python": ("winget install -e --id Python.Python.3.12", "sudo apt install python3 python3-venv python3-pip",
|
|
376
|
+
"brew install python"),
|
|
377
|
+
"node": ("winget install -e --id OpenJS.NodeJS.LTS", "sudo apt install nodejs npm", "brew install node"),
|
|
378
|
+
"git": ("winget install -e --id Git.Git", "sudo apt install git", "brew install git"),
|
|
379
|
+
"go": ("winget install -e --id GoLang.Go", "sudo apt install golang-go", "brew install go"),
|
|
380
|
+
"uv": ("winget install -e --id astral-sh.uv", "curl -LsSf https://astral.sh/uv/install.sh | sh",
|
|
381
|
+
"brew install uv"),
|
|
382
|
+
"poetry": ("pipx install poetry", "pipx install poetry", "pipx install poetry"),
|
|
383
|
+
"cargo": ("winget install -e --id Rustlang.Rustup", "curl --proto =https --tlsv1.2 -sSf https://sh.rustup.rs | sh",
|
|
384
|
+
"curl --proto =https --tlsv1.2 -sSf https://sh.rustup.rs | sh"),
|
|
385
|
+
"pnpm": ("npm install -g pnpm", "npm install -g pnpm", "npm install -g pnpm"),
|
|
386
|
+
"yarn": ("npm install -g yarn", "npm install -g yarn", "npm install -g yarn"),
|
|
387
|
+
}
|
|
388
|
+
_HINTS["npm"] = _HINTS["node"]
|
|
389
|
+
_HINTS["rust"] = _HINTS["cargo"]
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def system_tool_hint(tool: str, platform: str | None = None) -> str:
|
|
393
|
+
"""N2: the exact install command of a system tool for this OS (`platform` is sys.platform; default the current).
|
|
394
|
+
Autopilot prints it and never runs it. '' for an unknown tool."""
|
|
395
|
+
row = _HINTS.get(str(tool).lower().strip())
|
|
396
|
+
if not row:
|
|
397
|
+
return ""
|
|
398
|
+
p = platform or sys.platform
|
|
399
|
+
return row[0] if p.startswith("win") else row[2] if p == "darwin" else row[1]
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def check_deps(cfg: Config) -> tuple[str, str, str] | None:
|
|
403
|
+
"""N2: WARN row naming the lock files whose dependencies are not installed, with the command."""
|
|
404
|
+
miss = missing_deps(cfg.root)
|
|
405
|
+
if not miss:
|
|
406
|
+
return None
|
|
407
|
+
parts = [f"{m['lock']}: {m['command']}" + ("" if shutil.which(m["tool"]) or (m["tool"] == "python"
|
|
408
|
+
and (shutil.which("python") or shutil.which("python3"))) else f" (needs {m['tool']}: "
|
|
409
|
+
f"{system_tool_hint(m['tool'])})") for m in miss]
|
|
410
|
+
return (WARN, "dependencies", "not installed; run " + "; ".join(parts))
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def package_line() -> str:
|
|
414
|
+
"""Which Autopilot is imported: version and package path (shared by `ap doctor` and the run log)."""
|
|
415
|
+
from . import __file__ as init_file, __version__
|
|
416
|
+
return f"Autopilot {__version__} from {Path(init_file).resolve().parent}"
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def check_package(root) -> tuple[str, str, str]:
|
|
420
|
+
"""WARN when the project's own autopilot/ folder is imported instead of the installed package."""
|
|
421
|
+
line = package_line()
|
|
422
|
+
pkg = Path(line.split(" from ", 1)[1])
|
|
423
|
+
if Path(root).resolve() in pkg.parents:
|
|
424
|
+
return (WARN, "autopilot", line + ": the project's own autopilot/ folder is imported, not the installed Autopilot")
|
|
425
|
+
return (OK, "autopilot", line)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def checks(cfg: Config) -> list[tuple[str, str, str]]:
|
|
429
|
+
"""(level, name, detail) per check. FAIL = the run would break; WARN = it runs, but something is missing."""
|
|
430
|
+
out = [check_path(), check_package(cfg.root)]
|
|
431
|
+
if cfg.get("agent.backend", "claude_cli") == "claude_cli":
|
|
432
|
+
out += check_claude(cfg)
|
|
433
|
+
out += [g for g in [check_user_guards(cfg)] if g]
|
|
434
|
+
elif cfg.get("agent.backend") == "command":
|
|
435
|
+
from .backends.command import command_template
|
|
436
|
+
exe = command_template(cfg).split()
|
|
437
|
+
found = bool(exe) and shutil.which(exe[0])
|
|
438
|
+
out.append((OK if found else FAIL, "agent command", f"{exe[0] if exe else '(empty)'} "
|
|
439
|
+
+ ("found" if found else "not found on PATH")))
|
|
440
|
+
|
|
441
|
+
rc, txt = _run(["git", "--version"])
|
|
442
|
+
out.append((OK if rc == 0 else FAIL, "git", txt.splitlines()[0] if rc == 0 and txt else "not found on PATH"))
|
|
443
|
+
from .rights import describe
|
|
444
|
+
out.append((OK, "rights", describe())) # N8: what the installer took and how to undo it
|
|
445
|
+
if cfg.get("git.push"):
|
|
446
|
+
rc, url = _run(["git", "remote", "get-url", "origin"], cwd=cfg.root)
|
|
447
|
+
out.append((OK if rc == 0 else FAIL, "git remote", url if rc == 0 else "git.push is on but there is no origin"))
|
|
448
|
+
|
|
449
|
+
needs_gh = [k for k, on in (("intake.enabled", cfg.get("intake.enabled")),
|
|
450
|
+
("git.mode: pr", cfg.get("git.mode") == "pr")) if on]
|
|
451
|
+
if needs_gh:
|
|
452
|
+
rc, txt = _run(["gh", "auth", "status"])
|
|
453
|
+
out.append((OK if rc == 0 else FAIL, "gh", ("logged in" if rc == 0 else "not installed or not logged in "
|
|
454
|
+
"(`gh auth login`)") + f"; needed by {', '.join(needs_gh)}"))
|
|
455
|
+
|
|
456
|
+
if cfg.get("sandbox.enabled") and sys.platform.startswith("linux"):
|
|
457
|
+
missing = [b for b in ("bwrap", "socat") if not shutil.which(b)]
|
|
458
|
+
out.append((FAIL if missing else OK, "sandbox", f"missing {', '.join(missing)} (apt install bubblewrap socat)"
|
|
459
|
+
if missing else "bubblewrap and socat found"))
|
|
460
|
+
|
|
461
|
+
channels = [cfg.get(f"notify.{k}") for k in NOTIFY_ENVS if _env(str(cfg.get(f"notify.{k}") or ""))]
|
|
462
|
+
out.append((OK if channels else WARN, "notifications", f"via {', '.join(channels)}" if channels else
|
|
463
|
+
"none set: you will not hear about decisions (docs/NEEDS-YOU.md) or a finished run"))
|
|
464
|
+
if cfg.get("chat.enabled"):
|
|
465
|
+
need = [str(cfg.get(f"notify.{k}")) for k in ("telegram_token_env", "telegram_chat_env")]
|
|
466
|
+
miss = [n for n in need if not _env(n)]
|
|
467
|
+
out.append((FAIL if miss else OK, "telegram chat", f"chat.enabled but {', '.join(miss)} not set" if miss
|
|
468
|
+
else "bot token and chat id set"))
|
|
469
|
+
|
|
470
|
+
out += [d for d in [check_deps(cfg)] if d]
|
|
471
|
+
line = _config_load_line(cfg)
|
|
472
|
+
if line:
|
|
473
|
+
out.append((OK, "claude config", line + " (from the last session)"))
|
|
474
|
+
for e in cfg.blocking_errors():
|
|
475
|
+
out.append((FAIL, "config", e))
|
|
476
|
+
for e in sorted(set(cfg.validate()) - set(cfg.blocking_errors())):
|
|
477
|
+
out.append((WARN, "config", e))
|
|
478
|
+
return out
|