agent-devkit 0.0.3 → 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.
- package/README.md +170 -1
- package/package.json +1 -1
- package/runtime/README.md +192 -14
- 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 +93 -0
- 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/diagnostics.py +9 -0
- package/runtime/cli/aikit/github_pr.py +254 -0
- package/runtime/cli/aikit/identity.py +75 -0
- package/runtime/cli/aikit/llm.py +240 -48
- package/runtime/cli/aikit/main.py +924 -24
- package/runtime/cli/aikit/memory.py +148 -8
- package/runtime/cli/aikit/notifications.py +9 -0
- package/runtime/cli/aikit/permissions.py +345 -0
- package/runtime/cli/aikit/personality.py +123 -0
- package/runtime/cli/aikit/providers.py +4 -5
- 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/plugins/claude-code-ai-devkit/plugin.json +1 -1
- package/runtime/plugins/claude-skill-ai-devkit/plugin.json +1 -1
- 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/scripts/README.md +1 -1
- package/runtime/scripts/verify-release-alignment.mjs +1 -1
- package/runtime/vendor/skills/napkin/napkin.md +13 -3
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Local audit trail for Agent DevKit CLI executions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import getpass
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import uuid
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from cli.aikit.app_home import audit_home, ensure_app_home
|
|
14
|
+
from cli.aikit.memory import normalize_prompt, redact_secrets
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
AUDIT_VERSION = 1
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def now_utc() -> datetime:
|
|
21
|
+
return datetime.now(timezone.utc)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def now_iso() -> str:
|
|
25
|
+
return now_utc().isoformat()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def ensure_audit_home() -> Path:
|
|
29
|
+
ensure_app_home()
|
|
30
|
+
home = audit_home()
|
|
31
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
return home
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def audit_day_home(day: str | None = None) -> Path:
|
|
36
|
+
resolved_day = day or now_utc().date().isoformat()
|
|
37
|
+
path = ensure_audit_home() / resolved_day
|
|
38
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
return path
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def record_audit(
|
|
43
|
+
*,
|
|
44
|
+
command: str | None,
|
|
45
|
+
args: dict[str, Any],
|
|
46
|
+
result: dict[str, Any] | None = None,
|
|
47
|
+
error: str | None = None,
|
|
48
|
+
) -> dict[str, Any]:
|
|
49
|
+
created_at = now_iso()
|
|
50
|
+
execution_id = f"exec_{now_utc().strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex[:8]}"
|
|
51
|
+
prompt = extract_prompt(args)
|
|
52
|
+
safe_result = redact_value(result or {})
|
|
53
|
+
entry = {
|
|
54
|
+
"version": AUDIT_VERSION,
|
|
55
|
+
"id": execution_id,
|
|
56
|
+
"created_at": created_at,
|
|
57
|
+
"user": safe_user(),
|
|
58
|
+
"command": command,
|
|
59
|
+
"prompt": redact_secrets(prompt) if prompt else None,
|
|
60
|
+
"prompt_normalized": normalize_prompt(prompt) if prompt else None,
|
|
61
|
+
"session": extract_session(safe_result),
|
|
62
|
+
"route": safe_result.get("route"),
|
|
63
|
+
"agent": extract_agent(safe_result, args),
|
|
64
|
+
"providers": extract_providers(safe_result),
|
|
65
|
+
"sources": extract_sources(safe_result),
|
|
66
|
+
"commands": extract_commands(safe_result),
|
|
67
|
+
"permissions": extract_permissions(safe_result),
|
|
68
|
+
"llm_backends": extract_llm_backends(safe_result),
|
|
69
|
+
"token_estimate": extract_token_estimate(safe_result),
|
|
70
|
+
"external_actions": extract_external_actions(safe_result),
|
|
71
|
+
"result": summarize_result(safe_result),
|
|
72
|
+
"error": redact_secrets(error) if error else None,
|
|
73
|
+
"redaction_applied": True,
|
|
74
|
+
}
|
|
75
|
+
day_home = audit_day_home(created_at[:10])
|
|
76
|
+
json_path = day_home / f"{execution_id}.json"
|
|
77
|
+
md_path = day_home / f"{execution_id}.md"
|
|
78
|
+
json_path.write_text(json.dumps(entry, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
79
|
+
md_path.write_text(render_audit_md(entry), encoding="utf-8")
|
|
80
|
+
return {"id": execution_id, "json_path": str(json_path), "markdown_path": str(md_path)}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def list_audits(limit: int = 20) -> dict[str, Any]:
|
|
84
|
+
items: list[dict[str, Any]] = []
|
|
85
|
+
for path in sorted(ensure_audit_home().glob("*/*.json"), reverse=True):
|
|
86
|
+
try:
|
|
87
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
88
|
+
except json.JSONDecodeError:
|
|
89
|
+
continue
|
|
90
|
+
if not isinstance(payload, dict):
|
|
91
|
+
continue
|
|
92
|
+
items.append(
|
|
93
|
+
{
|
|
94
|
+
"id": payload.get("id"),
|
|
95
|
+
"created_at": payload.get("created_at"),
|
|
96
|
+
"command": payload.get("command"),
|
|
97
|
+
"status": (payload.get("result") or {}).get("status"),
|
|
98
|
+
"ok": (payload.get("result") or {}).get("ok"),
|
|
99
|
+
"json_path": str(path),
|
|
100
|
+
"markdown_path": str(path.with_suffix(".md")),
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
if len(items) >= limit:
|
|
104
|
+
break
|
|
105
|
+
return {"kind": "audit", "status": "ok", "home": str(ensure_audit_home()), "items": items}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def show_audit(execution_id: str) -> dict[str, Any]:
|
|
109
|
+
path = find_audit_json(execution_id)
|
|
110
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
111
|
+
return {
|
|
112
|
+
"kind": "audit-entry",
|
|
113
|
+
"status": "ok",
|
|
114
|
+
"id": payload.get("id"),
|
|
115
|
+
"entry": payload,
|
|
116
|
+
"json_path": str(path),
|
|
117
|
+
"markdown_path": str(path.with_suffix(".md")),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def export_audit(execution_id: str, *, fmt: str = "md") -> dict[str, Any]:
|
|
122
|
+
path = find_audit_json(execution_id)
|
|
123
|
+
if fmt == "json":
|
|
124
|
+
content = path.read_text(encoding="utf-8")
|
|
125
|
+
export_path = path
|
|
126
|
+
elif fmt == "md":
|
|
127
|
+
md_path = path.with_suffix(".md")
|
|
128
|
+
content = md_path.read_text(encoding="utf-8") if md_path.exists() else render_audit_md(json.loads(path.read_text(encoding="utf-8")))
|
|
129
|
+
export_path = md_path
|
|
130
|
+
else:
|
|
131
|
+
raise ValueError("--format must be md or json")
|
|
132
|
+
return {
|
|
133
|
+
"kind": "audit-export",
|
|
134
|
+
"status": "ok",
|
|
135
|
+
"id": execution_id,
|
|
136
|
+
"format": fmt,
|
|
137
|
+
"path": str(export_path),
|
|
138
|
+
"content": content,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def find_audit_json(execution_id: str) -> Path:
|
|
143
|
+
if not execution_id or "/" in execution_id or "\\" in execution_id:
|
|
144
|
+
raise ValueError(f"invalid audit execution id: {execution_id}")
|
|
145
|
+
matches = sorted(ensure_audit_home().glob(f"*/{execution_id}.json"))
|
|
146
|
+
if not matches:
|
|
147
|
+
prefix_matches = sorted(ensure_audit_home().glob(f"*/{execution_id}*.json"))
|
|
148
|
+
if len(prefix_matches) == 1:
|
|
149
|
+
return prefix_matches[0]
|
|
150
|
+
if len(prefix_matches) > 1:
|
|
151
|
+
raise ValueError(f"ambiguous audit execution id prefix: {execution_id}")
|
|
152
|
+
raise ValueError(f"audit execution not found: {execution_id}")
|
|
153
|
+
return matches[0]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def redact_value(value: Any) -> Any:
|
|
157
|
+
if isinstance(value, str):
|
|
158
|
+
return redact_secrets(value)
|
|
159
|
+
if isinstance(value, list):
|
|
160
|
+
return [redact_value(item) for item in value]
|
|
161
|
+
if isinstance(value, tuple):
|
|
162
|
+
return [redact_value(item) for item in value]
|
|
163
|
+
if isinstance(value, dict):
|
|
164
|
+
redacted: dict[str, Any] = {}
|
|
165
|
+
for key, item in value.items():
|
|
166
|
+
key_text = str(key)
|
|
167
|
+
if secret_key(key_text):
|
|
168
|
+
redacted[key_text] = "[REDACTED_SECRET]"
|
|
169
|
+
else:
|
|
170
|
+
redacted[key_text] = redact_value(item)
|
|
171
|
+
return redacted
|
|
172
|
+
return value
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def secret_key(key: str) -> bool:
|
|
176
|
+
normalized = key.lower().replace("-", "_")
|
|
177
|
+
if normalized in {"token_estimate", "tokens", "prompt_tokens", "completion_tokens", "total_tokens"}:
|
|
178
|
+
return False
|
|
179
|
+
exact = {"token", "secret", "password", "passwd", "pwd", "api_key", "private_key", "pat"}
|
|
180
|
+
if normalized in exact:
|
|
181
|
+
return True
|
|
182
|
+
if normalized.endswith(("_token", "_secret", "_password", "_passwd", "_pwd", "_api_key", "_private_key", "_pat")):
|
|
183
|
+
return True
|
|
184
|
+
compact = re.sub(r"[^a-z0-9]+", "", key.lower())
|
|
185
|
+
if compact in {"tokenestimate", "tokens", "prompttokens", "completiontokens", "totaltokens"}:
|
|
186
|
+
return False
|
|
187
|
+
return any(marker in compact for marker in ("token", "secret", "password", "passwd", "apikey", "privatekey"))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def extract_prompt(args: dict[str, Any]) -> str | None:
|
|
191
|
+
prompt = args.get("prompt")
|
|
192
|
+
if isinstance(prompt, list):
|
|
193
|
+
return " ".join(str(item) for item in prompt).strip()
|
|
194
|
+
return str(prompt).strip() if prompt else None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def extract_session(result: dict[str, Any]) -> dict[str, Any] | None:
|
|
198
|
+
session = result.get("session")
|
|
199
|
+
if isinstance(session, dict):
|
|
200
|
+
return {
|
|
201
|
+
"id": session.get("id"),
|
|
202
|
+
"title": session.get("title"),
|
|
203
|
+
"project": session.get("project"),
|
|
204
|
+
"token_estimate": session.get("token_estimate"),
|
|
205
|
+
}
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def extract_agent(result: dict[str, Any], args: dict[str, Any]) -> dict[str, Any]:
|
|
210
|
+
if isinstance(result.get("agent"), dict):
|
|
211
|
+
return result["agent"]
|
|
212
|
+
if result.get("agent_id"):
|
|
213
|
+
return {"id": result.get("agent_id")}
|
|
214
|
+
if args.get("agent"):
|
|
215
|
+
return {"id": args.get("agent")}
|
|
216
|
+
route = result.get("route") if isinstance(result.get("route"), dict) else {}
|
|
217
|
+
if route.get("agent_id"):
|
|
218
|
+
return {"id": route.get("agent_id"), "capability": route.get("capability_id")}
|
|
219
|
+
return {}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def extract_providers(result: dict[str, Any]) -> Any:
|
|
223
|
+
if result.get("providers") is not None:
|
|
224
|
+
return result.get("providers")
|
|
225
|
+
route = result.get("route") if isinstance(result.get("route"), dict) else {}
|
|
226
|
+
provider = result.get("provider") or route.get("provider") or result.get("source_provider")
|
|
227
|
+
return {"used": [provider], "missing": [], "skipped": []} if provider else {"used": [], "missing": [], "skipped": []}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def extract_sources(result: dict[str, Any]) -> list[Any]:
|
|
231
|
+
source = result.get("source")
|
|
232
|
+
if source:
|
|
233
|
+
return [source]
|
|
234
|
+
return []
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def extract_commands(result: dict[str, Any]) -> list[Any]:
|
|
238
|
+
commands = []
|
|
239
|
+
command = result.get("command")
|
|
240
|
+
if command:
|
|
241
|
+
commands.append(command)
|
|
242
|
+
for attempt in result.get("llm_backend_attempts") or []:
|
|
243
|
+
if isinstance(attempt, dict) and attempt.get("command"):
|
|
244
|
+
commands.append(attempt["command"])
|
|
245
|
+
return commands
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def extract_permissions(result: dict[str, Any]) -> list[Any]:
|
|
249
|
+
permissions = []
|
|
250
|
+
for key in ("permission", "permissions"):
|
|
251
|
+
if result.get(key):
|
|
252
|
+
permissions.append(result[key])
|
|
253
|
+
preview = result.get("preview") if isinstance(result.get("preview"), dict) else {}
|
|
254
|
+
if preview.get("permissions"):
|
|
255
|
+
permissions.append(preview["permissions"])
|
|
256
|
+
return permissions
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def extract_llm_backends(result: dict[str, Any]) -> list[Any]:
|
|
260
|
+
attempts = result.get("llm_backend_attempts")
|
|
261
|
+
if isinstance(attempts, list):
|
|
262
|
+
return attempts
|
|
263
|
+
backend = result.get("llm_backend")
|
|
264
|
+
return [{"id": backend}] if backend else []
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def extract_token_estimate(result: dict[str, Any]) -> int | None:
|
|
268
|
+
session = result.get("session") if isinstance(result.get("session"), dict) else {}
|
|
269
|
+
if session.get("token_estimate") is not None:
|
|
270
|
+
return int(session.get("token_estimate") or 0)
|
|
271
|
+
prompt_length = result.get("prompt_length")
|
|
272
|
+
return int(prompt_length / 4) if isinstance(prompt_length, int) else None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def extract_external_actions(result: dict[str, Any]) -> list[dict[str, Any]]:
|
|
276
|
+
actions: list[dict[str, Any]] = []
|
|
277
|
+
preview = result.get("preview") if isinstance(result.get("preview"), dict) else {}
|
|
278
|
+
if preview.get("external_writes"):
|
|
279
|
+
actions.append({"type": preview.get("action_type"), "external_writes": True})
|
|
280
|
+
if result.get("external_action"):
|
|
281
|
+
actions.append(result["external_action"])
|
|
282
|
+
return actions
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def summarize_result(result: dict[str, Any]) -> dict[str, Any]:
|
|
286
|
+
return {
|
|
287
|
+
"kind": result.get("kind"),
|
|
288
|
+
"status": result.get("status"),
|
|
289
|
+
"ok": result.get("ok"),
|
|
290
|
+
"exit_code": result.get("exit_code"),
|
|
291
|
+
"message": result.get("message"),
|
|
292
|
+
"mode": result.get("mode"),
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def safe_user() -> str:
|
|
297
|
+
try:
|
|
298
|
+
return redact_secrets(getpass.getuser())
|
|
299
|
+
except Exception:
|
|
300
|
+
return "unknown"
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def render_audit_md(entry: dict[str, Any]) -> str:
|
|
304
|
+
lines = [
|
|
305
|
+
f"# Audit {entry.get('id')}",
|
|
306
|
+
"",
|
|
307
|
+
f"- Created at: `{entry.get('created_at')}`",
|
|
308
|
+
f"- User: `{entry.get('user')}`",
|
|
309
|
+
f"- Command: `{entry.get('command')}`",
|
|
310
|
+
f"- Status: `{(entry.get('result') or {}).get('status')}`",
|
|
311
|
+
f"- OK: `{(entry.get('result') or {}).get('ok')}`",
|
|
312
|
+
"",
|
|
313
|
+
"## Prompt",
|
|
314
|
+
"",
|
|
315
|
+
entry.get("prompt") or "-",
|
|
316
|
+
"",
|
|
317
|
+
"## Agent",
|
|
318
|
+
"",
|
|
319
|
+
"```json",
|
|
320
|
+
json.dumps(entry.get("agent") or {}, ensure_ascii=False, indent=2, sort_keys=True),
|
|
321
|
+
"```",
|
|
322
|
+
"",
|
|
323
|
+
"## Providers",
|
|
324
|
+
"",
|
|
325
|
+
"```json",
|
|
326
|
+
json.dumps(entry.get("providers") or {}, ensure_ascii=False, indent=2, sort_keys=True),
|
|
327
|
+
"```",
|
|
328
|
+
"",
|
|
329
|
+
"## Permissions",
|
|
330
|
+
"",
|
|
331
|
+
"```json",
|
|
332
|
+
json.dumps(entry.get("permissions") or [], ensure_ascii=False, indent=2, sort_keys=True),
|
|
333
|
+
"```",
|
|
334
|
+
"",
|
|
335
|
+
"## Result",
|
|
336
|
+
"",
|
|
337
|
+
"```json",
|
|
338
|
+
json.dumps(entry.get("result") or {}, ensure_ascii=False, indent=2, sort_keys=True),
|
|
339
|
+
"```",
|
|
340
|
+
]
|
|
341
|
+
if entry.get("error"):
|
|
342
|
+
lines.extend(["", "## Error", "", str(entry["error"])])
|
|
343
|
+
lines.append("")
|
|
344
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Calendar MVP backed by local ICS files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import date, datetime, timedelta
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from cli.aikit.app_home import config_path, ensure_app_home
|
|
11
|
+
from cli.aikit.llm import load_config, save_config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
DATE_COMPACT = "%Y%m%d"
|
|
15
|
+
DATETIME_COMPACT = "%Y%m%dT%H%M%S"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def configure_calendar(*, ics_path: str | None = None, timezone: str | None = None) -> dict[str, Any]:
|
|
19
|
+
if not ics_path:
|
|
20
|
+
return calendar_needs_input()
|
|
21
|
+
path = Path(ics_path).expanduser().resolve()
|
|
22
|
+
if not path.exists():
|
|
23
|
+
raise ValueError(f"calendar ics file not found: {ics_path}")
|
|
24
|
+
config = load_config()
|
|
25
|
+
calendar = config.setdefault("calendar", {})
|
|
26
|
+
calendar["provider"] = "ics"
|
|
27
|
+
calendar["source_ref"] = str(path)
|
|
28
|
+
if timezone:
|
|
29
|
+
calendar["timezone"] = timezone
|
|
30
|
+
written = save_config(config)
|
|
31
|
+
return {
|
|
32
|
+
"kind": "calendar-configure",
|
|
33
|
+
"status": "configured",
|
|
34
|
+
"provider": "ics",
|
|
35
|
+
"source_ref": str(path),
|
|
36
|
+
"timezone": timezone,
|
|
37
|
+
"config_path": str(written),
|
|
38
|
+
"stored_secret": False,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def calendar_today() -> dict[str, Any]:
|
|
43
|
+
today = date.today()
|
|
44
|
+
return calendar_list(today.isoformat(), today.isoformat(), label="today")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def calendar_tomorrow() -> dict[str, Any]:
|
|
48
|
+
tomorrow = date.today() + timedelta(days=1)
|
|
49
|
+
return calendar_list(tomorrow.isoformat(), tomorrow.isoformat(), label="tomorrow")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def calendar_list(date_from: str | None, date_to: str | None, *, label: str | None = None) -> dict[str, Any]:
|
|
53
|
+
config = load_config()
|
|
54
|
+
calendar = config.get("calendar") if isinstance(config.get("calendar"), dict) else {}
|
|
55
|
+
if calendar.get("provider") != "ics" or not calendar.get("source_ref"):
|
|
56
|
+
return calendar_needs_input()
|
|
57
|
+
start = parse_date(date_from) if date_from else date.today()
|
|
58
|
+
end = parse_date(date_to) if date_to else start
|
|
59
|
+
events = [
|
|
60
|
+
event
|
|
61
|
+
for event in parse_ics(Path(str(calendar["source_ref"])))
|
|
62
|
+
if event_overlaps(event, start, end)
|
|
63
|
+
]
|
|
64
|
+
events.sort(key=lambda item: item.get("start") or "")
|
|
65
|
+
return {
|
|
66
|
+
"kind": "calendar",
|
|
67
|
+
"status": "ok",
|
|
68
|
+
"label": label,
|
|
69
|
+
"provider": "ics",
|
|
70
|
+
"source_ref": str(calendar["source_ref"]),
|
|
71
|
+
"from": start.isoformat(),
|
|
72
|
+
"to": end.isoformat(),
|
|
73
|
+
"events": events,
|
|
74
|
+
"count": len(events),
|
|
75
|
+
"sensitive": True,
|
|
76
|
+
"llm_safe": False,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def calendar_needs_input() -> dict[str, Any]:
|
|
81
|
+
ensure_app_home()
|
|
82
|
+
return {
|
|
83
|
+
"kind": "calendar",
|
|
84
|
+
"status": "needs-input",
|
|
85
|
+
"ok": False,
|
|
86
|
+
"requires_provider": True,
|
|
87
|
+
"config_path": str(config_path()),
|
|
88
|
+
"message": "Calendar provider is not configured.",
|
|
89
|
+
"next_steps": [
|
|
90
|
+
"Configure a local ICS file with `agent calendar configure --ics <path>`.",
|
|
91
|
+
"Then run `agent calendar today` or ask `agent qual minha agenda de hoje?`.",
|
|
92
|
+
],
|
|
93
|
+
"exit_code": 2,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def parse_ics(path: Path) -> list[dict[str, Any]]:
|
|
98
|
+
text = unfold_ics(path.read_text(encoding="utf-8"))
|
|
99
|
+
events: list[dict[str, Any]] = []
|
|
100
|
+
for block in re.findall(r"BEGIN:VEVENT(.*?)END:VEVENT", text, re.DOTALL):
|
|
101
|
+
fields: dict[str, str] = {}
|
|
102
|
+
for raw_line in block.splitlines():
|
|
103
|
+
if ":" not in raw_line:
|
|
104
|
+
continue
|
|
105
|
+
key, value = raw_line.split(":", 1)
|
|
106
|
+
key = key.split(";", 1)[0].upper()
|
|
107
|
+
fields[key] = value.strip()
|
|
108
|
+
start = parse_ics_datetime(fields.get("DTSTART"))
|
|
109
|
+
end = parse_ics_datetime(fields.get("DTEND"))
|
|
110
|
+
events.append(
|
|
111
|
+
{
|
|
112
|
+
"uid": fields.get("UID"),
|
|
113
|
+
"summary": fields.get("SUMMARY") or "(sem titulo)",
|
|
114
|
+
"description": fields.get("DESCRIPTION"),
|
|
115
|
+
"location": fields.get("LOCATION"),
|
|
116
|
+
"start": start.isoformat() if start else None,
|
|
117
|
+
"end": end.isoformat() if end else None,
|
|
118
|
+
"all_day": bool(start and isinstance(start, date) and not isinstance(start, datetime)),
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
return events
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def unfold_ics(text: str) -> str:
|
|
125
|
+
return re.sub(r"\r?\n[ \t]", "", text)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def parse_ics_datetime(value: str | None) -> date | datetime | None:
|
|
129
|
+
if not value:
|
|
130
|
+
return None
|
|
131
|
+
value = value.rstrip("Z")
|
|
132
|
+
for fmt in (DATETIME_COMPACT, DATE_COMPACT):
|
|
133
|
+
try:
|
|
134
|
+
parsed = datetime.strptime(value, fmt)
|
|
135
|
+
except ValueError:
|
|
136
|
+
continue
|
|
137
|
+
return parsed.date() if fmt == DATE_COMPACT else parsed
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def parse_date(value: str) -> date:
|
|
142
|
+
return datetime.strptime(value, "%Y-%m-%d").date()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def event_overlaps(event: dict[str, Any], start: date, end: date) -> bool:
|
|
146
|
+
raw_start = event.get("start")
|
|
147
|
+
if not raw_start:
|
|
148
|
+
return False
|
|
149
|
+
event_date = datetime.fromisoformat(raw_start).date() if "T" in raw_start else date.fromisoformat(raw_start)
|
|
150
|
+
return start <= event_date <= end
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def calendar_summary(payload: dict[str, Any]) -> str:
|
|
154
|
+
if payload.get("status") != "ok":
|
|
155
|
+
return str(payload.get("message") or "")
|
|
156
|
+
if not payload.get("events"):
|
|
157
|
+
return "Nenhum compromisso encontrado no periodo."
|
|
158
|
+
lines = []
|
|
159
|
+
for item in payload["events"]:
|
|
160
|
+
start = item.get("start") or "-"
|
|
161
|
+
summary = item.get("summary") or "-"
|
|
162
|
+
lines.append(f"- {start}: {summary}")
|
|
163
|
+
return "\n".join(lines)
|
|
@@ -7,6 +7,7 @@ import os
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
|
+
from cli.aikit.app_home import APP_DIRS, app_home
|
|
10
11
|
from cli.aikit.llm import config_path as llm_config_path
|
|
11
12
|
from cli.aikit.llm import doctor_backends
|
|
12
13
|
from cli.aikit.lock import lock_path, lock_status, read_lock
|
|
@@ -42,6 +43,14 @@ def runtime_diagnostics(
|
|
|
42
43
|
"kind": "runtime-summary",
|
|
43
44
|
"status": status,
|
|
44
45
|
"root": str(root),
|
|
46
|
+
"app_home": str(app_home()),
|
|
47
|
+
"app_dirs": {
|
|
48
|
+
name: {
|
|
49
|
+
"path": str(app_home() / name),
|
|
50
|
+
"exists": (app_home() / name).is_dir(),
|
|
51
|
+
}
|
|
52
|
+
for name in APP_DIRS
|
|
53
|
+
},
|
|
45
54
|
"install_home": str(install_home),
|
|
46
55
|
"config_path": str(runtime_config_path()),
|
|
47
56
|
"llm_config_path": str(llm_config_path()),
|