outerloop-science 0.1.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.
- outerloop/__init__.py +18 -0
- outerloop/__main__.py +3 -0
- outerloop/appauth.py +230 -0
- outerloop/appmanifest.py +203 -0
- outerloop/attempt.py +3784 -0
- outerloop/brief.py +528 -0
- outerloop/cli.py +621 -0
- outerloop/climbboard.py +1395 -0
- outerloop/compute.py +654 -0
- outerloop/contract.py +492 -0
- outerloop/contract_cli.py +63 -0
- outerloop/disk.py +164 -0
- outerloop/dispatch.py +631 -0
- outerloop/evalcache.py +147 -0
- outerloop/followup.py +2172 -0
- outerloop/github.py +1531 -0
- outerloop/harness.py +1435 -0
- outerloop/housekeeping.py +151 -0
- outerloop/image.py +368 -0
- outerloop/init.py +744 -0
- outerloop/intake.py +126 -0
- outerloop/launchlog.py +239 -0
- outerloop/limits.py +80 -0
- outerloop/maintain.py +353 -0
- outerloop/maintain_agent_cli.py +81 -0
- outerloop/maintain_post_cli.py +140 -0
- outerloop/markers.py +48 -0
- outerloop/measure.py +529 -0
- outerloop/orchestrator.py +2011 -0
- outerloop/panel.py +188 -0
- outerloop/paths.py +40 -0
- outerloop/posting.py +160 -0
- outerloop/progress.py +170 -0
- outerloop/py.typed +0 -0
- outerloop/review.py +615 -0
- outerloop/review_agent.py +263 -0
- outerloop/review_agent_cli.py +209 -0
- outerloop/review_post_cli.py +162 -0
- outerloop/review_summarize_cli.py +165 -0
- outerloop/role_runner.py +229 -0
- outerloop/roles.py +274 -0
- outerloop/rolespec.py +91 -0
- outerloop/runstate.py +385 -0
- outerloop/steward.py +845 -0
- outerloop/style.py +12 -0
- outerloop/syscall.py +1192 -0
- outerloop/syscall_cli.py +762 -0
- outerloop/tick.py +3422 -0
- outerloop/verifier.py +403 -0
- outerloop/verify_agent.py +151 -0
- outerloop/verify_agent_cli.py +95 -0
- outerloop/verify_post_cli.py +116 -0
- outerloop/watcher.py +203 -0
- outerloop_science-0.1.0.dist-info/METADATA +152 -0
- outerloop_science-0.1.0.dist-info/RECORD +59 -0
- outerloop_science-0.1.0.dist-info/WHEEL +4 -0
- outerloop_science-0.1.0.dist-info/entry_points.txt +2 -0
- outerloop_science-0.1.0.dist-info/licenses/LICENSE +202 -0
- outerloop_science-0.1.0.dist-info/licenses/NOTICE +5 -0
outerloop/init.py
ADDED
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
"""`outerloop init` — the guided setup.
|
|
2
|
+
|
|
3
|
+
Collects placement (Slurm or local), the target repo, bot auth, and the
|
|
4
|
+
author's model key, then writes `~/.config/outerloop/.env` (plus the credential
|
|
5
|
+
files), so a new adopter never hand-edits config or reasons about which
|
|
6
|
+
`OUTERLOOP_*` keys to set. Flags fill
|
|
7
|
+
answers non-interactively; anything left out is prompted for (a secret via
|
|
8
|
+
getpass, never echoed). Auth is the adopter's own GitHub App by default —
|
|
9
|
+
`--github-app`, one click via the manifest flow in `appmanifest.py` — with a PAT
|
|
10
|
+
as the fallback; either way `resolve_bot_auth` reads the result exactly as
|
|
11
|
+
`outerloop start` does. An existing `.env` is never overwritten without asking.
|
|
12
|
+
|
|
13
|
+
The config location is `cli.ENV_FILE`, the same file `start` reads — one source
|
|
14
|
+
of truth, so a rename of the config dir moves both together.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import getpass
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import shutil
|
|
25
|
+
import sys
|
|
26
|
+
import time
|
|
27
|
+
import urllib.error
|
|
28
|
+
import urllib.request
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from outerloop.cli import ENV_FILE
|
|
34
|
+
from outerloop.image import ensure_image
|
|
35
|
+
from outerloop.paths import write_private
|
|
36
|
+
|
|
37
|
+
log = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
CONFIG_DIR = ENV_FILE.parent
|
|
40
|
+
DEFAULT_PAT_FILE = CONFIG_DIR / "bot_pat"
|
|
41
|
+
API = "https://api.github.com"
|
|
42
|
+
# The climbing author's harnesses (attempt.py's --author-backend choices).
|
|
43
|
+
AUTHOR_BACKENDS = ("claude", "codex")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class InitAnswers:
|
|
48
|
+
compute: str # "slurm" | "local"
|
|
49
|
+
target: str # "owner/repo"
|
|
50
|
+
root: str = "" # Slurm state root on the shared filesystem
|
|
51
|
+
account: str = "" # Slurm account (optional; unset -> the default association)
|
|
52
|
+
partition: str = "" # Slurm partition (optional; unset -> Slurm default)
|
|
53
|
+
author_backend: str = "" # optional: the climbing author's harness
|
|
54
|
+
author_model: str = "" # optional
|
|
55
|
+
author_key_file: str = "" # the author's model key file, when known
|
|
56
|
+
image: str = "" # the agent image (OUTERLOOP_IMAGE)
|
|
57
|
+
uncontained: bool = False # --no-image: write OUTERLOOP_IMAGE= so no image is picked up
|
|
58
|
+
author_bin: str = "" # the author harness binary (claude/codex), absolute, when found
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def render_env(
|
|
62
|
+
a: InitAnswers, pat_file: str = "", *, app_file: str = "", bot_login: str = ""
|
|
63
|
+
) -> str:
|
|
64
|
+
"""The `.env` body for these answers — only the keys that have a value, so
|
|
65
|
+
the file stays minimal and every line means something. Ordered placement →
|
|
66
|
+
target → auth → author to read top-to-bottom like the setup itself. Auth is
|
|
67
|
+
an App file (`--github-app`) or a PAT file, never both."""
|
|
68
|
+
lines = [f"OUTERLOOP_COMPUTE={a.compute}"]
|
|
69
|
+
if a.compute == "slurm":
|
|
70
|
+
lines.append(f"OUTERLOOP_ROOT={a.root}")
|
|
71
|
+
if a.account: # optional: unset bills the caller's default association
|
|
72
|
+
lines.append(f"OUTERLOOP_ACCOUNT={a.account}")
|
|
73
|
+
if a.partition: # optional: unset lets Slurm pick its default partition
|
|
74
|
+
lines.append(f"OUTERLOOP_PARTITION={a.partition}")
|
|
75
|
+
# the checkout stays put until the operator upgrades; `release` follows
|
|
76
|
+
# the release tags, `main` every merge (docs/install.md)
|
|
77
|
+
lines.append("OUTERLOOP_AUTO_UPDATE=off")
|
|
78
|
+
if a.image:
|
|
79
|
+
lines.append(f"OUTERLOOP_IMAGE={a.image}")
|
|
80
|
+
elif a.uncontained:
|
|
81
|
+
# the empty value is the off-switch: without it the tick would still
|
|
82
|
+
# pick up an image already at the default path
|
|
83
|
+
lines.append("OUTERLOOP_IMAGE=")
|
|
84
|
+
lines.append(f"OUTERLOOP_TARGET={a.target}")
|
|
85
|
+
if app_file:
|
|
86
|
+
lines.append(f"OUTERLOOP_GITHUB_APP_FILE={app_file}")
|
|
87
|
+
elif pat_file:
|
|
88
|
+
lines.append(f"OUTERLOOP_PAT_FILE={pat_file}")
|
|
89
|
+
if bot_login:
|
|
90
|
+
# every own-comment filter and own-PR scan keys on this login; without
|
|
91
|
+
# it the kernel assumes a default that is not this adopter's identity
|
|
92
|
+
lines.append(f"OUTERLOOP_BOT_LOGIN={bot_login}")
|
|
93
|
+
if a.author_backend:
|
|
94
|
+
lines.append(f"OUTERLOOP_AUTHOR_BACKEND={a.author_backend}")
|
|
95
|
+
if a.author_model:
|
|
96
|
+
lines.append(f"OUTERLOOP_AUTHOR_MODEL={a.author_model}")
|
|
97
|
+
if a.author_key_file:
|
|
98
|
+
lines.append(f"{author_key_env(a.author_backend)}={a.author_key_file}")
|
|
99
|
+
if a.author_bin:
|
|
100
|
+
lines.append(f"{author_bin_env(a.author_backend)}={a.author_bin}")
|
|
101
|
+
return "\n".join(lines) + "\n"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def author_bin_env(backend: str) -> str:
|
|
105
|
+
"""The `.env` key naming `backend`'s harness binary (`OUTERLOOP_CLAUDE_BIN`,
|
|
106
|
+
`OUTERLOOP_CODEX_BIN`), the same names `attempt` reads."""
|
|
107
|
+
return f"OUTERLOOP_{(backend or AUTHOR_BACKENDS[0]).upper()}_BIN"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
HARNESS_INSTALL = {
|
|
111
|
+
"claude": "npm install -g @anthropic-ai/claude-code "
|
|
112
|
+
"(or: curl -fsSL https://claude.ai/install.sh | bash)",
|
|
113
|
+
"codex": "npm install -g @openai/codex",
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def locate_harness(backend: str) -> str:
|
|
118
|
+
"""The absolute path of `backend`'s CLI on this machine: PATH first, then
|
|
119
|
+
~/.local/bin (where the native installers put it and where a Slurm job,
|
|
120
|
+
with no login PATH, would not find it); "" when absent. Recorded in .env so
|
|
121
|
+
every job spawns the same binary the operator installed."""
|
|
122
|
+
name = backend or AUTHOR_BACKENDS[0]
|
|
123
|
+
found = shutil.which(name)
|
|
124
|
+
if found:
|
|
125
|
+
return str(Path(found).resolve())
|
|
126
|
+
local = Path.home() / ".local" / "bin" / name
|
|
127
|
+
return str(local) if local.is_file() and os.access(local, os.X_OK) else ""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _harness_hint(answers: InitAnswers) -> None:
|
|
131
|
+
if not answers.author_bin:
|
|
132
|
+
name = answers.author_backend or AUTHOR_BACKENDS[0]
|
|
133
|
+
print(
|
|
134
|
+
f" no `{name}` binary found on PATH or in ~/.local/bin — the first climb would end\n"
|
|
135
|
+
f" with spawn-error. Install it: {HARNESS_INSTALL.get(name, 'see its docs')}\n"
|
|
136
|
+
" then run `outerloop init --force` so its path is recorded."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _token_login(token: str) -> str:
|
|
141
|
+
"""The login a PAT acts as (GET /user), or "" when GitHub does not say."""
|
|
142
|
+
req = urllib.request.Request(
|
|
143
|
+
f"{API}/user",
|
|
144
|
+
headers={"Authorization": f"token {token}", "Accept": "application/vnd.github+json"},
|
|
145
|
+
)
|
|
146
|
+
try:
|
|
147
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
148
|
+
return str(json.loads(resp.read()).get("login") or "")
|
|
149
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
150
|
+
return ""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _auth_is_fatal(problem: str) -> bool:
|
|
154
|
+
"""A credential the loop cannot open pull requests with fails setup; not
|
|
155
|
+
being able to ask GitHub right now (network, 5xx, rate limit) does not."""
|
|
156
|
+
return bool(problem) and not problem.startswith(
|
|
157
|
+
("could not reach GitHub", "could not read the App", "could not check the App")
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def author_key_env(backend: str) -> str:
|
|
162
|
+
"""The `.env` key naming `backend`'s model-key file: one rule for every
|
|
163
|
+
backend (`OUTERLOOP_CLAUDE_KEY_FILE`, `OUTERLOOP_CODEX_KEY_FILE`)."""
|
|
164
|
+
return f"OUTERLOOP_{(backend or AUTHOR_BACKENDS[0]).upper()}_KEY_FILE"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def author_key_path(backend: str, *, config_dir: Path = CONFIG_DIR) -> Path:
|
|
168
|
+
"""Where `backend`'s model key lives by default: `<config dir>/<backend>_key`,
|
|
169
|
+
the same rule `attempt.resolve_author_key_file` reads."""
|
|
170
|
+
return config_dir / f"{backend or AUTHOR_BACKENDS[0]}_key"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def write_author_key(backend: str, key: str, *, config_dir: Path = CONFIG_DIR) -> Path:
|
|
174
|
+
"""Write a pasted model key to its default file, owner-only (0600), no
|
|
175
|
+
trailing newline. Returns the path."""
|
|
176
|
+
path = author_key_path(backend, config_dir=config_dir)
|
|
177
|
+
write_private(path, key)
|
|
178
|
+
return path
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def write_config(
|
|
182
|
+
a: InitAnswers, token: str, pat_file: str, *, config_dir: Path = CONFIG_DIR
|
|
183
|
+
) -> tuple[Path, Path | None]:
|
|
184
|
+
"""Write the PAT file (only when a token is pasted) and the `.env`, both
|
|
185
|
+
owner-only (0600) — `start`/`tick_deploy` refuse a group/world-readable
|
|
186
|
+
`.env`, and a token file must never be wider. Returns (env_path, pat_path)."""
|
|
187
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
188
|
+
written_pat: Path | None = None
|
|
189
|
+
if token:
|
|
190
|
+
pat_path = config_dir / DEFAULT_PAT_FILE.name
|
|
191
|
+
# printf-style: no trailing newline — the deploy `cat`s this file into
|
|
192
|
+
# the git credential, and a trailing newline rides along and breaks auth.
|
|
193
|
+
write_private(pat_path, token)
|
|
194
|
+
written_pat = pat_path
|
|
195
|
+
pat_file = str(pat_path)
|
|
196
|
+
env_path = config_dir / ENV_FILE.name
|
|
197
|
+
write_private(env_path, render_env(a, pat_file))
|
|
198
|
+
return env_path, written_pat
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _check_repo_access(token: str, target: str) -> str:
|
|
202
|
+
"""Can this token write the target repo? "" on success, else a short reason.
|
|
203
|
+
Never raises — a check failure is a warning, not a reason to abandon config."""
|
|
204
|
+
req = urllib.request.Request(
|
|
205
|
+
f"{API}/repos/{target}",
|
|
206
|
+
headers={"Authorization": f"token {token}", "Accept": "application/vnd.github+json"},
|
|
207
|
+
)
|
|
208
|
+
try:
|
|
209
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
210
|
+
body = json.loads(resp.read())
|
|
211
|
+
perms = body.get("permissions") or {}
|
|
212
|
+
if not perms.get("push"):
|
|
213
|
+
return f"reaches {target} but lacks write access (it opens PRs)"
|
|
214
|
+
return ""
|
|
215
|
+
except urllib.error.HTTPError as exc:
|
|
216
|
+
if exc.code == 404:
|
|
217
|
+
return f"{target} not found, or the token cannot see it"
|
|
218
|
+
if exc.code == 401:
|
|
219
|
+
return "the token is invalid or expired (GitHub returned 401)"
|
|
220
|
+
if exc.code == 403:
|
|
221
|
+
return f"the token is not allowed to access {target} (GitHub returned 403)"
|
|
222
|
+
if exc.code == 429 or exc.code >= 500:
|
|
223
|
+
# GitHub's problem, not the credential's: never fatal
|
|
224
|
+
return f"could not reach GitHub: it returned {exc.code} for {target}"
|
|
225
|
+
return f"GitHub returned {exc.code} for {target}"
|
|
226
|
+
except urllib.error.URLError as exc:
|
|
227
|
+
return f"could not reach GitHub: {exc.reason}"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _check_app_access(provider: Any, target: str) -> str:
|
|
231
|
+
"""Can this App write the target repo? "" on success, else a short reason.
|
|
232
|
+
|
|
233
|
+
An installation token does not populate a repository's `permissions`
|
|
234
|
+
object, so the PAT check above reads all-false for an App that can push.
|
|
235
|
+
The App is asked the two questions that decide it: is the target among
|
|
236
|
+
the repositories its installation covers, and does the installation carry
|
|
237
|
+
write on contents and pull requests. Never raises."""
|
|
238
|
+
from outerloop.appauth import build_app_jwt
|
|
239
|
+
|
|
240
|
+
headers = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"}
|
|
241
|
+
try:
|
|
242
|
+
# Asked as the App itself: GitHub answers with the installation that
|
|
243
|
+
# covers this repository, permissions included, or 404. A public
|
|
244
|
+
# repository would answer a plain read for any token, and the
|
|
245
|
+
# installation's repository list paginates, so neither is asked.
|
|
246
|
+
jwt = build_app_jwt(provider.app_id, time.time(), provider._sign)
|
|
247
|
+
req = urllib.request.Request(
|
|
248
|
+
f"{API}/repos/{target}/installation",
|
|
249
|
+
headers={**headers, "Authorization": f"Bearer {jwt}"},
|
|
250
|
+
)
|
|
251
|
+
try:
|
|
252
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
253
|
+
installation = json.loads(resp.read())
|
|
254
|
+
except urllib.error.HTTPError as exc:
|
|
255
|
+
if exc.code == 404:
|
|
256
|
+
return f"the App is not installed on {target}; install it there"
|
|
257
|
+
raise
|
|
258
|
+
if int(installation.get("id", 0)) != int(provider.installation_id):
|
|
259
|
+
return (
|
|
260
|
+
f"the App is installed on {target} under installation "
|
|
261
|
+
f"{installation.get('id')}, but the App file records {provider.installation_id}"
|
|
262
|
+
)
|
|
263
|
+
perms = installation.get("permissions") or {}
|
|
264
|
+
missing = [k for k in ("contents", "issues", "pull_requests") if perms.get(k) != "write"]
|
|
265
|
+
if missing:
|
|
266
|
+
return (
|
|
267
|
+
f"the App lacks write on {', '.join(missing)} "
|
|
268
|
+
"(it pushes branches, opens PRs, and files and closes issues)"
|
|
269
|
+
)
|
|
270
|
+
return ""
|
|
271
|
+
except urllib.error.HTTPError as exc:
|
|
272
|
+
if exc.code == 429 or exc.code >= 500:
|
|
273
|
+
return f"could not reach GitHub: it returned {exc.code} while checking the App"
|
|
274
|
+
return f"GitHub returned {exc.code} while checking the App"
|
|
275
|
+
except urllib.error.URLError as exc:
|
|
276
|
+
return f"could not reach GitHub: {exc.reason}"
|
|
277
|
+
except Exception as exc: # a check failure is a warning, never fails setup
|
|
278
|
+
return f"could not check the App: {exc}"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def validate_pat(pat_file: str, target: str) -> str:
|
|
282
|
+
"""Best-effort: can the PAT in `pat_file` write the target repo?"""
|
|
283
|
+
try:
|
|
284
|
+
token = Path(pat_file).expanduser().read_text().strip()
|
|
285
|
+
except OSError as exc:
|
|
286
|
+
return f"could not read {pat_file}: {exc}"
|
|
287
|
+
if not token:
|
|
288
|
+
return f"{pat_file} is empty"
|
|
289
|
+
return _check_repo_access(token, target)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _ask(prompt: str, default: str = "", *, required: bool = False) -> str:
|
|
293
|
+
"""One interactive prompt with an optional default; re-asks while a required
|
|
294
|
+
answer is blank."""
|
|
295
|
+
suffix = f" [{default}]" if default else ""
|
|
296
|
+
while True:
|
|
297
|
+
got = input(f"{prompt}{suffix}: ").strip() or default
|
|
298
|
+
if got or not required:
|
|
299
|
+
return got
|
|
300
|
+
print(" (required)")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _collect(args: argparse.Namespace, interactive: bool) -> tuple[InitAnswers, str]:
|
|
304
|
+
"""Merge flags with prompts (when interactive) into answers + a PAT-file
|
|
305
|
+
path. `args.pat_file` names an existing file; otherwise, interactively, a
|
|
306
|
+
pasted token is returned separately to be written 0600."""
|
|
307
|
+
compute = args.compute or (_ask("Compute: slurm or local", "slurm") if interactive else "slurm")
|
|
308
|
+
compute = compute.lower()
|
|
309
|
+
target = args.target or (_ask("Target repo (owner/repo)", required=True) if interactive else "")
|
|
310
|
+
root = account = partition = ""
|
|
311
|
+
if compute == "slurm":
|
|
312
|
+
root = args.root or (
|
|
313
|
+
_ask("Slurm state root (shared filesystem)", required=True) if interactive else ""
|
|
314
|
+
)
|
|
315
|
+
account = args.account or (
|
|
316
|
+
_ask("Slurm account (blank = your default association)") if interactive else ""
|
|
317
|
+
)
|
|
318
|
+
partition = args.partition or (
|
|
319
|
+
_ask("Slurm partition (blank = Slurm default; a,b for a list)") if interactive else ""
|
|
320
|
+
)
|
|
321
|
+
# Author config is part of the full setup, not the focused --github-app run
|
|
322
|
+
# (that one is about auth). When asked, offer the fixed set, not a blank.
|
|
323
|
+
ask_author = interactive and not args.github_app
|
|
324
|
+
backend = args.author_backend or (
|
|
325
|
+
_ask("Author backend (claude or codex)", "claude") if ask_author else ""
|
|
326
|
+
)
|
|
327
|
+
model = args.author_model or (
|
|
328
|
+
_ask("Author model (blank = the backend's default)") if ask_author else ""
|
|
329
|
+
)
|
|
330
|
+
# An explicit --image is recorded here (absolute: jobs read it from their
|
|
331
|
+
# own directory); the published one is fetched in main, after every check
|
|
332
|
+
# that could still end the run.
|
|
333
|
+
image = str(Path(args.image).expanduser().absolute()) if args.image else ""
|
|
334
|
+
answers = InitAnswers(
|
|
335
|
+
compute=compute,
|
|
336
|
+
target=target,
|
|
337
|
+
root=root,
|
|
338
|
+
account=account,
|
|
339
|
+
partition=partition,
|
|
340
|
+
author_backend=backend,
|
|
341
|
+
author_model=model,
|
|
342
|
+
image=image,
|
|
343
|
+
author_bin=locate_harness(backend),
|
|
344
|
+
uncontained=bool(args.no_image) and not image,
|
|
345
|
+
)
|
|
346
|
+
return answers, (args.pat_file or "")
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
ORG_PROMPT = (
|
|
350
|
+
"Create the App under which account? (the repository's owner is the only one "
|
|
351
|
+
"the App can then be installed on; Enter to accept)"
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _owner_type(owner: str) -> str:
|
|
356
|
+
""" "User" or "Organization" for a GitHub account, or "" when the lookup
|
|
357
|
+
fails. GitHub creates Apps at different pages for the two, and a wrong
|
|
358
|
+
guess sends the adopter to a page that cannot create the App."""
|
|
359
|
+
req = urllib.request.Request(
|
|
360
|
+
f"{API}/users/{owner}", headers={"Accept": "application/vnd.github+json"}
|
|
361
|
+
)
|
|
362
|
+
try:
|
|
363
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
364
|
+
return str(json.loads(resp.read()).get("type", ""))
|
|
365
|
+
except Exception as exc:
|
|
366
|
+
log.warning("could not look up the account type of %s: %s", owner, exc)
|
|
367
|
+
return ""
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _app_owner_mismatch_note(conversion: dict[str, Any], expected_owner: str, target: str) -> str:
|
|
371
|
+
"""When the App landed under a different account than intended — GitHub's
|
|
372
|
+
fallback when someone who is not an org owner tries to create an org-owned
|
|
373
|
+
App — explain the path that still works, since a personal App does not
|
|
374
|
+
install on an org repo by default. Empty when the App landed as intended."""
|
|
375
|
+
actual = str((conversion.get("owner") or {}).get("login", "")).strip()
|
|
376
|
+
if not actual or actual.casefold() == expected_owner.casefold():
|
|
377
|
+
return ""
|
|
378
|
+
org = target.split("/")[0]
|
|
379
|
+
return (
|
|
380
|
+
f" note: the App was created under '{actual}', not '{expected_owner}'. Creating an\n"
|
|
381
|
+
f" App owned by an organization needs org-owner rights, so GitHub made a personal\n"
|
|
382
|
+
f" one — which will not install on {target} by default. To use it there anyway:\n"
|
|
383
|
+
f" make the App public (its Settings > 'Make public'), request its installation on\n"
|
|
384
|
+
f" {target}, and have an owner of {org} approve it; then rerun\n"
|
|
385
|
+
f" `outerloop init --force --github-app`. For lab ownership, you can later transfer\n"
|
|
386
|
+
f" the App to {org} from its Advanced settings — the key keeps working."
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _app_failure(answers: InitAnswers, slug: str, problem: str) -> int:
|
|
391
|
+
"""The App cannot write the target: say so, keep the credentials, and point
|
|
392
|
+
at the fix and the re-check. Never `start` — the check just failed."""
|
|
393
|
+
print(f" auth check: FAILED — {problem}", file=sys.stderr)
|
|
394
|
+
print(
|
|
395
|
+
f"outerloop init: the App '{slug}' cannot write {answers.target}. On\n"
|
|
396
|
+
f" https://github.com/apps/{slug}/installations/new (or the App's page under\n"
|
|
397
|
+
" Settings > Installations > Configure) install it on that repository and accept\n"
|
|
398
|
+
" contents, issues and pull-request write access, then run\n"
|
|
399
|
+
" `outerloop init --force --github-app` to re-check with these credentials.\n"
|
|
400
|
+
" If the App is under your personal account and the repository belongs to an\n"
|
|
401
|
+
" organization, make the App public first and have an org owner approve the install.",
|
|
402
|
+
file=sys.stderr,
|
|
403
|
+
)
|
|
404
|
+
return 1
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _github_app_recheck(answers: InitAnswers, app_json: Path) -> int:
|
|
408
|
+
"""Re-run Step 3 for an App this machine already created: confirm it can
|
|
409
|
+
write the target, then point the .env at it."""
|
|
410
|
+
from outerloop import appmanifest
|
|
411
|
+
from outerloop.appauth import app_provider_from_file
|
|
412
|
+
|
|
413
|
+
slug = app_json.name[len("github_app.") : -len(".json")]
|
|
414
|
+
print(f"Re-checking the existing App '{slug}' ({app_json}) against {answers.target}.")
|
|
415
|
+
try:
|
|
416
|
+
data = json.loads(app_json.read_text())
|
|
417
|
+
if not int(data.get("installation_id") or 0):
|
|
418
|
+
# the earlier run ended before the App was installed: look again
|
|
419
|
+
iid = appmanifest.capture_installation_id(
|
|
420
|
+
int(data["app_id"]), Path(str(data["private_key"])), answers.target.split("/")[0]
|
|
421
|
+
)
|
|
422
|
+
if iid:
|
|
423
|
+
appmanifest.set_installation_id(app_json, iid)
|
|
424
|
+
print(f" installation id {iid} recorded")
|
|
425
|
+
problem = _check_app_access(app_provider_from_file(app_json), answers.target)
|
|
426
|
+
except Exception as exc:
|
|
427
|
+
problem = f"could not read the App credentials: {exc}"
|
|
428
|
+
env_path = CONFIG_DIR / ENV_FILE.name
|
|
429
|
+
write_private(env_path, render_env(answers, app_file=str(app_json), bot_login=f"{slug}[bot]"))
|
|
430
|
+
if _auth_is_fatal(problem):
|
|
431
|
+
return _app_failure(answers, slug, problem)
|
|
432
|
+
print(f" auth check: {'ok' if not problem else 'WARNING — ' + problem}")
|
|
433
|
+
print(f"wrote {env_path}")
|
|
434
|
+
_author_key_hint(answers)
|
|
435
|
+
_harness_hint(answers)
|
|
436
|
+
print("next: outerloop start")
|
|
437
|
+
return 0
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _github_app_setup(
|
|
441
|
+
answers: InitAnswers, app_name: str, org: str, *, interactive: bool = True
|
|
442
|
+
) -> int:
|
|
443
|
+
"""The `--github-app` path: create the adopter's own App via the manifest
|
|
444
|
+
flow, write its creds, help install it, then point the .env at the App file.
|
|
445
|
+
Interactive by nature (a browser click + install), so no `--yes` variant."""
|
|
446
|
+
from outerloop import appmanifest
|
|
447
|
+
|
|
448
|
+
existing = sorted(CONFIG_DIR.glob("github_app.*.json"))
|
|
449
|
+
if len(existing) == 1:
|
|
450
|
+
# a re-run after fixing the installation: re-check the App this machine
|
|
451
|
+
# already has rather than creating a second one
|
|
452
|
+
reuse = True
|
|
453
|
+
if interactive:
|
|
454
|
+
answer = _ask(f"Reuse the App credentials at {existing[0]}? (Y/n)", "y")
|
|
455
|
+
reuse = not answer.lower().startswith("n")
|
|
456
|
+
if reuse:
|
|
457
|
+
return _github_app_recheck(answers, existing[0])
|
|
458
|
+
owner = org or answers.target.split("/")[0]
|
|
459
|
+
name = app_name or f"outerloop-{owner}"[:34] # GitHub caps App names at 34
|
|
460
|
+
code = appmanifest.request_manifest_code(
|
|
461
|
+
name, "https://github.com/outerloop-science/outerloop", org
|
|
462
|
+
)
|
|
463
|
+
if not code:
|
|
464
|
+
print("outerloop init: no code entered — nothing created", file=sys.stderr)
|
|
465
|
+
return 1
|
|
466
|
+
try:
|
|
467
|
+
conversion = appmanifest.convert_manifest(code)
|
|
468
|
+
except ValueError as exc:
|
|
469
|
+
print(f"outerloop init: {exc}", file=sys.stderr)
|
|
470
|
+
return 1
|
|
471
|
+
pem_path, app_json = appmanifest.save_app_creds(conversion, CONFIG_DIR)
|
|
472
|
+
repo = answers.target.split("/", 1)[-1]
|
|
473
|
+
print(f" created App '{conversion['slug']}'; credentials in {app_json} and {pem_path} (0600)")
|
|
474
|
+
mismatch = _app_owner_mismatch_note(conversion, owner, answers.target)
|
|
475
|
+
if mismatch:
|
|
476
|
+
print(mismatch, file=sys.stderr)
|
|
477
|
+
print()
|
|
478
|
+
print(f"Step 2 of 3: install the App on {answers.target}.")
|
|
479
|
+
print(f" Open {appmanifest.install_url(conversion)}")
|
|
480
|
+
print(f" choose 'Only select repositories', pick '{repo}', and click 'Install'.")
|
|
481
|
+
input("Press Enter here once GitHub shows the App as installed… ")
|
|
482
|
+
print()
|
|
483
|
+
print(f"Step 3 of 3: checking that the App can write {answers.target}.")
|
|
484
|
+
iid = appmanifest.capture_installation_id(int(conversion["id"]), pem_path, owner)
|
|
485
|
+
for _ in range(2):
|
|
486
|
+
if iid:
|
|
487
|
+
break
|
|
488
|
+
# not installed yet: a rerun of init would stop at the existing config,
|
|
489
|
+
# so ask again here rather than sending the adopter around the loop
|
|
490
|
+
print(
|
|
491
|
+
f" no installation found yet. Install the App at {appmanifest.install_url(conversion)}"
|
|
492
|
+
)
|
|
493
|
+
input("Press Enter once GitHub shows the App as installed… ")
|
|
494
|
+
iid = appmanifest.capture_installation_id(int(conversion["id"]), pem_path, owner)
|
|
495
|
+
if iid:
|
|
496
|
+
appmanifest.set_installation_id(app_json, iid)
|
|
497
|
+
print(f" installation id {iid} recorded")
|
|
498
|
+
# Self-verify end to end: mint a real installation token and check it can
|
|
499
|
+
# write the target — this is what confirms the whole flow actually worked.
|
|
500
|
+
from outerloop.appauth import app_provider_from_file
|
|
501
|
+
|
|
502
|
+
try:
|
|
503
|
+
problem = _check_app_access(app_provider_from_file(app_json), answers.target)
|
|
504
|
+
except Exception as exc: # a check failure is a warning, never fails setup
|
|
505
|
+
problem = f"could not read the App credentials: {exc}"
|
|
506
|
+
if _auth_is_fatal(problem):
|
|
507
|
+
write_private(
|
|
508
|
+
CONFIG_DIR / ENV_FILE.name,
|
|
509
|
+
render_env(answers, app_file=str(app_json), bot_login=f"{conversion['slug']}[bot]"),
|
|
510
|
+
)
|
|
511
|
+
return _app_failure(answers, str(conversion["slug"]), problem)
|
|
512
|
+
print(f" auth check: {'ok' if not problem else 'WARNING — ' + problem}")
|
|
513
|
+
else:
|
|
514
|
+
# the credentials are kept; nothing can run until the App is installed
|
|
515
|
+
write_private(
|
|
516
|
+
CONFIG_DIR / ENV_FILE.name,
|
|
517
|
+
render_env(answers, app_file=str(app_json), bot_login=f"{conversion['slug']}[bot]"),
|
|
518
|
+
)
|
|
519
|
+
return _app_failure(
|
|
520
|
+
answers, str(conversion["slug"]), f"the App is not installed on {answers.target}"
|
|
521
|
+
)
|
|
522
|
+
env_path = CONFIG_DIR / ENV_FILE.name
|
|
523
|
+
write_private(
|
|
524
|
+
env_path,
|
|
525
|
+
render_env(answers, app_file=str(app_json), bot_login=f"{conversion['slug']}[bot]"),
|
|
526
|
+
)
|
|
527
|
+
print(f"wrote {env_path}")
|
|
528
|
+
_author_key_hint(answers)
|
|
529
|
+
_harness_hint(answers)
|
|
530
|
+
print("next: outerloop start")
|
|
531
|
+
return 0
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def main(argv: list[str] | None = None) -> int:
|
|
535
|
+
parser = argparse.ArgumentParser(
|
|
536
|
+
prog="outerloop init",
|
|
537
|
+
description="Guided setup: asks for anything not given as a flag, checks the "
|
|
538
|
+
"credentials, and writes ~/.config/outerloop/.env.",
|
|
539
|
+
)
|
|
540
|
+
parser.add_argument("--compute", choices=["slurm", "local"], help="where the loop runs")
|
|
541
|
+
parser.add_argument("--target", help="the repo the agents work on, owner/repo")
|
|
542
|
+
parser.add_argument("--root", help="Slurm state root on the shared filesystem")
|
|
543
|
+
parser.add_argument(
|
|
544
|
+
"--account", help="Slurm account (optional; unset bills your default association)"
|
|
545
|
+
)
|
|
546
|
+
parser.add_argument(
|
|
547
|
+
"--partition", help="Slurm partition (optional; blank = default; a,b = list)"
|
|
548
|
+
)
|
|
549
|
+
parser.add_argument(
|
|
550
|
+
"--pat-file", dest="pat_file", help="path to an existing file holding a PAT"
|
|
551
|
+
)
|
|
552
|
+
parser.add_argument(
|
|
553
|
+
"--github-app",
|
|
554
|
+
dest="github_app",
|
|
555
|
+
action="store_true",
|
|
556
|
+
help="create your own GitHub App in a browser (one click) instead of a PAT",
|
|
557
|
+
)
|
|
558
|
+
parser.add_argument("--app-name", dest="app_name", help="name for the created GitHub App")
|
|
559
|
+
parser.add_argument("--org", help="create the App under this org (default: your account)")
|
|
560
|
+
parser.add_argument(
|
|
561
|
+
"--author-backend",
|
|
562
|
+
dest="author_backend",
|
|
563
|
+
help=f"climbing author's backend ({' or '.join(AUTHOR_BACKENDS)}; default claude)",
|
|
564
|
+
)
|
|
565
|
+
parser.add_argument("--author-model", dest="author_model", help="climbing author's model")
|
|
566
|
+
image_flags = parser.add_mutually_exclusive_group() # one or the other, never both
|
|
567
|
+
image_flags.add_argument(
|
|
568
|
+
"--image",
|
|
569
|
+
help="path to an Apptainer image to run sessions and evals in (default: the "
|
|
570
|
+
"published one, downloaded on Linux when apptainer is installed)",
|
|
571
|
+
)
|
|
572
|
+
image_flags.add_argument(
|
|
573
|
+
"--no-image",
|
|
574
|
+
dest="no_image",
|
|
575
|
+
action="store_true",
|
|
576
|
+
help="no image at all: runs stay uncontained even if one is already on disk",
|
|
577
|
+
)
|
|
578
|
+
parser.add_argument(
|
|
579
|
+
"--author-key-file",
|
|
580
|
+
dest="author_key_file",
|
|
581
|
+
help="path to an existing file holding the author's model API key",
|
|
582
|
+
)
|
|
583
|
+
parser.add_argument(
|
|
584
|
+
"--yes", "-y", action="store_true", help="non-interactive: use flags, do not prompt"
|
|
585
|
+
)
|
|
586
|
+
parser.add_argument(
|
|
587
|
+
"--force", action="store_true", help="overwrite an existing config without asking"
|
|
588
|
+
)
|
|
589
|
+
args = parser.parse_args(sys.argv[2:] if argv is None else argv)
|
|
590
|
+
interactive = not args.yes
|
|
591
|
+
|
|
592
|
+
answers, pat_file = _collect(args, interactive)
|
|
593
|
+
if not answers.target:
|
|
594
|
+
print("outerloop init: a target repo is required (--target owner/repo)", file=sys.stderr)
|
|
595
|
+
return 2
|
|
596
|
+
if answers.compute == "slurm" and not answers.root:
|
|
597
|
+
# the account and the partition are optional: Slurm's defaults apply
|
|
598
|
+
print("outerloop init: Slurm needs --root", file=sys.stderr)
|
|
599
|
+
return 2
|
|
600
|
+
if answers.author_backend and answers.author_backend not in AUTHOR_BACKENDS:
|
|
601
|
+
print(
|
|
602
|
+
f"outerloop init: author backend must be one of {', '.join(AUTHOR_BACKENDS)}",
|
|
603
|
+
file=sys.stderr,
|
|
604
|
+
)
|
|
605
|
+
return 2
|
|
606
|
+
if answers.image and not Path(answers.image).is_file():
|
|
607
|
+
# a typo here would surface as an uncontained loop later
|
|
608
|
+
print(f"outerloop init: --image {answers.image} is not a file", file=sys.stderr)
|
|
609
|
+
return 2
|
|
610
|
+
|
|
611
|
+
# Never clobber a working setup silently: a re-run of init on a configured
|
|
612
|
+
# machine must ask (or be told --force). Checked before any App is created.
|
|
613
|
+
env_path = CONFIG_DIR / ENV_FILE.name
|
|
614
|
+
if env_path.exists() and not args.force:
|
|
615
|
+
if not interactive:
|
|
616
|
+
print(f"outerloop init: {env_path} exists; pass --force to overwrite", file=sys.stderr)
|
|
617
|
+
return 1
|
|
618
|
+
if not _ask(f"{env_path} exists — overwrite it? (y/N)", "n").lower().startswith("y"):
|
|
619
|
+
print(
|
|
620
|
+
"outerloop init: kept the existing config (--force skips this check)",
|
|
621
|
+
file=sys.stderr,
|
|
622
|
+
)
|
|
623
|
+
return 1
|
|
624
|
+
|
|
625
|
+
# The author's model key: an existing file by flag, or (on the full
|
|
626
|
+
# interactive run, never the focused --github-app one) a hidden paste written
|
|
627
|
+
# to <config>/<backend>_key. Collected after the overwrite check so a
|
|
628
|
+
# declined run never asks for a secret.
|
|
629
|
+
if args.author_key_file:
|
|
630
|
+
key_file = Path(args.author_key_file).expanduser()
|
|
631
|
+
if not key_file.is_file() or not os.access(key_file, os.R_OK):
|
|
632
|
+
print(
|
|
633
|
+
f"outerloop init: --author-key-file {key_file} is not a readable file",
|
|
634
|
+
file=sys.stderr,
|
|
635
|
+
)
|
|
636
|
+
return 2
|
|
637
|
+
# absolute: climb jobs read the key from their own flight directory
|
|
638
|
+
answers.author_key_file = str(key_file.absolute())
|
|
639
|
+
if not answers.author_key_file and interactive and not args.github_app:
|
|
640
|
+
pasted = getpass.getpass(
|
|
641
|
+
f"API key for the {answers.author_backend or AUTHOR_BACKENDS[0]} author "
|
|
642
|
+
"(hidden; blank to set later): "
|
|
643
|
+
).strip()
|
|
644
|
+
if pasted:
|
|
645
|
+
key_path = write_author_key(answers.author_backend, pasted, config_dir=CONFIG_DIR)
|
|
646
|
+
answers.author_key_file = str(key_path)
|
|
647
|
+
print(f"wrote {key_path} (0600)")
|
|
648
|
+
|
|
649
|
+
# The image, only now: every check that could still end the run has passed
|
|
650
|
+
# and the overwrite question is answered, so a download is never wasted.
|
|
651
|
+
# An existing image is used, else the published one is fetched on a machine
|
|
652
|
+
# that can run it (asked first when interactive); --no-image opts out.
|
|
653
|
+
if not answers.image and not args.no_image:
|
|
654
|
+
# the container probe is for local mode: on Slurm the image runs on
|
|
655
|
+
# compute nodes, which the login node cannot speak for
|
|
656
|
+
answers.image = ensure_image(interactive=interactive, probe=(answers.compute == "local"))
|
|
657
|
+
|
|
658
|
+
# The App is the recommended credential (scoped, revocable, no plaintext
|
|
659
|
+
# token); the PAT is the fallback. Offer it first when interactive.
|
|
660
|
+
if not args.github_app and not pat_file and interactive:
|
|
661
|
+
choice = _ask(
|
|
662
|
+
"Auth — [app] create your own GitHub App (recommended) or [pat] paste a token",
|
|
663
|
+
"app",
|
|
664
|
+
).lower()
|
|
665
|
+
if choice.startswith("a"):
|
|
666
|
+
args.github_app = True
|
|
667
|
+
if args.github_app:
|
|
668
|
+
org = args.org or ""
|
|
669
|
+
if not org and interactive:
|
|
670
|
+
# a private App installs only on the account that created it, so
|
|
671
|
+
# the target's owner is the only default that can work
|
|
672
|
+
owner = answers.target.split("/")[0]
|
|
673
|
+
org = _ask(ORG_PROMPT, owner).strip()
|
|
674
|
+
if org:
|
|
675
|
+
kind = _owner_type(org)
|
|
676
|
+
if not kind and interactive:
|
|
677
|
+
answer = _ask(f"Is '{org}' an organization or a user account? [org/user]", "org")
|
|
678
|
+
kind = "User" if answer.strip().lower().startswith("u") else "Organization"
|
|
679
|
+
if kind == "User":
|
|
680
|
+
org = "" # a user account: GitHub's personal App page, not an org page
|
|
681
|
+
return _github_app_setup(answers, args.app_name or "", org, interactive=interactive)
|
|
682
|
+
|
|
683
|
+
token = ""
|
|
684
|
+
if not pat_file and interactive:
|
|
685
|
+
# re-collect only the token here so _collect stays pure of getpass in tests
|
|
686
|
+
token = getpass.getpass(
|
|
687
|
+
"Paste a GitHub PAT with write access to the target (hidden; blank to skip): "
|
|
688
|
+
).strip()
|
|
689
|
+
|
|
690
|
+
env_path, pat_path = write_config(answers, token, pat_file, config_dir=CONFIG_DIR)
|
|
691
|
+
print(f"wrote {env_path}")
|
|
692
|
+
if pat_path:
|
|
693
|
+
print(f"wrote {pat_path} (0600)")
|
|
694
|
+
effective_pat = str(pat_path) if pat_path else pat_file
|
|
695
|
+
if effective_pat:
|
|
696
|
+
problem = validate_pat(effective_pat, answers.target)
|
|
697
|
+
if _auth_is_fatal(problem):
|
|
698
|
+
# the loop opens pull requests with this token; sending the adopter
|
|
699
|
+
# to `start` would fail later and further from the cause
|
|
700
|
+
print(f" auth check: FAILED — {problem}", file=sys.stderr)
|
|
701
|
+
print(
|
|
702
|
+
f"outerloop init: fix the token in {effective_pat} (write access to "
|
|
703
|
+
f"{answers.target}), then run `outerloop init --force`",
|
|
704
|
+
file=sys.stderr,
|
|
705
|
+
)
|
|
706
|
+
return 1
|
|
707
|
+
print(f" auth check: {'ok' if not problem else 'WARNING — ' + problem}")
|
|
708
|
+
if not problem:
|
|
709
|
+
# record the login the kernel will post as: every own-comment and
|
|
710
|
+
# own-PR filter keys on it, and a default that is not this token's
|
|
711
|
+
# identity would hide the adopter's own PRs from the kernel
|
|
712
|
+
try:
|
|
713
|
+
login = _token_login(Path(effective_pat).expanduser().read_text().strip())
|
|
714
|
+
except OSError:
|
|
715
|
+
login = ""
|
|
716
|
+
if login:
|
|
717
|
+
write_private(env_path, render_env(answers, effective_pat, bot_login=login))
|
|
718
|
+
print(f" posting as {login} (OUTERLOOP_BOT_LOGIN)")
|
|
719
|
+
else:
|
|
720
|
+
print(
|
|
721
|
+
" the token's login could not be read — set OUTERLOOP_BOT_LOGIN in "
|
|
722
|
+
f"{env_path} before `outerloop start` (the tick skips a target without it)"
|
|
723
|
+
)
|
|
724
|
+
else:
|
|
725
|
+
print(
|
|
726
|
+
" OUTERLOOP_BOT_LOGIN not recorded (the check did not pass) — rerun "
|
|
727
|
+
"`outerloop init --force` with network access, or set it in "
|
|
728
|
+
f"{env_path} (the tick skips a target without it)"
|
|
729
|
+
)
|
|
730
|
+
else:
|
|
731
|
+
print(" no PAT set — add OUTERLOOP_PAT_FILE before the agents can open PRs")
|
|
732
|
+
_author_key_hint(answers)
|
|
733
|
+
_harness_hint(answers)
|
|
734
|
+
print("next: outerloop start")
|
|
735
|
+
return 0
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _author_key_hint(answers: InitAnswers) -> None:
|
|
739
|
+
if not answers.author_key_file:
|
|
740
|
+
print(
|
|
741
|
+
" no author key set — put it in "
|
|
742
|
+
f"{author_key_path(answers.author_backend, config_dir=CONFIG_DIR)} "
|
|
743
|
+
"before the first climb"
|
|
744
|
+
)
|