agent-devkit 0.0.4 → 0.1.0

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 (52) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/runtime/README.md +3 -0
  4. package/runtime/agent +29 -13
  5. package/runtime/agents/README.md +3 -0
  6. package/runtime/agents/github-pr-reviewer/AGENTS.md +10 -0
  7. package/runtime/agents/github-pr-reviewer/README.md +7 -0
  8. package/runtime/agents/github-pr-reviewer/agent.yaml +33 -0
  9. package/runtime/agents/github-pr-reviewer/capabilities/create-review-automation/capability.yaml +20 -0
  10. package/runtime/agents/github-pr-reviewer/capabilities/create-review-automation/decision-rules.md +8 -0
  11. package/runtime/agents/github-pr-reviewer/capabilities/create-review-automation/workflow.md +9 -0
  12. package/runtime/agents/github-pr-reviewer/capabilities/inspect-pr/capability.yaml +20 -0
  13. package/runtime/agents/github-pr-reviewer/capabilities/inspect-pr/decision-rules.md +7 -0
  14. package/runtime/agents/github-pr-reviewer/capabilities/inspect-pr/workflow.md +8 -0
  15. package/runtime/agents/github-pr-reviewer/capabilities/list-review-requests/capability.yaml +20 -0
  16. package/runtime/agents/github-pr-reviewer/capabilities/list-review-requests/decision-rules.md +7 -0
  17. package/runtime/agents/github-pr-reviewer/capabilities/list-review-requests/workflow.md +8 -0
  18. package/runtime/agents/github-pr-reviewer/capabilities/review-pr-diff/capability.yaml +20 -0
  19. package/runtime/agents/github-pr-reviewer/capabilities/review-pr-diff/decision-rules.md +9 -0
  20. package/runtime/agents/github-pr-reviewer/capabilities/review-pr-diff/workflow.md +10 -0
  21. package/runtime/agents/github-pr-reviewer/infra/README.md +7 -0
  22. package/runtime/agents/github-pr-reviewer/knowledge/context.md +7 -0
  23. package/runtime/agents/github-pr-reviewer/knowledge/system.md +17 -0
  24. package/runtime/agents/github-pr-reviewer/templates/pr-automation-output.md +10 -0
  25. package/runtime/agents/github-pr-reviewer/templates/pr-inspection-output.md +9 -0
  26. package/runtime/agents/github-pr-reviewer/templates/pr-list-output.md +9 -0
  27. package/runtime/agents/github-pr-reviewer/templates/pr-review-output.md +17 -0
  28. package/runtime/cli/aikit/__init__.py +1 -1
  29. package/runtime/cli/aikit/aliases.py +196 -0
  30. package/runtime/cli/aikit/app_home.py +78 -0
  31. package/runtime/cli/aikit/audit.py +344 -0
  32. package/runtime/cli/aikit/calendar.py +163 -0
  33. package/runtime/cli/aikit/diagnostics.py +9 -0
  34. package/runtime/cli/aikit/github_pr.py +254 -0
  35. package/runtime/cli/aikit/identity.py +75 -0
  36. package/runtime/cli/aikit/llm.py +240 -48
  37. package/runtime/cli/aikit/main.py +924 -24
  38. package/runtime/cli/aikit/memory.py +148 -8
  39. package/runtime/cli/aikit/notifications.py +9 -0
  40. package/runtime/cli/aikit/permissions.py +345 -0
  41. package/runtime/cli/aikit/personality.py +123 -0
  42. package/runtime/cli/aikit/providers.py +4 -5
  43. package/runtime/cli/aikit/scheduler.py +18 -0
  44. package/runtime/cli/aikit/sessions.py +396 -0
  45. package/runtime/cli/aikit/setup_wizard.py +12 -0
  46. package/runtime/cli/aikit/tasks.py +351 -0
  47. package/runtime/cli/aikit/toolchain.py +274 -0
  48. package/runtime/providers/calendar.yaml +28 -0
  49. package/runtime/providers/github.yaml +42 -0
  50. package/runtime/providers/local-notification.yaml +19 -0
  51. package/runtime/providers/local-scheduler.yaml +24 -0
  52. package/runtime/vendor/skills/napkin/napkin.md +13 -3
@@ -0,0 +1,351 @@
1
+ """Local task and scheduler primitives for Agent DevKit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import shutil
8
+ from datetime import datetime, time, timedelta, timezone
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from cli.aikit.app_home import ensure_app_home, tasks_home
13
+ from cli.aikit.memory import redact_secrets
14
+ from cli.aikit.permissions import permission_check, provider_for_agent
15
+
16
+
17
+ TASK_ID_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]*$")
18
+
19
+
20
+ def now_iso() -> str:
21
+ return datetime.now(timezone.utc).isoformat()
22
+
23
+
24
+ def ensure_tasks_home() -> Path:
25
+ ensure_app_home()
26
+ home = tasks_home()
27
+ (home / "history").mkdir(parents=True, exist_ok=True)
28
+ return home
29
+
30
+
31
+ def tasks_path() -> Path:
32
+ return ensure_tasks_home() / "tasks.json"
33
+
34
+
35
+ def history_path(task_id: str) -> Path:
36
+ return ensure_tasks_home() / "history" / f"{safe_task_id(task_id)}.md"
37
+
38
+
39
+ def load_tasks() -> dict[str, Any]:
40
+ path = tasks_path()
41
+ if not path.exists():
42
+ return {"version": 1, "tasks": []}
43
+ try:
44
+ data = json.loads(path.read_text(encoding="utf-8"))
45
+ except json.JSONDecodeError:
46
+ return {"version": 1, "tasks": []}
47
+ if not isinstance(data, dict):
48
+ return {"version": 1, "tasks": []}
49
+ tasks = data.get("tasks")
50
+ if not isinstance(tasks, list):
51
+ data["tasks"] = []
52
+ data.setdefault("version", 1)
53
+ return data
54
+
55
+
56
+ def save_tasks(data: dict[str, Any]) -> Path:
57
+ path = tasks_path()
58
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
59
+ return path
60
+
61
+
62
+ def list_tasks() -> dict[str, Any]:
63
+ data = load_tasks()
64
+ return {
65
+ "kind": "tasks",
66
+ "status": "ok",
67
+ "path": str(tasks_path()),
68
+ "items": [public_task(item) for item in data["tasks"] if isinstance(item, dict)],
69
+ }
70
+
71
+
72
+ def create_task(
73
+ *,
74
+ task_id: str | None = None,
75
+ title: str | None = None,
76
+ prompt: str | None = None,
77
+ schedule: dict[str, Any] | None = None,
78
+ action: dict[str, Any] | None = None,
79
+ permissions: dict[str, Any] | None = None,
80
+ notifications: list[dict[str, Any]] | None = None,
81
+ enabled: bool = True,
82
+ ) -> dict[str, Any]:
83
+ data = load_tasks()
84
+ raw_id = task_id or slugify(title or prompt or "task")
85
+ task_id = unique_task_id(raw_id, data)
86
+ created_at = now_iso()
87
+ task = {
88
+ "id": task_id,
89
+ "title": title or prompt or task_id,
90
+ "status": "enabled" if enabled else "disabled",
91
+ "created_at": created_at,
92
+ "updated_at": created_at,
93
+ "schedule": schedule or {"type": "manual"},
94
+ "action": action or {"type": "prompt", "prompt": redact_secrets(prompt or "")},
95
+ "permissions": permissions or {"mode": "report-only"},
96
+ "notifications": notifications or [{"type": "terminal"}],
97
+ "run_count": 0,
98
+ }
99
+ data["tasks"].append(task)
100
+ save_tasks(data)
101
+ append_history(task_id, f"Created task `{task_id}`.")
102
+ return {"kind": "task", "status": "created", "task": public_task(task), "path": str(tasks_path())}
103
+
104
+
105
+ def show_task(task_id: str) -> dict[str, Any]:
106
+ task = find_task(task_id)
107
+ return {"kind": "task", "status": "ok", "task": public_task(task), "path": str(tasks_path())}
108
+
109
+
110
+ def task_history(task_id: str) -> dict[str, Any]:
111
+ task = find_task(task_id)
112
+ path = history_path(str(task["id"]))
113
+ return {
114
+ "kind": "task-history",
115
+ "status": "ok",
116
+ "task": public_task(task),
117
+ "path": str(path),
118
+ "history": path.read_text(encoding="utf-8") if path.exists() else "",
119
+ }
120
+
121
+
122
+ def run_task(task_id: str, *, dry_run: bool = False) -> dict[str, Any]:
123
+ data = load_tasks()
124
+ task = find_task_in_data(data, task_id)
125
+ if task.get("status") in {"paused", "disabled"}:
126
+ return {"kind": "task-run", "status": "skipped", "ok": False, "task": public_task(task), "message": "Task is not enabled.", "exit_code": 2}
127
+ preview = task_run_preview(task)
128
+ if dry_run:
129
+ return {"kind": "task-run", "status": "planned", "ok": True, "dry_run": True, "task": public_task(task), "preview": preview}
130
+ permission = task_external_write_permission(task)
131
+ if not permission["ok"]:
132
+ append_history(str(task["id"]), f"Blocked task `{task['id']}` because it requires external write permission.")
133
+ return {
134
+ "kind": "task-run",
135
+ "status": "blocked",
136
+ "ok": False,
137
+ "dry_run": False,
138
+ "task": public_task(task),
139
+ "preview": preview,
140
+ "permission": permission,
141
+ "requires_permission": True,
142
+ "message": "Task declares external_writes=true and does not have explicit external write permission.",
143
+ "exit_code": 2,
144
+ }
145
+ task["run_count"] = int(task.get("run_count") or 0) + 1
146
+ task["last_run_at"] = now_iso()
147
+ task["updated_at"] = task["last_run_at"]
148
+ save_tasks(data)
149
+ append_history(str(task["id"]), f"Executed task `{task['id']}` in local scheduler.")
150
+ return {"kind": "task-run", "status": "ok", "ok": True, "dry_run": False, "task": public_task(task), "preview": preview}
151
+
152
+
153
+ def scheduler_run_once(*, dry_run: bool = False) -> dict[str, Any]:
154
+ data = load_tasks()
155
+ due = [item for item in data["tasks"] if isinstance(item, dict) and item.get("status") == "enabled" and task_is_due(item)]
156
+ runs = [run_task(str(item["id"]), dry_run=dry_run) for item in due]
157
+ return {"kind": "scheduler", "status": "ok", "dry_run": dry_run, "due_count": len(due), "runs": runs}
158
+
159
+
160
+ def update_task_status(task_id: str, status: str) -> dict[str, Any]:
161
+ data = load_tasks()
162
+ task = find_task_in_data(data, task_id)
163
+ task["status"] = status
164
+ task["updated_at"] = now_iso()
165
+ save_tasks(data)
166
+ append_history(str(task["id"]), f"Status changed to `{status}`.")
167
+ return {"kind": "task", "status": "updated", "task": public_task(task), "path": str(tasks_path())}
168
+
169
+
170
+ def update_task_schedule(task_id: str, *, every: str | None = None, cron: str | None = None) -> dict[str, Any]:
171
+ data = load_tasks()
172
+ task = find_task_in_data(data, task_id)
173
+ if every:
174
+ task["schedule"] = {"type": "interval", "every": every}
175
+ if cron:
176
+ task["schedule"] = {"type": "cron", "cron": cron}
177
+ task["updated_at"] = now_iso()
178
+ save_tasks(data)
179
+ append_history(str(task["id"]), "Schedule updated.")
180
+ return {"kind": "task", "status": "updated", "task": public_task(task), "path": str(tasks_path())}
181
+
182
+
183
+ def delete_task(task_id: str, *, yes: bool = False) -> dict[str, Any]:
184
+ if not yes:
185
+ return {"kind": "task", "status": "needs-confirmation", "ok": False, "message": "Deleting a task requires --yes.", "exit_code": 2}
186
+ data = load_tasks()
187
+ task = find_task_in_data(data, task_id)
188
+ data["tasks"] = [item for item in data["tasks"] if not isinstance(item, dict) or item.get("id") != task.get("id")]
189
+ save_tasks(data)
190
+ append_history(str(task["id"]), "Task deleted.")
191
+ return {"kind": "task", "status": "deleted", "task": public_task(task), "path": str(tasks_path())}
192
+
193
+
194
+ def reset_tasks() -> list[str]:
195
+ home = tasks_home()
196
+ removed: list[str] = []
197
+ if home.exists():
198
+ removed.append(str(home))
199
+ shutil.rmtree(home)
200
+ ensure_tasks_home()
201
+ return removed
202
+
203
+
204
+ def public_task(task: dict[str, Any]) -> dict[str, Any]:
205
+ return {
206
+ "id": task.get("id"),
207
+ "title": task.get("title"),
208
+ "status": task.get("status"),
209
+ "created_at": task.get("created_at"),
210
+ "updated_at": task.get("updated_at"),
211
+ "schedule": task.get("schedule") or {},
212
+ "action": task.get("action") or {},
213
+ "permissions": task.get("permissions") or {},
214
+ "notifications": task.get("notifications") or [],
215
+ "run_count": int(task.get("run_count") or 0),
216
+ "last_run_at": task.get("last_run_at"),
217
+ }
218
+
219
+
220
+ def task_run_preview(task: dict[str, Any]) -> dict[str, Any]:
221
+ action = task.get("action") if isinstance(task.get("action"), dict) else {}
222
+ return {
223
+ "task_id": task.get("id"),
224
+ "action_type": action.get("type"),
225
+ "agent": action.get("agent"),
226
+ "capability": action.get("capability"),
227
+ "prompt": action.get("prompt"),
228
+ "external_writes": bool(action.get("external_writes")),
229
+ "permissions": task.get("permissions") or {},
230
+ }
231
+
232
+
233
+ def task_requires_external_write_permission(task: dict[str, Any]) -> bool:
234
+ return not task_external_write_permission(task)["ok"]
235
+
236
+
237
+ def task_is_due(task: dict[str, Any], *, now: datetime | None = None) -> bool:
238
+ now = now or datetime.now(timezone.utc)
239
+ schedule = task.get("schedule") if isinstance(task.get("schedule"), dict) else {}
240
+ schedule_type = schedule.get("type") or "manual"
241
+ if schedule_type == "manual":
242
+ return False
243
+ last_run_at = parse_iso_datetime(task.get("last_run_at"))
244
+ if schedule_type == "interval":
245
+ interval = parse_interval(str(schedule.get("every") or ""))
246
+ if interval is None:
247
+ return False
248
+ return last_run_at is None or last_run_at + interval <= now
249
+ if schedule_type == "daily":
250
+ scheduled_time = parse_time(str(schedule.get("time") or "00:00")) or time(0, 0)
251
+ if now.time().replace(tzinfo=None) < scheduled_time:
252
+ return False
253
+ return last_run_at is None or last_run_at.date() < now.date()
254
+ if schedule_type == "cron":
255
+ # Full cron parsing is outside the local MVP; run once after creation
256
+ # and require an external scheduler for precise recurrence.
257
+ return last_run_at is None
258
+ return False
259
+
260
+
261
+ def parse_interval(value: str) -> timedelta | None:
262
+ match = re.fullmatch(r"\s*(\d+)\s*([mhd])\s*", value.lower())
263
+ if not match:
264
+ return None
265
+ amount = int(match.group(1))
266
+ unit = match.group(2)
267
+ if amount <= 0:
268
+ return None
269
+ if unit == "m":
270
+ return timedelta(minutes=amount)
271
+ if unit == "h":
272
+ return timedelta(hours=amount)
273
+ return timedelta(days=amount)
274
+
275
+
276
+ def parse_time(value: str) -> time | None:
277
+ try:
278
+ return datetime.strptime(value, "%H:%M").time()
279
+ except ValueError:
280
+ return None
281
+
282
+
283
+ def parse_iso_datetime(value: Any) -> datetime | None:
284
+ if not value:
285
+ return None
286
+ try:
287
+ parsed = datetime.fromisoformat(str(value))
288
+ except ValueError:
289
+ return None
290
+ if parsed.tzinfo is None:
291
+ return parsed.replace(tzinfo=timezone.utc)
292
+ return parsed.astimezone(timezone.utc)
293
+
294
+
295
+ def task_external_write_permission(task: dict[str, Any]) -> dict[str, Any]:
296
+ action = task.get("action") if isinstance(task.get("action"), dict) else {}
297
+ permissions = task.get("permissions") if isinstance(task.get("permissions"), dict) else {}
298
+ if not bool(action.get("external_writes")):
299
+ return {"ok": True, "status": "allowed", "requires_permission": False}
300
+ if bool(permissions.get("external_writes_allowed")):
301
+ return {"ok": True, "status": "allowed", "source": "task-permission", "requires_permission": False}
302
+ agent = action.get("agent")
303
+ provider = action.get("provider") or provider_for_agent(str(agent) if agent else None)
304
+ check = permission_check(
305
+ agent=str(agent) if agent else None,
306
+ provider=str(provider) if provider else None,
307
+ action=str(permissions.get("required_action") or action.get("required_action") or "write"),
308
+ task_id=str(task.get("id") or ""),
309
+ )
310
+ return check
311
+
312
+
313
+ def find_task(task_id: str) -> dict[str, Any]:
314
+ return find_task_in_data(load_tasks(), task_id)
315
+
316
+
317
+ def find_task_in_data(data: dict[str, Any], task_id: str) -> dict[str, Any]:
318
+ resolved = safe_task_id(task_id)
319
+ for item in data.get("tasks", []):
320
+ if isinstance(item, dict) and item.get("id") == resolved:
321
+ return item
322
+ raise ValueError(f"task not found: {task_id}")
323
+
324
+
325
+ def append_history(task_id: str, message: str) -> None:
326
+ path = history_path(task_id)
327
+ line = f"- {now_iso()} {redact_secrets(message)}\n"
328
+ with path.open("a", encoding="utf-8") as file:
329
+ file.write(line)
330
+
331
+
332
+ def unique_task_id(task_id: str, data: dict[str, Any]) -> str:
333
+ base = safe_task_id(task_id)
334
+ existing = {item.get("id") for item in data.get("tasks", []) if isinstance(item, dict)}
335
+ if base not in existing:
336
+ return base
337
+ index = 2
338
+ while f"{base}-{index}" in existing:
339
+ index += 1
340
+ return f"{base}-{index}"
341
+
342
+
343
+ def safe_task_id(task_id: str) -> str:
344
+ if not TASK_ID_PATTERN.fullmatch(task_id):
345
+ return slugify(task_id)
346
+ return task_id
347
+
348
+
349
+ def slugify(value: str) -> str:
350
+ slug = re.sub(r"[^a-zA-Z0-9._-]+", "-", value.lower()).strip(".-")
351
+ return slug[:80] or "task"
@@ -0,0 +1,274 @@
1
+ """Toolchain discovery and controlled install planning."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import platform
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+
13
+ TOOLCHAIN_PATH = Path(__file__).resolve().parents[2] / "tooling" / "toolchain.yaml"
14
+ INSTALL_TIMEOUT_SECONDS = 900
15
+
16
+
17
+ def list_toolchain(root: Path | None = None) -> dict[str, Any]:
18
+ tools = load_toolchain(root)
19
+ return {
20
+ "kind": "toolchain",
21
+ "status": "ok",
22
+ "platform": platform_key(),
23
+ "path": str(toolchain_path(root)),
24
+ "items": [public_tool(tool_id, spec) for tool_id, spec in tools.items()],
25
+ }
26
+
27
+
28
+ def doctor_toolchain(root: Path | None = None, tool_id: str | None = None) -> dict[str, Any]:
29
+ tools = select_tools(root, tool_id)
30
+ items = [tool_status(item_id, spec) for item_id, spec in tools.items()]
31
+ required_missing = [item["id"] for item in items if item.get("required") and item.get("status") == "missing"]
32
+ optional_missing = [item["id"] for item in items if not item.get("required") and item.get("status") == "missing"]
33
+ status = "ok"
34
+ if required_missing:
35
+ status = "missing"
36
+ elif optional_missing:
37
+ status = "partial"
38
+ return {
39
+ "kind": "toolchain-doctor",
40
+ "status": status,
41
+ "platform": platform_key(),
42
+ "path": str(toolchain_path(root)),
43
+ "required_missing": required_missing,
44
+ "optional_missing": optional_missing,
45
+ "items": items,
46
+ }
47
+
48
+
49
+ def install_toolchain(
50
+ root: Path | None = None,
51
+ tool_id: str | None = None,
52
+ *,
53
+ dry_run: bool = False,
54
+ yes: bool = False,
55
+ ) -> dict[str, Any]:
56
+ tools = select_tools(root, tool_id)
57
+ platform_name = platform_key()
58
+ plans = []
59
+ for item_id, spec in tools.items():
60
+ current_status = tool_status(item_id, spec)
61
+ already_installed = current_status.get("status") == "ok"
62
+ command = None if already_installed else install_command(spec, platform_name)
63
+ plans.append(
64
+ {
65
+ "id": item_id,
66
+ "label": spec.get("label") or item_id,
67
+ "command": command,
68
+ "status": "already-installed" if already_installed else "planned",
69
+ "binary": current_status.get("binary"),
70
+ "executable": is_executable_install_command(command),
71
+ }
72
+ )
73
+
74
+ if dry_run:
75
+ return install_payload("planned", plans, dry_run=True, yes=yes, executed=[])
76
+ if not yes:
77
+ return install_payload(
78
+ "needs-confirmation",
79
+ plans,
80
+ dry_run=False,
81
+ yes=False,
82
+ executed=[],
83
+ message="External tool installation requires --yes. Use --dry-run to inspect the plan.",
84
+ )
85
+
86
+ executed: list[dict[str, Any]] = []
87
+ for plan in plans:
88
+ if plan["status"] == "already-installed":
89
+ executed.append({**plan, "status": "already-installed", "message": "Tool already available in PATH."})
90
+ continue
91
+ if not plan["executable"]:
92
+ executed.append({**plan, "status": "skipped", "message": "Manual installation instruction."})
93
+ continue
94
+ process = subprocess.run(
95
+ str(plan["command"]),
96
+ shell=True,
97
+ check=False,
98
+ text=True,
99
+ stdout=subprocess.PIPE,
100
+ stderr=subprocess.PIPE,
101
+ timeout=INSTALL_TIMEOUT_SECONDS,
102
+ )
103
+ executed.append(
104
+ {
105
+ **plan,
106
+ "status": "installed" if process.returncode == 0 else "failed",
107
+ "exit_code": process.returncode,
108
+ "stdout": safe_tail(redact_environment_secrets(process.stdout)),
109
+ "stderr": safe_tail(redact_environment_secrets(process.stderr)),
110
+ }
111
+ )
112
+ status = "installed" if all(item["status"] in {"installed", "skipped", "already-installed"} for item in executed) else "failed"
113
+ return install_payload(status, plans, dry_run=False, yes=True, executed=executed)
114
+
115
+
116
+ def setup_plan(root: Path | None = None, *, dry_run: bool = False, yes: bool = False) -> dict[str, Any]:
117
+ doctor = doctor_toolchain(root)
118
+ if yes and not dry_run:
119
+ install = install_toolchain(root, "all", dry_run=False, yes=True)
120
+ else:
121
+ install = install_toolchain(root, "all", dry_run=True, yes=yes)
122
+ status = install["status"] if yes and not dry_run else "planned"
123
+ return {
124
+ "kind": "setup",
125
+ "status": status,
126
+ "dry_run": dry_run,
127
+ "yes": yes,
128
+ "toolchain": doctor,
129
+ "install_plan": install["plans"],
130
+ "install": install,
131
+ "next_steps": [
132
+ "Run `agent toolchain doctor` to inspect local dependencies.",
133
+ "Run `agent toolchain install <tool> --dry-run` before installing external tools.",
134
+ "Use `agent setup personality` to configure local identity and style.",
135
+ ],
136
+ }
137
+
138
+
139
+ def install_payload(
140
+ status: str,
141
+ plans: list[dict[str, Any]],
142
+ *,
143
+ dry_run: bool,
144
+ yes: bool,
145
+ executed: list[dict[str, Any]],
146
+ message: str | None = None,
147
+ ) -> dict[str, Any]:
148
+ payload = {
149
+ "kind": "toolchain-install",
150
+ "status": status,
151
+ "platform": platform_key(),
152
+ "dry_run": dry_run,
153
+ "yes": yes,
154
+ "stored_secret": False,
155
+ "plans": plans,
156
+ "executed": executed,
157
+ }
158
+ if message:
159
+ payload["message"] = message
160
+ return payload
161
+
162
+
163
+ def load_toolchain(root: Path | None = None) -> dict[str, dict[str, Any]]:
164
+ path = toolchain_path(root)
165
+ try:
166
+ import yaml # type: ignore
167
+ except ImportError:
168
+ return fallback_toolchain()
169
+ with path.open(encoding="utf-8") as file:
170
+ data = yaml.safe_load(file) or {}
171
+ tools = data.get("tools") if isinstance(data, dict) else {}
172
+ if not isinstance(tools, dict):
173
+ return {}
174
+ return {str(key): value for key, value in tools.items() if isinstance(value, dict)}
175
+
176
+
177
+ def fallback_toolchain() -> dict[str, dict[str, Any]]:
178
+ return {
179
+ "node": {"label": "Node.js", "command": "node", "required": True, "install": {}},
180
+ "python": {"label": "Python", "command": "python3", "required": True, "install": {}},
181
+ "git": {"label": "Git", "command": "git", "required": True, "install": {}},
182
+ }
183
+
184
+
185
+ def select_tools(root: Path | None, tool_id: str | None) -> dict[str, dict[str, Any]]:
186
+ tools = load_toolchain(root)
187
+ if not tool_id or tool_id == "all":
188
+ return tools
189
+ if tool_id not in tools:
190
+ available = ", ".join(sorted(tools)) or "none"
191
+ raise ValueError(f"unknown toolchain item: {tool_id}. available: {available}")
192
+ return {tool_id: tools[tool_id]}
193
+
194
+
195
+ def public_tool(tool_id: str, spec: dict[str, Any]) -> dict[str, Any]:
196
+ return {
197
+ "id": tool_id,
198
+ "label": spec.get("label") or tool_id,
199
+ "command": spec.get("command"),
200
+ "required": bool(spec.get("required")),
201
+ "install": install_command(spec, platform_key()),
202
+ "notes": spec.get("notes"),
203
+ }
204
+
205
+
206
+ def tool_status(tool_id: str, spec: dict[str, Any]) -> dict[str, Any]:
207
+ command = str(spec.get("command") or "")
208
+ binary = shutil.which(command) if command else None
209
+ return {
210
+ **public_tool(tool_id, spec),
211
+ "status": "ok" if binary else "missing",
212
+ "binary": binary,
213
+ "version": read_version(binary),
214
+ }
215
+
216
+
217
+ def read_version(binary: str | None) -> str | None:
218
+ if not binary:
219
+ return None
220
+ for args in ([binary, "--version"], [binary, "version"]):
221
+ try:
222
+ process = subprocess.run(args, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5)
223
+ except (OSError, subprocess.TimeoutExpired):
224
+ continue
225
+ output = (process.stdout or process.stderr or "").strip()
226
+ if output:
227
+ return output.splitlines()[0]
228
+ return None
229
+
230
+
231
+ def install_command(spec: dict[str, Any], platform_name: str) -> str | None:
232
+ install = spec.get("install")
233
+ if not isinstance(install, dict):
234
+ return None
235
+ command = install.get(platform_name) or install.get("default")
236
+ return str(command) if command else None
237
+
238
+
239
+ def is_executable_install_command(command: str | None) -> bool:
240
+ if not command:
241
+ return False
242
+ manual_prefixes = ("See ", "Configure ", "Use ", "Open ", "Install manually")
243
+ return not command.startswith(manual_prefixes)
244
+
245
+
246
+ def platform_key() -> str:
247
+ system = platform.system().lower()
248
+ if system == "darwin":
249
+ return "darwin"
250
+ if system.startswith("win"):
251
+ return "windows"
252
+ return "linux"
253
+
254
+
255
+ def toolchain_path(root: Path | None = None) -> Path:
256
+ if root:
257
+ return root / "tooling" / "toolchain.yaml"
258
+ return TOOLCHAIN_PATH
259
+
260
+
261
+ def safe_tail(value: str, limit: int = 2000) -> str:
262
+ text = value or ""
263
+ return text[-limit:]
264
+
265
+
266
+ def redact_environment_secrets(value: str) -> str:
267
+ redacted = value or ""
268
+ for key, secret in os.environ.items():
269
+ if not secret:
270
+ continue
271
+ if not any(marker in key.upper() for marker in ("KEY", "TOKEN", "SECRET", "PASSWORD", "PASS", "PAT")):
272
+ continue
273
+ redacted = redacted.replace(secret, "[REDACTED]")
274
+ return redacted
@@ -0,0 +1,28 @@
1
+ id: calendar
2
+ name: Calendar
3
+ kind: local-source
4
+ status: draft
5
+ description: Local calendar provider for ICS-backed agenda queries.
6
+ auth_methods: []
7
+ config_fields:
8
+ - name: CALENDAR_SOURCE_REF
9
+ required: false
10
+ secret: false
11
+ - name: CALENDAR_TIMEZONE
12
+ required: false
13
+ secret: false
14
+ health_check:
15
+ command: agent calendar today
16
+ risk:
17
+ default: read-sensitive-local
18
+ writes: false
19
+ fallbacks:
20
+ - configure_provider
21
+ - manual_steps
22
+ capabilities:
23
+ read:
24
+ - calendar.today
25
+ - calendar.tomorrow
26
+ - calendar.list
27
+ write: []
28
+ write: false
@@ -0,0 +1,42 @@
1
+ id: github
2
+ name: GitHub
3
+ kind: cli-provider
4
+ status: draft
5
+ description: GitHub provider backed by the official GitHub CLI.
6
+ auth_methods:
7
+ - id: gh-auth
8
+ label: GitHub CLI auth
9
+ native: true
10
+ secret_fields: []
11
+ - id: token
12
+ label: GitHub token
13
+ secret_fields:
14
+ - GITHUB_TOKEN
15
+ config_fields:
16
+ - name: GITHUB_TOKEN
17
+ required: false
18
+ secret: true
19
+ - name: GITHUB_HOST
20
+ required: false
21
+ secret: false
22
+ detection:
23
+ env_any:
24
+ - GITHUB_TOKEN
25
+ health_check:
26
+ command: gh auth status
27
+ risk:
28
+ default: read
29
+ writes: true
30
+ fallbacks:
31
+ - report_only
32
+ - manual_steps
33
+ capabilities:
34
+ read:
35
+ - pr.list-review-requests
36
+ - pr.inspect
37
+ - pr.review-report
38
+ write:
39
+ - pr.comment
40
+ - pr.approve
41
+ - pr.request-changes
42
+ write: true