agent-devkit 0.0.4 → 0.1.5
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.
- package/README.md +42 -2
- package/package.json +1 -1
- package/runtime/README.md +50 -2
- package/runtime/agent +29 -13
- package/runtime/agents/README.md +3 -0
- package/runtime/agents/github-pr-reviewer/AGENTS.md +10 -0
- package/runtime/agents/github-pr-reviewer/README.md +7 -0
- package/runtime/agents/github-pr-reviewer/agent.yaml +33 -0
- package/runtime/agents/github-pr-reviewer/capabilities/create-review-automation/capability.yaml +20 -0
- package/runtime/agents/github-pr-reviewer/capabilities/create-review-automation/decision-rules.md +8 -0
- package/runtime/agents/github-pr-reviewer/capabilities/create-review-automation/workflow.md +9 -0
- package/runtime/agents/github-pr-reviewer/capabilities/inspect-pr/capability.yaml +20 -0
- package/runtime/agents/github-pr-reviewer/capabilities/inspect-pr/decision-rules.md +7 -0
- package/runtime/agents/github-pr-reviewer/capabilities/inspect-pr/workflow.md +8 -0
- package/runtime/agents/github-pr-reviewer/capabilities/list-review-requests/capability.yaml +20 -0
- package/runtime/agents/github-pr-reviewer/capabilities/list-review-requests/decision-rules.md +7 -0
- package/runtime/agents/github-pr-reviewer/capabilities/list-review-requests/workflow.md +8 -0
- package/runtime/agents/github-pr-reviewer/capabilities/review-pr-diff/capability.yaml +20 -0
- package/runtime/agents/github-pr-reviewer/capabilities/review-pr-diff/decision-rules.md +9 -0
- package/runtime/agents/github-pr-reviewer/capabilities/review-pr-diff/workflow.md +10 -0
- package/runtime/agents/github-pr-reviewer/infra/README.md +7 -0
- package/runtime/agents/github-pr-reviewer/knowledge/context.md +7 -0
- package/runtime/agents/github-pr-reviewer/knowledge/system.md +17 -0
- package/runtime/agents/github-pr-reviewer/templates/pr-automation-output.md +10 -0
- package/runtime/agents/github-pr-reviewer/templates/pr-inspection-output.md +9 -0
- package/runtime/agents/github-pr-reviewer/templates/pr-list-output.md +9 -0
- package/runtime/agents/github-pr-reviewer/templates/pr-review-output.md +17 -0
- package/runtime/cli/README.md +37 -1
- package/runtime/cli/aikit/__init__.py +1 -1
- package/runtime/cli/aikit/aliases.py +196 -0
- package/runtime/cli/aikit/app_home.py +78 -0
- package/runtime/cli/aikit/audit.py +344 -0
- package/runtime/cli/aikit/calendar.py +163 -0
- package/runtime/cli/aikit/configuration_orchestrator.py +291 -0
- package/runtime/cli/aikit/decision_store.py +158 -0
- package/runtime/cli/aikit/diagnostics.py +9 -0
- package/runtime/cli/aikit/fallback.py +48 -2
- package/runtime/cli/aikit/github_pr.py +254 -0
- package/runtime/cli/aikit/identity.py +75 -0
- package/runtime/cli/aikit/llm.py +249 -48
- package/runtime/cli/aikit/main.py +1240 -32
- package/runtime/cli/aikit/memory.py +148 -8
- package/runtime/cli/aikit/model_router.py +70 -0
- package/runtime/cli/aikit/notifications.py +9 -0
- package/runtime/cli/aikit/ollama.py +237 -0
- package/runtime/cli/aikit/permissions.py +345 -0
- package/runtime/cli/aikit/personality.py +123 -0
- package/runtime/cli/aikit/provider_wizard.py +19 -0
- package/runtime/cli/aikit/providers.py +4 -5
- package/runtime/cli/aikit/review_gate.py +40 -0
- package/runtime/cli/aikit/scheduler.py +18 -0
- package/runtime/cli/aikit/sessions.py +396 -0
- package/runtime/cli/aikit/setup_wizard.py +12 -0
- package/runtime/cli/aikit/tasks.py +351 -0
- package/runtime/cli/aikit/toolchain.py +274 -0
- package/runtime/providers/calendar.yaml +28 -0
- package/runtime/providers/github.yaml +42 -0
- package/runtime/providers/local-notification.yaml +19 -0
- package/runtime/providers/local-scheduler.yaml +24 -0
- 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
|