taskboy 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. taskboy/__init__.py +0 -0
  2. taskboy/adapters/__init__.py +0 -0
  3. taskboy/adapters/_util.py +30 -0
  4. taskboy/adapters/aws_read.py +107 -0
  5. taskboy/adapters/confluence.py +88 -0
  6. taskboy/adapters/github_api.py +564 -0
  7. taskboy/adapters/issues.py +435 -0
  8. taskboy/adapters/jira.py +263 -0
  9. taskboy/adapters/sentry.py +82 -0
  10. taskboy/adapters/slack_history.py +204 -0
  11. taskboy/assets.py +27 -0
  12. taskboy/audit.py +78 -0
  13. taskboy/broker.py +266 -0
  14. taskboy/classifier.py +219 -0
  15. taskboy/cli.py +161 -0
  16. taskboy/config.py +539 -0
  17. taskboy/dashboard/__init__.py +5 -0
  18. taskboy/dashboard/api.py +963 -0
  19. taskboy/dashboard/app.py +78 -0
  20. taskboy/dashboard/auth.py +117 -0
  21. taskboy/dashboard/editors.py +140 -0
  22. taskboy/dashboard/gitops.py +60 -0
  23. taskboy/dashboard/render.py +48 -0
  24. taskboy/debug_feed.py +170 -0
  25. taskboy/deploy/env.example +17 -0
  26. taskboy/deploy/git-cred-helper.py +36 -0
  27. taskboy/deploy/install.sh +101 -0
  28. taskboy/deploy/remote-update.sh +52 -0
  29. taskboy/deploy/taskboy-restart.path +10 -0
  30. taskboy/deploy/taskboy-restart.service +8 -0
  31. taskboy/deploy/taskboy.service +22 -0
  32. taskboy/hooks.py +208 -0
  33. taskboy/issue_runs.py +203 -0
  34. taskboy/llm.py +125 -0
  35. taskboy/main.py +269 -0
  36. taskboy/memory.py +59 -0
  37. taskboy/models.py +103 -0
  38. taskboy/mrkdwn.py +19 -0
  39. taskboy/notify.py +41 -0
  40. taskboy/orchestrator.py +401 -0
  41. taskboy/personality.py +17 -0
  42. taskboy/prompts.py +288 -0
  43. taskboy/quick.py +234 -0
  44. taskboy/redact.py +49 -0
  45. taskboy/repocache.py +117 -0
  46. taskboy/review_requests.py +388 -0
  47. taskboy/router.py +146 -0
  48. taskboy/runner.py +581 -0
  49. taskboy/scheduler.py +301 -0
  50. taskboy/secrets.py +83 -0
  51. taskboy/settings.py +19 -0
  52. taskboy/setup_checks.py +184 -0
  53. taskboy/setup_wizard.py +699 -0
  54. taskboy/skills.py +95 -0
  55. taskboy/slack.py +666 -0
  56. taskboy/slack_users.py +32 -0
  57. taskboy/started_messages.py +33 -0
  58. taskboy/store.py +1393 -0
  59. taskboy/task_actions.py +86 -0
  60. taskboy/templates/config.example.yaml +149 -0
  61. taskboy/templates/conventions.md +60 -0
  62. taskboy/templates/help.example.md +29 -0
  63. taskboy/templates/personality_agent.example.md +5 -0
  64. taskboy/templates/personality_reviewer.example.md +5 -0
  65. taskboy/templates/services/aws.yaml +7 -0
  66. taskboy/templates/services/confluence.yaml +4 -0
  67. taskboy/templates/services/github.yaml +14 -0
  68. taskboy/templates/services/jira.yaml +6 -0
  69. taskboy/templates/services/sentry.yaml +4 -0
  70. taskboy/templates/services/slack.yaml +9 -0
  71. taskboy/templates/skills/README.md +33 -0
  72. taskboy/templates/skills/discoverissues/SKILL.md +46 -0
  73. taskboy/templates/skills/implementapprovedissues/SKILL.md +41 -0
  74. taskboy/templates/skills/jira2pr/SKILL.md +66 -0
  75. taskboy/templates/skills/monitor/SKILL.md +32 -0
  76. taskboy/templates/skills/monitornew/SKILL.md +27 -0
  77. taskboy/templates/skills/refineissue/SKILL.md +35 -0
  78. taskboy/templates/skills/release/SKILL.md +36 -0
  79. taskboy/templates/skills/review/SKILL.md +89 -0
  80. taskboy/templates/skills/reviewandmonitor/SKILL.md +22 -0
  81. taskboy/templates/skills/reviews/SKILL.md +26 -0
  82. taskboy/templates/skills/slack2jira/SKILL.md +47 -0
  83. taskboy/templates/skills/slack2pr/SKILL.md +64 -0
  84. taskboy/templates/skills/spec2pr/SKILL.md +35 -0
  85. taskboy/templates/slack_app_manifest.yaml +48 -0
  86. taskboy/templates/task_started_messages.yaml +14 -0
  87. taskboy/ui_dist/assets/index-DmYxR9Qy.css +1 -0
  88. taskboy/ui_dist/assets/index-LDDO4iT2.js +15 -0
  89. taskboy/ui_dist/index.html +15 -0
  90. taskboy/workspace.py +86 -0
  91. taskboy-0.1.1.dist-info/METADATA +120 -0
  92. taskboy-0.1.1.dist-info/RECORD +95 -0
  93. taskboy-0.1.1.dist-info/WHEEL +5 -0
  94. taskboy-0.1.1.dist-info/entry_points.txt +2 -0
  95. taskboy-0.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,699 @@
1
+ """interactive first-run setup: `taskboy setup`.
2
+
3
+ walks each integration step by step: prints the manual admin-ui instructions, prompts for the
4
+ resulting ids/tokens, validates them live, and writes config/config.yaml plus one
5
+ config/services/<name>.yaml per connected service (comment-preserving, via ruamel) and a
6
+ sourceable .env for local secrets. every completed step is written immediately and every write
7
+ must pass load_config, so quitting mid-run and re-running resumes naturally — there is no
8
+ separate state file.
9
+
10
+ secrets are read with getpass or from a file path, never from argv, and are echoed back only
11
+ as `set (…last4)`.
12
+ """
13
+
14
+ import getpass
15
+ import json
16
+ import shutil
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from taskboy import assets, settings, setup_checks, skills
21
+ from taskboy.config import KNOWN_SERVICES, ConfigError, load_config, service_enabled_in
22
+
23
+ CONFIG_PATH = Path(settings.CONFIG_PATH)
24
+ ENV_PATH = Path(".env")
25
+ # packaged seed material; module-level so tests can monkeypatch
26
+ TEMPLATES_ROOT = assets.TEMPLATES_ROOT
27
+ EXAMPLE_PATH = TEMPLATES_ROOT / "config.example.yaml"
28
+
29
+ # .env keys that hold credentials (everything the service reads in secrets.py, plus claude)
30
+ SECRET_ENV_KEYS = [
31
+ "CLAUDE_CODE_OAUTH_TOKEN",
32
+ "SLACK_BOT_TOKEN",
33
+ "SLACK_APP_TOKEN",
34
+ "GITHUB_APP_ID",
35
+ "GITHUB_INSTALLATION_ID",
36
+ "GITHUB_APP_PRIVATE_KEY",
37
+ "REVIEWER_GITHUB_APP_ID",
38
+ "REVIEWER_GITHUB_INSTALLATION_ID",
39
+ "REVIEWER_GITHUB_APP_PRIVATE_KEY",
40
+ "JIRA_EMAIL",
41
+ "JIRA_API_TOKEN",
42
+ "SENTRY_TOKEN",
43
+ "DASHBOARD_GITHUB_TOKEN",
44
+ ]
45
+
46
+
47
+ # -- terminal helpers ---------------------------------------------------------
48
+
49
+
50
+ def say(text: str = "") -> None:
51
+ print(text)
52
+
53
+
54
+ def status(ok: bool | None, label: str, detail: str) -> None:
55
+ tag = "[ok]" if ok else "[--]" if ok is None else "[!!]"
56
+ print(f" {tag} {label}: {detail}")
57
+
58
+
59
+ def masked(value: str) -> str:
60
+ return f"set (…{value[-4:]})" if value else "not set"
61
+
62
+
63
+ def ask(prompt: str, default: str = "") -> str:
64
+ suffix = f" [{default}]" if default else ""
65
+ answer = input(f" {prompt}{suffix}: ").strip()
66
+ return answer or default
67
+
68
+
69
+ def ask_yes(prompt: str, default: bool = False) -> bool:
70
+ suffix = "[Y/n]" if default else "[y/N]"
71
+ answer = input(f" {prompt} {suffix}: ").strip().lower()
72
+ if not answer:
73
+ return default
74
+ return answer in ("y", "yes")
75
+
76
+
77
+ def ask_secret(prompt: str, current: str = "") -> str:
78
+ """hidden input; empty keeps the current value."""
79
+ suffix = f" [{masked(current)}]" if current else ""
80
+ answer = getpass.getpass(f" {prompt}{suffix}: ").strip()
81
+ return answer or current
82
+
83
+
84
+ def ask_pem(prompt: str, current: str = "") -> str:
85
+ """private keys are pasted as a file path, not typed."""
86
+ suffix = f" [{masked(current)}]" if current else ""
87
+ answer = input(f" {prompt} (path to .pem file){suffix}: ").strip()
88
+ if not answer:
89
+ return current
90
+ path = Path(answer).expanduser()
91
+ if not path.is_file():
92
+ say(f" file not found: {path}")
93
+ return ask_pem(prompt, current)
94
+ return path.read_text()
95
+
96
+
97
+ def ask_list(prompt: str, default: list[str] | None = None) -> list[str]:
98
+ current = ", ".join(default or [])
99
+ answer = ask(f"{prompt} (comma-separated)", current)
100
+ return [item.strip() for item in answer.split(",") if item.strip()]
101
+
102
+
103
+ # -- config + env io ----------------------------------------------------------
104
+
105
+
106
+ def _yaml():
107
+ from ruamel.yaml import YAML
108
+
109
+ yaml = YAML()
110
+ yaml.preserve_quotes = True
111
+ yaml.width = 100000 # never rewrap the long allowed_tools lines
112
+ return yaml
113
+
114
+
115
+ def service_config_path(name: str) -> Path:
116
+ return CONFIG_PATH.parent / "services" / f"{name}.yaml"
117
+
118
+
119
+ def seed_config() -> None:
120
+ """fresh instance directory: create the config dir and seed it from the packaged example."""
121
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
122
+ shutil.copyfile(EXAMPLE_PATH, CONFIG_PATH)
123
+ services_dir = CONFIG_PATH.parent / "services"
124
+ services_dir.mkdir(exist_ok=True)
125
+ for file in sorted((TEMPLATES_ROOT / "services").glob("*.yaml")):
126
+ target = services_dir / file.name
127
+ if not target.exists():
128
+ shutil.copyfile(file, target)
129
+ messages = CONFIG_PATH.parent / "task_started_messages.yaml"
130
+ if not messages.exists():
131
+ shutil.copyfile(TEMPLATES_ROOT / "task_started_messages.yaml", messages)
132
+ say(f"created {CONFIG_PATH} and {services_dir}/ from the packaged templates")
133
+
134
+
135
+ def load_config_data() -> dict:
136
+ """one merged mapping for the steps to mutate; save_config_data splits service sections back out."""
137
+ if not CONFIG_PATH.exists():
138
+ seed_config()
139
+ data = _yaml().load(CONFIG_PATH.read_text())
140
+ for name in KNOWN_SERVICES:
141
+ target = service_config_path(name)
142
+ if target.exists():
143
+ data[name] = _yaml().load(target.read_text())
144
+ return data
145
+
146
+
147
+ def save_config_data(data) -> None:
148
+ """write config.yaml + services/*.yaml, but only if the result still passes load_config (exit-64 guard)."""
149
+ import io
150
+
151
+ def dump(value) -> str:
152
+ buffer = io.StringIO()
153
+ _yaml().dump(value, buffer)
154
+ return buffer.getvalue()
155
+
156
+ # sections whose service file exists are written there; everything else stays in config.yaml
157
+ writes: list[tuple[Path, str]] = []
158
+ split_out: dict[str, object] = {}
159
+ for name in KNOWN_SERVICES:
160
+ target = service_config_path(name)
161
+ if name in data and target.exists():
162
+ split_out[name] = data.pop(name)
163
+ writes.append((target, dump(split_out[name])))
164
+ writes.append((CONFIG_PATH, dump(data)))
165
+ for name, section in split_out.items():
166
+ data[name] = section # restore the merged in-memory view for later steps
167
+ previous = {path: path.read_text() if path.exists() else None for path, _ in writes}
168
+ for path, content in writes:
169
+ path.write_text(content)
170
+ try:
171
+ load_config(str(CONFIG_PATH))
172
+ except ConfigError:
173
+ for path, _ in writes:
174
+ original = previous[path]
175
+ if original is not None:
176
+ path.write_text(original)
177
+ else:
178
+ path.unlink(missing_ok=True)
179
+ raise
180
+
181
+
182
+ def read_env() -> dict[str, str]:
183
+ """parse the sourceable .env this wizard writes: export KEY='value' (single-quoted, may span lines)."""
184
+ if not ENV_PATH.exists():
185
+ return {}
186
+ values: dict[str, str] = {}
187
+ text = ENV_PATH.read_text()
188
+ index = 0
189
+ while True:
190
+ start = text.find("export ", index)
191
+ if start == -1:
192
+ break
193
+ equals = text.find("='", start)
194
+ if equals == -1:
195
+ break
196
+ key = text[start + len("export ") : equals].strip()
197
+ end = equals + 2
198
+ raw = []
199
+ while end < len(text):
200
+ if text[end] == "'":
201
+ if text[end : end + 4] == "'\\''": # escaped single quote
202
+ raw.append("'")
203
+ end += 4
204
+ continue
205
+ break
206
+ raw.append(text[end])
207
+ end += 1
208
+ values[key] = "".join(raw)
209
+ index = end + 1
210
+ return values
211
+
212
+
213
+ def write_env(values: dict[str, str]) -> None:
214
+ lines = [
215
+ "# local secrets for taskboy, written by `taskboy setup`.",
216
+ "# load with: source .env (gitignored; on a deployed host use /etc/taskboy/env or AWS Secrets Manager)",
217
+ ]
218
+ for key in SECRET_ENV_KEYS:
219
+ value = values.get(key, "")
220
+ if value:
221
+ quoted = value.replace("'", "'\\''")
222
+ lines.append(f"export {key}='{quoted}'")
223
+ ENV_PATH.write_text("\n".join(lines) + "\n")
224
+ ENV_PATH.chmod(0o600)
225
+
226
+
227
+ # -- steps --------------------------------------------------------------------
228
+ # each step is step_<name>(data, env) -> None, mutating the ruamel config mapping and the
229
+ # env dict in place; run() saves both after every step.
230
+
231
+
232
+ def step_identity(data, env) -> None:
233
+ say("\n== Identity ==")
234
+ say(" Names appear in Slack, prompts, commits, and the dashboard. Pick anything you like.")
235
+ agent = data.setdefault("agent", {})
236
+ agent["name"] = ask("Main agent name", str(agent.get("name") or "Agent"))
237
+ github = data.setdefault("github", {})
238
+ github["commit_name"] = ask("Git commit author for the agent", str(github.get("commit_name") or agent["name"]))
239
+ github["commit_email"] = ask("Git commit email for the agent (GitHub derives the commit avatar from it)", str(github.get("commit_email") or ""))
240
+ cli_update = data.setdefault("cli_update", {})
241
+ cli_update["tzname"] = ask("Timezone (IANA name, for scheduled maintenance windows)", str(cli_update.get("tzname") or "America/Los_Angeles"))
242
+
243
+
244
+ def step_claude(data, env) -> None:
245
+ say("\n== Claude auth ==")
246
+ say(" The harness runs sub-agents through your Claude subscription.")
247
+ say(" On this machine, run: claude setup-token and paste the resulting token here.")
248
+ token = ask_secret("CLAUDE_CODE_OAUTH_TOKEN", env.get("CLAUDE_CODE_OAUTH_TOKEN", ""))
249
+ if not token:
250
+ say(" skipped — the echo runner works without it; the claude runner will not.")
251
+ return
252
+ env["CLAUDE_CODE_OAUTH_TOKEN"] = token
253
+ ok, detail = setup_checks.check_claude(token)
254
+ status(ok, "claude", detail)
255
+ if ok and str((data.get("orchestrator") or {}).get("runner")) == "echo" and ask_yes("Switch orchestrator.runner from echo to claude?", default=True):
256
+ data["orchestrator"]["runner"] = "claude"
257
+
258
+
259
+ def step_slack(data, env) -> None:
260
+ say("\n== Slack ==")
261
+ slack = data.setdefault("slack", {})
262
+ if not ask_yes("Configure Slack intake?", default=True):
263
+ slack["enabled"] = False
264
+ return
265
+ say(" 1. Create the app from the manifest: https://api.slack.com/apps -> From an app manifest")
266
+ say(f" manifest file: {TEMPLATES_ROOT / 'slack_app_manifest.yaml'}")
267
+ say(" 2. Socket Mode -> generate an app-level token with connections:write (xapp-…)")
268
+ say(" 3. Install App -> Install to Workspace, copy the bot token (xoxb-…)")
269
+ say(" 4. Invite the bot to every channel you plan to allow.")
270
+ env["SLACK_BOT_TOKEN"] = ask_secret("Bot token (xoxb-…)", env.get("SLACK_BOT_TOKEN", ""))
271
+ env["SLACK_APP_TOKEN"] = ask_secret("App-level token (xapp-…)", env.get("SLACK_APP_TOKEN", ""))
272
+ slack["team_id"] = ask("Workspace team id (T…)", str(slack.get("team_id") or ""))
273
+ slack["enabled"] = bool(slack["team_id"])
274
+ if not slack["enabled"]:
275
+ say(" slack left disabled — set the team id and re-run `taskboy setup --step slack`")
276
+ if env["SLACK_BOT_TOKEN"]:
277
+ ok, detail = setup_checks.check_slack(env["SLACK_BOT_TOKEN"], slack["team_id"])
278
+ status(ok, "slack", detail)
279
+ ok, detail = setup_checks.check_slack_app_token(env["SLACK_APP_TOKEN"])
280
+ status(ok, "slack app token", detail)
281
+ channels = ask_list("Allowed channel ids (empty = any channel the bot is invited to)", list(slack.get("allowed_channels") or []))
282
+ slack["allowed_channels"] = channels
283
+ slack["debug_channel"] = ask("Debug channel id (empty disables the debug feed)", str(slack.get("debug_channel") or ""))
284
+ if env["SLACK_BOT_TOKEN"]:
285
+ for channel in channels + ([slack["debug_channel"]] if slack["debug_channel"] else []):
286
+ ok, detail = setup_checks.check_slack_channel(env["SLACK_BOT_TOKEN"], channel)
287
+ status(ok, "channel", detail)
288
+ roles = data.setdefault("roles", {})
289
+ admin = roles.setdefault("admin", {"members": [], "allowed_profiles": ["read_only", "standard", "deep"], "model_override": True, "max_budget_usd": None})
290
+ members = [member for member in ask_list("Admin Slack user ids (U…)", [m for m in admin.get("members") or [] if m not in ("cli", "YOUR_SLACK_USER_ID")])]
291
+ admin["members"] = members + ["cli"]
292
+
293
+
294
+ def step_github(data, env) -> None:
295
+ say("\n== GitHub App (main agent) ==")
296
+ github = data.setdefault("github", {})
297
+ if not ask_yes("Configure GitHub?", default=True):
298
+ github["enabled"] = False
299
+ return
300
+ github["enabled"] = True
301
+ say(" Create a GitHub App (org Settings -> Developer settings -> GitHub Apps -> New):")
302
+ say(" permissions: Contents read/write, Metadata read, Pull requests read/write; no webhook.")
303
+ say(" install it on every repository the agent may touch, then note the App ID and the")
304
+ say(" installation id (the number in the installation page URL) and download a private key.")
305
+ env["GITHUB_APP_ID"] = ask("App ID", env.get("GITHUB_APP_ID", ""))
306
+ env["GITHUB_INSTALLATION_ID"] = ask("Installation id", env.get("GITHUB_INSTALLATION_ID", ""))
307
+ env["GITHUB_APP_PRIVATE_KEY"] = ask_pem("Private key", env.get("GITHUB_APP_PRIVATE_KEY", ""))
308
+ github["approved_repos"] = ask_list("Approved repositories (owner/repo)", list(github.get("approved_repos") or []))
309
+ self_repo = ask("This agent's own repo, if it may work on itself (owner/repo, empty = none)", str(github.get("self_repo") or ""))
310
+ github["self_repo"] = self_repo
311
+ if self_repo and self_repo not in github["approved_repos"]:
312
+ github["approved_repos"].append(self_repo)
313
+ github["protected_branch_patterns"] = ask_list("Protected branch patterns", list(github.get("protected_branch_patterns") or ["main"]))
314
+ if env["GITHUB_APP_ID"]:
315
+ ok, detail = setup_checks.check_github_app(env["GITHUB_APP_ID"], env["GITHUB_INSTALLATION_ID"], env["GITHUB_APP_PRIVATE_KEY"], github["approved_repos"])
316
+ status(ok, "github app", detail)
317
+ review_requests = github.setdefault("review_requests", {"enabled": False, "poll_interval_seconds": 60, "notify_channel": ""})
318
+ review_requests["enabled"] = ask_yes("Poll for PRs where the agent is a requested reviewer?", bool(review_requests.get("enabled")))
319
+
320
+
321
+ def step_reviewer(data, env) -> None:
322
+ say("\n== Reviewer persona (optional) ==")
323
+ say(" A second GitHub-only persona that reviews the main agent's PRs. It needs its OWN")
324
+ say(" GitHub App because GitHub forbids requesting changes on your own pull requests.")
325
+ reviewer = data.setdefault("reviewer", {})
326
+ if not ask_yes("Enable the reviewer?", bool(reviewer.get("enabled"))):
327
+ reviewer["enabled"] = False
328
+ return
329
+ reviewer["name"] = ask("Reviewer name", str(reviewer.get("name") or "Reviewer"))
330
+ reviewer["commit_name"] = ask("Reviewer git commit author", str(reviewer.get("commit_name") or reviewer["name"]))
331
+ reviewer["commit_email"] = ask("Reviewer git commit email (required)", str(reviewer.get("commit_email") or ""))
332
+ say(" Create a second GitHub App with the same permissions as the main one (a distinct avatar helps).")
333
+ env["REVIEWER_GITHUB_APP_ID"] = ask("Reviewer App ID", env.get("REVIEWER_GITHUB_APP_ID", ""))
334
+ env["REVIEWER_GITHUB_INSTALLATION_ID"] = ask("Reviewer installation id", env.get("REVIEWER_GITHUB_INSTALLATION_ID", ""))
335
+ env["REVIEWER_GITHUB_APP_PRIVATE_KEY"] = ask_pem("Reviewer private key", env.get("REVIEWER_GITHUB_APP_PRIVATE_KEY", ""))
336
+ if env["REVIEWER_GITHUB_APP_ID"]:
337
+ approved = list((data.get("github") or {}).get("approved_repos") or [])
338
+ ok, detail = setup_checks.check_github_app(env["REVIEWER_GITHUB_APP_ID"], env["REVIEWER_GITHUB_INSTALLATION_ID"], env["REVIEWER_GITHUB_APP_PRIVATE_KEY"], approved)
339
+ status(ok, "reviewer github app", detail)
340
+ reviewer["enabled"] = True
341
+ reviewer["review_agent_prs"] = ask_yes("Auto-review every PR the main agent opens?", bool(reviewer.get("review_agent_prs", True)))
342
+
343
+
344
+ def step_jira(data, env) -> None:
345
+ say("\n== Jira + Confluence (optional) ==")
346
+ jira = data.setdefault("jira", {})
347
+ if not ask_yes("Configure Jira?", default=False):
348
+ jira["enabled"] = False
349
+ return
350
+ say(" Use a dedicated service account with a scoped project role; create an API token at")
351
+ say(" https://id.atlassian.com/manage-profile/security/api-tokens")
352
+ jira["site"] = ask("Jira site url (https://your-org.atlassian.net)", str(jira.get("site") or ""))
353
+ jira["enabled"] = bool(jira["site"])
354
+ env["JIRA_EMAIL"] = ask("Service account email", env.get("JIRA_EMAIL", ""))
355
+ env["JIRA_API_TOKEN"] = ask_secret("API token", env.get("JIRA_API_TOKEN", ""))
356
+ ok, detail = setup_checks.check_jira(jira["site"], env["JIRA_EMAIL"], env["JIRA_API_TOKEN"])
357
+ status(ok, "jira", detail)
358
+ jira["projects"] = ask_list("Project keys the agent may touch", list(jira.get("projects") or []))
359
+ for key in jira["projects"]:
360
+ ok, detail = setup_checks.check_jira_project(jira["site"], env["JIRA_EMAIL"], env["JIRA_API_TOKEN"], key)
361
+ status(ok, "project", detail)
362
+ jira["story_points_field"] = ask("Story points custom field (e.g. customfield_10016, empty = unused)", str(jira.get("story_points_field") or ""))
363
+ confluence = data.setdefault("confluence", {})
364
+ if ask_yes("Configure Confluence with the same account?", default=False):
365
+ confluence["site"] = ask("Confluence site url", str(confluence.get("site") or jira["site"]))
366
+ confluence["enabled"] = bool(confluence["site"])
367
+ ok, detail = setup_checks.check_confluence(confluence["site"], env["JIRA_EMAIL"], env["JIRA_API_TOKEN"])
368
+ status(ok, "confluence", detail)
369
+ else:
370
+ confluence["enabled"] = False
371
+
372
+
373
+ def step_sentry(data, env) -> None:
374
+ say("\n== Sentry (optional) ==")
375
+ sentry = data.setdefault("sentry", {})
376
+ if not ask_yes("Configure Sentry?", default=False):
377
+ sentry["enabled"] = False
378
+ return
379
+ say(" Create an internal integration with read-only Project / Issue & Event / Organization scopes.")
380
+ sentry["organization"] = ask("Organization slug", str(sentry.get("organization") or ""))
381
+ sentry["enabled"] = bool(sentry["organization"])
382
+ env["SENTRY_TOKEN"] = ask_secret("Integration token", env.get("SENTRY_TOKEN", ""))
383
+ ok, detail = setup_checks.check_sentry(sentry["organization"], env["SENTRY_TOKEN"])
384
+ status(ok, "sentry", detail)
385
+ sentry["projects"] = ask_list("Project slugs (empty = all readable)", list(sentry.get("projects") or []))
386
+
387
+
388
+ def step_aws(data, env) -> None:
389
+ say("\n== AWS diagnostics (optional) ==")
390
+ aws = data.setdefault("aws", {})
391
+ if not ask_yes("Configure read-only AWS diagnostics?", default=False):
392
+ aws["enabled"] = False
393
+ return
394
+ aws["allowed_services"] = ask_list("Allowed services (e.g. logs, cloudwatch, lambda, s3)", list(aws.get("allowed_services") or []))
395
+ aws["enabled"] = bool(aws["allowed_services"])
396
+ aws["allowed_regions"] = ask_list("Allowed regions", list(aws.get("allowed_regions") or ["us-east-1"]))
397
+ roles = dict(aws.get("diagnostics_role_arns") or {})
398
+ while ask_yes("Add a per-environment diagnostics role?", default=not roles):
399
+ environment = ask("Environment name (e.g. staging)")
400
+ arn = ask("Role ARN")
401
+ if environment and arn:
402
+ roles[environment] = arn
403
+ ok, detail = setup_checks.check_aws_role(arn, "taskboy")
404
+ status(ok, f"assume-role {environment}", detail + ("" if ok else " (needs local aws credentials — best-effort check)"))
405
+ if roles:
406
+ aws["diagnostics_role_arns"] = roles
407
+
408
+
409
+ def step_dashboard(data, env) -> None:
410
+ say("\n== Dashboard (optional) ==")
411
+ dashboard = data.setdefault("dashboard", {})
412
+ if not ask_yes("Enable the web dashboard?", bool(dashboard.get("enabled"))):
413
+ dashboard["enabled"] = False
414
+ return
415
+ dashboard["enabled"] = True
416
+ dashboard["allowed_email_domain"] = ask("Allowed email domain (viewers)", str(dashboard.get("allowed_email_domain") or ""))
417
+ dashboard["admin_emails"] = ask_list("Admin emails", list(dashboard.get("admin_emails") or []))
418
+ dashboard["public_url"] = ask("Public url (empty disables slack links)", str(dashboard.get("public_url") or ""))
419
+ dashboard["dev_user_email"] = ask("Local-dev stand-in identity", str(dashboard.get("dev_user_email") or "dev@example.com"))
420
+ auto_commit = dashboard.setdefault("auto_commit", {})
421
+ repo = ask("Auto-commit dashboard edits to repo (owner/repo, empty disables)", str(auto_commit.get("repo") or ""))
422
+ auto_commit["repo"] = repo
423
+ if repo:
424
+ auto_commit["committer_email"] = ask("Auto-commit committer email", str(auto_commit.get("committer_email") or ""))
425
+ env["DASHBOARD_GITHUB_TOKEN"] = ask_secret("Fine-grained PAT (contents read/write on that repo)", env.get("DASHBOARD_GITHUB_TOKEN", ""))
426
+ ok, detail = setup_checks.check_github_pat_repo(env["DASHBOARD_GITHUB_TOKEN"], repo)
427
+ status(ok, "dashboard pat", detail)
428
+
429
+
430
+ def step_content(data, env) -> None:
431
+ say("\n== Conventions, personality & help ==")
432
+ conventions = data.setdefault("conventions", {})
433
+ current = str(conventions.get("file") or "")
434
+ if ask_yes("Set up an engineering-conventions doc? (injected into every repo task)", default=not current):
435
+ target = CONFIG_PATH.parent / "conventions.md"
436
+ if not target.exists():
437
+ shutil.copyfile(TEMPLATES_ROOT / "conventions.md", target)
438
+ say(f" created {target} from the template — fill it in with your organization's rules.")
439
+ conventions["file"] = ask("Conventions file (relative to config.yaml)", current or "conventions.md")
440
+ agent = data.setdefault("agent", {})
441
+ if ask_yes("Give the agent a personality file?", default=bool(agent.get("personality_file"))):
442
+ target = CONFIG_PATH.parent / "personality_agent.md"
443
+ if not target.exists():
444
+ shutil.copyfile(TEMPLATES_ROOT / "personality_agent.example.md", target)
445
+ agent["personality_file"] = ask("Personality file", str(agent.get("personality_file") or "personality_agent.md"))
446
+ reviewer = data.setdefault("reviewer", {})
447
+ if reviewer.get("enabled") and ask_yes("Give the reviewer a personality file?", default=bool(reviewer.get("personality_file"))):
448
+ target = CONFIG_PATH.parent / "personality_reviewer.md"
449
+ if not target.exists():
450
+ shutil.copyfile(TEMPLATES_ROOT / "personality_reviewer.example.md", target)
451
+ reviewer["personality_file"] = ask("Reviewer personality file", str(reviewer.get("personality_file") or "personality_reviewer.md"))
452
+ help_section = data.setdefault("help", {})
453
+ if ask_yes("Set up a curated /help reply? (answered instantly in Slack, no task created)", default=bool(help_section.get("file"))):
454
+ target = CONFIG_PATH.parent / "help.md"
455
+ if not target.exists():
456
+ content = (TEMPLATES_ROOT / "help.example.md").read_text()
457
+ # drop the instruction-comment header; it's for readers of the template, not slack users
458
+ content = "\n".join(line for line in content.splitlines() if not line.startswith("#")).lstrip("\n") + "\n"
459
+ content = content.replace("{{agent_name}}", str((data.get("agent") or {}).get("name") or "Agent"))
460
+ content = content.replace("Dashboard: {{dashboard_url}}\n\n", f"Dashboard: {data['dashboard']['public_url']}\n\n" if (data.get("dashboard") or {}).get("public_url") else "")
461
+ target.write_text(content)
462
+ say(f" created {target} from the template — trim it to the skills you actually installed.")
463
+ help_section["file"] = ask("Help file (relative to config.yaml)", str(help_section.get("file") or "help.md"))
464
+
465
+
466
+ def template_variables(data) -> dict[str, str]:
467
+ """the {{var}} values used when instantiating skill templates, derived from config answers."""
468
+ github = data.get("github") or {}
469
+ approved = list(github.get("approved_repos") or [])
470
+ org = approved[0].split("/")[0] if approved else ""
471
+ jira = data.get("jira") or {}
472
+ site = str(jira.get("site") or "").removeprefix("https://").removeprefix("http://")
473
+ return {
474
+ "agent_name": str((data.get("agent") or {}).get("name") or "Agent"),
475
+ "reviewer_name": str((data.get("reviewer") or {}).get("name") or "Reviewer"),
476
+ "github_org": org,
477
+ "repo_list": ", ".join(f"`{repo.split('/', 1)[-1]}`" for repo in approved),
478
+ "self_repo": str(github.get("self_repo") or ""),
479
+ "pr_target_branch": "", # asked below
480
+ "jira_project": str((jira.get("projects") or [""])[0]),
481
+ "jira_site": site,
482
+ "conventions_file": str((data.get("conventions") or {}).get("file") or ""),
483
+ }
484
+
485
+
486
+ def step_skills(data, env) -> None:
487
+ say("\n== Skills ==")
488
+ templates_dir = TEMPLATES_ROOT / "skills"
489
+ available = sorted(entry.name for entry in templates_dir.iterdir() if (entry / "SKILL.md").is_file())
490
+ if not available:
491
+ say(" no skill templates found — skipping")
492
+ return
493
+ if not ask_yes("Install skills from the template library?", default=True):
494
+ return
495
+ variables = template_variables(data)
496
+ github = data.get("github") or {}
497
+ jira_configured = service_enabled_in(data, "jira")
498
+ self_repo = service_enabled_in(data, "github") and bool(github.get("self_repo"))
499
+ say(" Available templates (× = needs an integration you haven't configured):")
500
+ disabled: dict[str, str] = {}
501
+ for name in available:
502
+ needs_jira = name in ("jira2pr", "slack2jira")
503
+ needs_self = name in ("release", "discoverissues")
504
+ if needs_jira and not jira_configured:
505
+ disabled[name] = "needs jira"
506
+ if needs_self and not self_repo:
507
+ disabled[name] = "needs github.self_repo"
508
+ for i, name in enumerate(available, 1):
509
+ note = f" × {disabled[name]}" if name in disabled else ""
510
+ say(f" {i:2}. /{name}{note}")
511
+ answer = ask("Install which? (numbers/names, comma-separated, or 'all')", "all")
512
+ if answer.strip().lower() == "all":
513
+ selected = [name for name in available if name not in disabled]
514
+ else:
515
+ selected = []
516
+ for token in answer.split(","):
517
+ token = token.strip().lstrip("/")
518
+ if token.isdigit() and 1 <= int(token) <= len(available):
519
+ selected.append(available[int(token) - 1])
520
+ elif token in available:
521
+ selected.append(token)
522
+ if any(name in ("jira2pr", "slack2pr") for name in selected):
523
+ variables["pr_target_branch"] = ask("Default PR target branch", "main")
524
+ skills_root = Path(settings.SKILLS_ROOT)
525
+ for name in selected:
526
+ content = (templates_dir / name / "SKILL.md").read_text()
527
+ # transitive requires must be installed too, or the skill fails to render at task time
528
+ for required in skills.load(str(templates_dir), name).requires:
529
+ if required not in selected and required not in skills.available(str(skills_root)):
530
+ selected.append(required)
531
+ for key, value in variables.items():
532
+ content = content.replace("{{" + key + "}}", value)
533
+ if "{{" in content:
534
+ leftover = content[content.find("{{") : content.find("{{") + 40]
535
+ say(f" [!!] /{name}: unfilled placeholder {leftover!r} — skipped")
536
+ continue
537
+ destination = skills_root / name / "SKILL.md"
538
+ destination.parent.mkdir(parents=True, exist_ok=True)
539
+ destination.write_text(content)
540
+ skills.load(str(skills_root), name) # same validation the dashboard editor runs
541
+ say(f" installed /{name}")
542
+
543
+
544
+ def step_secrets(data, env) -> None:
545
+ say("\n== Secrets destination ==")
546
+ say(f" Local secrets are in {ENV_PATH} (source it before `taskboy run`).")
547
+ say(" For a deployed host you can either put the same variables in /etc/taskboy/env")
548
+ say(" or push one JSON bundle to AWS Secrets Manager (what the shell repo's deploy setup expects).")
549
+ if not ask_yes("Push the bundle to AWS Secrets Manager now?", default=False):
550
+ return
551
+ environment = ask("Environment name", "staging")
552
+ region = ask("AWS region", settings.REGION)
553
+ secret_name = ask("Secret name", f"TASKBOY_SECRETS_{environment.upper()}")
554
+ bundle = {key.lower(): env[key] for key in SECRET_ENV_KEYS if env.get(key)}
555
+ try:
556
+ import boto3
557
+ from botocore.exceptions import ClientError
558
+
559
+ client = boto3.client("secretsmanager", region_name=region)
560
+ try:
561
+ client.create_secret(Name=secret_name, SecretString=json.dumps(bundle))
562
+ except ClientError as e:
563
+ if e.response["Error"]["Code"] != "ResourceExistsException":
564
+ raise
565
+ client.put_secret_value(SecretId=secret_name, SecretString=json.dumps(bundle))
566
+ except Exception as e:
567
+ status(False, "secrets push", f"{type(e).__name__}: {e}")
568
+ return
569
+ ok, detail = setup_checks.check_aws_secret(secret_name, region)
570
+ status(ok, "secrets push", detail)
571
+
572
+
573
+ STEPS = [
574
+ ("identity", step_identity),
575
+ ("claude", step_claude),
576
+ ("slack", step_slack),
577
+ ("github", step_github),
578
+ ("reviewer", step_reviewer),
579
+ ("jira", step_jira),
580
+ ("sentry", step_sentry),
581
+ ("aws", step_aws),
582
+ ("dashboard", step_dashboard),
583
+ ("content", step_content),
584
+ ("skills", step_skills),
585
+ ("secrets", step_secrets),
586
+ ]
587
+
588
+
589
+ # -- check mode ---------------------------------------------------------------
590
+
591
+
592
+ def run_checks(no_network: bool = False) -> int:
593
+ """non-interactive validation of config.yaml + reachable credentials; exit 64 on config errors."""
594
+ import os
595
+
596
+ try:
597
+ config = load_config(str(CONFIG_PATH))
598
+ except ConfigError as e:
599
+ status(False, "config", str(e))
600
+ return 64
601
+ status(True, "config", f"{CONFIG_PATH} loads (agent: {config.agent_name})")
602
+ env = {**read_env(), **{key: os.environ[key] for key in SECRET_ENV_KEYS if os.environ.get(key)}}
603
+ if no_network:
604
+ for key in SECRET_ENV_KEYS:
605
+ status(True if env.get(key) else None, key, masked(env.get(key, "")))
606
+ return 0
607
+ failures = 0
608
+
609
+ def report(label: str, result: tuple[bool, str]) -> None:
610
+ nonlocal failures
611
+ ok, detail = result
612
+ status(ok, label, detail)
613
+ failures += 0 if ok else 1
614
+
615
+ if env.get("CLAUDE_CODE_OAUTH_TOKEN") and config.runner == "claude":
616
+ report("claude", setup_checks.check_claude(env["CLAUDE_CODE_OAUTH_TOKEN"]))
617
+ if config.slack.enabled:
618
+ if env.get("SLACK_BOT_TOKEN"):
619
+ report("slack", setup_checks.check_slack(env["SLACK_BOT_TOKEN"], config.slack.team_id))
620
+ else:
621
+ status(False, "slack", "slack is enabled but SLACK_BOT_TOKEN missing")
622
+ failures += 1
623
+ github = config.raw.get("github") or {}
624
+ if config.service_enabled("github") and env.get("GITHUB_APP_ID"):
625
+ report("github app", setup_checks.check_github_app(env["GITHUB_APP_ID"], env.get("GITHUB_INSTALLATION_ID", ""), env.get("GITHUB_APP_PRIVATE_KEY", ""), list(github.get("approved_repos") or [])))
626
+ if config.reviewer.enabled:
627
+ if env.get("REVIEWER_GITHUB_APP_ID"):
628
+ report("reviewer github app", setup_checks.check_github_app(env["REVIEWER_GITHUB_APP_ID"], env.get("REVIEWER_GITHUB_INSTALLATION_ID", ""), env.get("REVIEWER_GITHUB_APP_PRIVATE_KEY", "")))
629
+ else:
630
+ status(False, "reviewer", "reviewer.enabled but REVIEWER_GITHUB_APP_ID missing")
631
+ failures += 1
632
+ jira = config.raw.get("jira") or {}
633
+ if config.service_enabled("jira") and env.get("JIRA_API_TOKEN"):
634
+ report("jira", setup_checks.check_jira(str(jira["site"]), env.get("JIRA_EMAIL", ""), env["JIRA_API_TOKEN"]))
635
+ sentry = config.raw.get("sentry") or {}
636
+ if config.service_enabled("sentry") and env.get("SENTRY_TOKEN"):
637
+ report("sentry", setup_checks.check_sentry(str(sentry["organization"]), env["SENTRY_TOKEN"]))
638
+ if config.dashboard.auto_commit_enabled and env.get("DASHBOARD_GITHUB_TOKEN"):
639
+ report("dashboard pat", setup_checks.check_github_pat_repo(env["DASHBOARD_GITHUB_TOKEN"], config.dashboard.commit_repo))
640
+ say("")
641
+ say("all checks passed" if failures == 0 else f"{failures} check(s) failed")
642
+ return 0 if failures == 0 else 1
643
+
644
+
645
+ # -- entry point --------------------------------------------------------------
646
+
647
+
648
+ def run(args) -> int:
649
+ if args.check:
650
+ return run_checks(no_network=args.no_validate)
651
+
652
+ if args.local:
653
+ if not CONFIG_PATH.exists():
654
+ seed_config()
655
+ say(f"local demo ready: {CONFIG_PATH} (runner: echo — no credentials needed)")
656
+ say("try it:")
657
+ say(" taskboy run # start the service in the foreground")
658
+ say(' taskboy inject "say hi" --watch # in another terminal')
659
+ say("then run `taskboy setup` for the full guided setup.")
660
+ return 0
661
+
662
+ say("taskboy setup — answers are written to config/config.yaml, config/services/*.yaml, and .env")
663
+ say("after every step, so you can quit (ctrl-c) and re-run anytime; existing values show as defaults.")
664
+ data = load_config_data()
665
+ env = read_env()
666
+ steps = STEPS if not args.step else [(name, fn) for name, fn in STEPS if name == args.step]
667
+ if args.step and not steps:
668
+ say(f"unknown step {args.step!r} — choose from: {', '.join(name for name, _ in STEPS)}")
669
+ return 2
670
+ for name, fn in steps:
671
+ try:
672
+ fn(data, env)
673
+ except KeyboardInterrupt:
674
+ say("\ninterrupted — progress so far is saved; re-run `taskboy setup` to continue")
675
+ return 130
676
+ try:
677
+ save_config_data(data)
678
+ except ConfigError as e:
679
+ say(f" [!!] {e} — this step was not saved; re-run `taskboy setup --step {name}`")
680
+ data = load_config_data()
681
+ write_env(env)
682
+ say("\n== Final check ==")
683
+ result = run_checks(no_network=args.no_validate)
684
+ say("\nnext steps:")
685
+ say(" source .env && taskboy run # run locally")
686
+ say(" see SETUP.md in your shell repo (taskboy-shell template) for deploying to a host and CI/CD")
687
+ return result
688
+
689
+
690
+ def add_parser(subparsers) -> None:
691
+ setup = subparsers.add_parser("setup", help="interactive first-run setup (config.yaml, credentials, skills)")
692
+ setup.add_argument("--step", default=None, help="run a single step (e.g. slack, github, skills)")
693
+ setup.add_argument("--check", action="store_true", help="non-interactive: validate config + live credentials and exit")
694
+ setup.add_argument("--local", action="store_true", help="zero-credential local demo: copy the example config and print how to run")
695
+ setup.add_argument("--no-validate", action="store_true", help="skip network validation")
696
+
697
+
698
+ if __name__ == "__main__":
699
+ sys.exit(0)