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.
Files changed (60) hide show
  1. package/README.md +42 -2
  2. package/package.json +1 -1
  3. package/runtime/README.md +50 -2
  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/README.md +37 -1
  29. package/runtime/cli/aikit/__init__.py +1 -1
  30. package/runtime/cli/aikit/aliases.py +196 -0
  31. package/runtime/cli/aikit/app_home.py +78 -0
  32. package/runtime/cli/aikit/audit.py +344 -0
  33. package/runtime/cli/aikit/calendar.py +163 -0
  34. package/runtime/cli/aikit/configuration_orchestrator.py +291 -0
  35. package/runtime/cli/aikit/decision_store.py +158 -0
  36. package/runtime/cli/aikit/diagnostics.py +9 -0
  37. package/runtime/cli/aikit/fallback.py +48 -2
  38. package/runtime/cli/aikit/github_pr.py +254 -0
  39. package/runtime/cli/aikit/identity.py +75 -0
  40. package/runtime/cli/aikit/llm.py +249 -48
  41. package/runtime/cli/aikit/main.py +1240 -32
  42. package/runtime/cli/aikit/memory.py +148 -8
  43. package/runtime/cli/aikit/model_router.py +70 -0
  44. package/runtime/cli/aikit/notifications.py +9 -0
  45. package/runtime/cli/aikit/ollama.py +237 -0
  46. package/runtime/cli/aikit/permissions.py +345 -0
  47. package/runtime/cli/aikit/personality.py +123 -0
  48. package/runtime/cli/aikit/provider_wizard.py +19 -0
  49. package/runtime/cli/aikit/providers.py +4 -5
  50. package/runtime/cli/aikit/review_gate.py +40 -0
  51. package/runtime/cli/aikit/scheduler.py +18 -0
  52. package/runtime/cli/aikit/sessions.py +396 -0
  53. package/runtime/cli/aikit/setup_wizard.py +12 -0
  54. package/runtime/cli/aikit/tasks.py +351 -0
  55. package/runtime/cli/aikit/toolchain.py +274 -0
  56. package/runtime/providers/calendar.yaml +28 -0
  57. package/runtime/providers/github.yaml +42 -0
  58. package/runtime/providers/local-notification.yaml +19 -0
  59. package/runtime/providers/local-scheduler.yaml +24 -0
  60. package/runtime/vendor/skills/napkin/napkin.md +13 -3
@@ -8,27 +8,140 @@ from datetime import datetime, timezone
8
8
  from pathlib import Path
9
9
  from typing import Any
10
10
 
11
- from cli.aikit.llm import config_home, config_path, load_config, save_config
11
+ from cli.aikit.app_home import (
12
+ app_path,
13
+ cache_home,
14
+ ensure_app_home,
15
+ memory_home as app_memory_home,
16
+ sessions_home,
17
+ tasks_home,
18
+ )
19
+ from cli.aikit.llm import config_path, load_config, save_config
20
+
21
+
22
+ SECRET_VALUE_PATTERN = re.compile(
23
+ r"(?i)\b("
24
+ r"sk-[a-z0-9_-]{12,}|"
25
+ r"npm_[a-z0-9]{12,}|"
26
+ r"gh[pousr]_[a-z0-9_]{12,}|"
27
+ r"xox[baprs]-[a-z0-9-]{12,}|"
28
+ r"AKIA[0-9A-Z]{12,}|"
29
+ r"ASIA[0-9A-Z]{12,}"
30
+ r")\b"
31
+ )
32
+ SECRET_ASSIGNMENT_PATTERN = re.compile(
33
+ r"(?i)([\"']?)([a-z0-9_]*(?:token|secret|password|passwd|pwd|senha|chave|api[_-]?key|private[_-]?key)[a-z0-9_]*)([\"']?)(\s*[:=]\s*)([\"']?)([^\s,;}\"']+)([\"']?)"
34
+ )
35
+
36
+ MEMORY_FILE_TEMPLATES: dict[str, str] = {
37
+ "profile.md": """# Profile
38
+
39
+ Local user profile for Agent DevKit.
40
+
41
+ ## User
42
+
43
+ - Name:
44
+ - Primary language:
45
+ - Timezone:
46
+
47
+ ## Notes
48
+
49
+ - Add stable facts the agent should remember.
50
+ """,
51
+ "personality.md": """# Personality
52
+
53
+ Configured public identity and response style for Agent DevKit.
54
+
55
+ ## Agent
56
+
57
+ - Name: Agent DevKit
58
+
59
+ ## Style
60
+
61
+ - Tone: direct
62
+ - Detail level: concise
63
+ """,
64
+ "preferences.md": """# Preferences
65
+
66
+ Reusable user preferences.
67
+
68
+ ## Defaults
69
+
70
+ - Prefer local-first execution.
71
+ - Ask before external writes.
72
+ """,
73
+ "projects.md": """# Projects
74
+
75
+ Frequently used projects and repositories.
76
+
77
+ ## Items
78
+
79
+ - Add project names, paths, and non-secret references here.
80
+ """,
81
+ "routines.md": """# Routines
82
+
83
+ Recurring workflows, checks, and habits.
84
+
85
+ ## Items
86
+
87
+ - Add routines that should be easy to reuse.
88
+ """,
89
+ "napkin.md": """# Agent DevKit Napkin
90
+
91
+ Curated local runbook entries promoted from repeated use.
92
+
93
+ ## Execution & Validation
94
+
95
+ - Keep high-value reusable notes here.
96
+ """,
97
+ }
12
98
 
13
99
 
14
100
  def memory_home() -> Path:
15
- return config_home() / "memory"
101
+ return app_memory_home()
102
+
103
+
104
+ def ensure_memory() -> dict[str, Any]:
105
+ ensure_app_home()
106
+ home = memory_home()
107
+ home.mkdir(parents=True, exist_ok=True)
108
+ files: list[dict[str, Any]] = []
109
+ created: list[str] = []
110
+ for name, template in MEMORY_FILE_TEMPLATES.items():
111
+ path = home / name
112
+ if not path.exists():
113
+ path.write_text(template, encoding="utf-8")
114
+ created.append(str(path))
115
+ files.append({"name": name, "path": str(path), "exists": path.exists()})
116
+ return {
117
+ "kind": "memory-path",
118
+ "status": "ok",
119
+ "home": str(home),
120
+ "created": created,
121
+ "files": files,
122
+ }
16
123
 
17
124
 
18
125
  def normalize_prompt(prompt: str) -> str:
19
- text = " ".join(prompt.lower().split())
126
+ text = " ".join(redact_secrets(prompt).lower().split())
20
127
  return text[:160]
21
128
 
22
129
 
130
+ def redact_secrets(value: str) -> str:
131
+ redacted = SECRET_VALUE_PATTERN.sub("[REDACTED_SECRET]", value)
132
+ return SECRET_ASSIGNMENT_PATTERN.sub(r"\1\2\3\4\5[REDACTED_SECRET]\7", redacted)
133
+
134
+
23
135
  def napkin_context(root: Path, *, agent_id: str | None = None, source_id: str | None = None) -> dict[str, Any]:
24
136
  paths = [
25
137
  root / "vendor" / "skills" / "napkin" / "napkin.md",
138
+ memory_home() / "napkin.md",
26
139
  memory_home() / "global" / "napkin.md",
27
140
  ]
28
141
  if agent_id:
29
- paths.append(memory_home() / "agents" / agent_id / "napkin.md")
142
+ paths.append(memory_home() / "agents" / sanitize_segment(agent_id) / "napkin.md")
30
143
  if source_id:
31
- paths.append(memory_home() / "sources" / source_id / "napkin.md")
144
+ paths.append(memory_home() / "sources" / sanitize_segment(source_id) / "napkin.md")
32
145
  return {
33
146
  "loaded": any(path.exists() for path in paths),
34
147
  "paths": [
@@ -61,6 +174,7 @@ def record_usage(
61
174
 
62
175
 
63
176
  def show_memory(root: Path, *, agent_id: str | None = None, source_id: str | None = None) -> dict[str, Any]:
177
+ memory_paths = ensure_memory()
64
178
  config = load_config()
65
179
  memory = config.get("memory") if isinstance(config.get("memory"), dict) else {}
66
180
  usage = memory.get("usage") if isinstance(memory.get("usage"), dict) else {}
@@ -69,6 +183,7 @@ def show_memory(root: Path, *, agent_id: str | None = None, source_id: str | Non
69
183
  "status": "ok",
70
184
  "config_path": str(config_path()),
71
185
  "memory_home": str(memory_home()),
186
+ "files": memory_paths["files"],
72
187
  "usage": {
73
188
  "prompts": sorted_usage(usage.get("prompts") or {}),
74
189
  "routes": sorted_usage(usage.get("routes") or {}),
@@ -78,10 +193,19 @@ def show_memory(root: Path, *, agent_id: str | None = None, source_id: str | Non
78
193
  }
79
194
 
80
195
 
81
- def reset_memory(*, all_memory: bool = False, agent_id: str | None = None, source_id: str | None = None) -> dict[str, Any]:
196
+ def reset_memory(
197
+ *,
198
+ all_memory: bool = False,
199
+ agent_id: str | None = None,
200
+ source_id: str | None = None,
201
+ reset_sessions: bool = False,
202
+ reset_tasks: bool = False,
203
+ reset_cache: bool = False,
204
+ ) -> dict[str, Any]:
82
205
  config = load_config()
83
206
  removed_paths: list[str] = []
84
- if all_memory or not agent_id and not source_id:
207
+ scoped_reset = any([agent_id, source_id, reset_sessions, reset_tasks, reset_cache])
208
+ if all_memory or not scoped_reset:
85
209
  config.pop("memory", None)
86
210
  if memory_home().exists():
87
211
  removed_paths.append(str(memory_home()))
@@ -97,6 +221,14 @@ def reset_memory(*, all_memory: bool = False, agent_id: str | None = None, sourc
97
221
  usage.get("sources", {}).pop(source_id, None)
98
222
  path = memory_home() / "sources" / sanitize_segment(source_id)
99
223
  remove_path(path, removed_paths)
224
+ if all_memory or reset_sessions:
225
+ remove_path(sessions_home(), removed_paths)
226
+ remove_path(app_path("state", "active-session.json"), removed_paths)
227
+ if all_memory or reset_tasks:
228
+ remove_path(tasks_home(), removed_paths)
229
+ if all_memory or reset_cache:
230
+ remove_path(cache_home(), removed_paths)
231
+ ensure_app_home()
100
232
  written_path = save_config(config)
101
233
  return {
102
234
  "kind": "memory-reset",
@@ -104,9 +236,16 @@ def reset_memory(*, all_memory: bool = False, agent_id: str | None = None, sourc
104
236
  "config_path": str(written_path),
105
237
  "removed_paths": removed_paths,
106
238
  "sources_preserved": True,
239
+ "sessions_reset": bool(all_memory or reset_sessions),
240
+ "tasks_reset": bool(all_memory or reset_tasks),
241
+ "cache_reset": bool(all_memory or reset_cache),
107
242
  }
108
243
 
109
244
 
245
+ def memory_path_payload() -> dict[str, Any]:
246
+ return ensure_memory()
247
+
248
+
110
249
  def increment_bucket(bucket: dict[str, Any], key: str, now: str) -> None:
111
250
  item = bucket.setdefault(key, {"count": 0, "first_seen": now, "last_seen": now})
112
251
  item["count"] = int(item.get("count") or 0) + 1
@@ -132,4 +271,5 @@ def remove_path(path: Path, removed_paths: list[str]) -> None:
132
271
 
133
272
 
134
273
  def sanitize_segment(value: str) -> str:
135
- return re.sub(r"[^a-zA-Z0-9._-]+", "-", value)
274
+ sanitized = re.sub(r"[^a-zA-Z0-9._-]+", "-", value).strip(".-")
275
+ return sanitized or "item"
@@ -0,0 +1,70 @@
1
+ """Task-to-model routing decisions for Agent DevKit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ from cli.aikit.llm import llm_preference, load_config
9
+ from cli.aikit.ollama import ollama_status
10
+
11
+
12
+ OPERATIONAL_PATTERN = re.compile(
13
+ r"(?i)\b(resum|sumari|classifi|extra(?:i|ir)|normaliz|compar|logs?|rascunho|agrupe|agrupar)\b"
14
+ )
15
+ HIGH_LEVEL_PATTERN = re.compile(
16
+ r"(?i)\b(arquitet|decid|aprovar|reprovar|especifica|requisit|implemente|codigo|c[oó]digo|documento|automac|deploy|seguran)\b"
17
+ )
18
+
19
+
20
+ def build_model_plan(prompt: str, *, route: dict[str, Any] | None = None) -> dict[str, Any]:
21
+ preference = llm_preference(load_config())
22
+ ollama = ollama_status()
23
+ local_available = ollama.get("status") == "ok"
24
+ operational = bool(OPERATIONAL_PATTERN.search(prompt))
25
+ high_level = bool(HIGH_LEVEL_PATTERN.search(prompt))
26
+ use_local = operational and local_available
27
+ return {
28
+ "kind": "model-plan",
29
+ "status": "planned",
30
+ "intent": route.get("intent") if route else "llm",
31
+ "primary_coordinators": coordinator_order(preference),
32
+ "local_llm_role": "operational-worker",
33
+ "local_llm_available": local_available,
34
+ "local_llm_provider": "ollama",
35
+ "local_llm_recommended": operational,
36
+ "local_llm_selected": use_local,
37
+ "delegation": {
38
+ "allowed": operational,
39
+ "selected": use_local,
40
+ "reason": local_reason(operational=operational, local_available=local_available, high_level=high_level),
41
+ "forbidden_actions": [
42
+ "final-review",
43
+ "external-write",
44
+ "permission-decision",
45
+ "architecture-decision",
46
+ "pr-approval",
47
+ ],
48
+ },
49
+ "fallback_order": preference.get("order") or [],
50
+ "route": route,
51
+ }
52
+
53
+
54
+ def coordinator_order(preference: dict[str, Any]) -> list[str]:
55
+ order = list(preference.get("order") or [])
56
+ preferred = [item for item in order if item in {"claude-code", "codex-cli"}]
57
+ for item in ("claude-code", "codex-cli"):
58
+ if item not in preferred:
59
+ preferred.append(item)
60
+ return preferred
61
+
62
+
63
+ def local_reason(*, operational: bool, local_available: bool, high_level: bool) -> str:
64
+ if operational and local_available and high_level:
65
+ return "Use Ollama for operational preprocessing; coordinator review remains mandatory."
66
+ if operational and local_available:
67
+ return "Task is operational and local Ollama is available."
68
+ if operational and not local_available:
69
+ return "Task is operational, but Ollama is not available; coordinator/API fallback should execute."
70
+ return "Task requires coordinator-level reasoning or review."
@@ -0,0 +1,9 @@
1
+ """Notification payload helpers for local automations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def terminal_notification(message: str) -> dict[str, Any]:
9
+ return {"type": "terminal", "message": message}
@@ -0,0 +1,237 @@
1
+ """Ollama local LLM discovery and model management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import platform
7
+ import shutil
8
+ import subprocess
9
+ import urllib.error
10
+ import urllib.request
11
+ from typing import Any
12
+
13
+
14
+ OLLAMA_TIMEOUT_SECONDS = 120
15
+ DEFAULT_BASE_URL = "http://localhost:11434"
16
+
17
+
18
+ def ollama_status(*, base_url: str = DEFAULT_BASE_URL) -> dict[str, Any]:
19
+ binary = shutil.which("ollama")
20
+ version = command_output(["ollama", "--version"]) if binary else None
21
+ daemon = daemon_status(base_url) if binary else {"status": "unknown", "message": "Ollama binary is not installed."}
22
+ models = list_local_models(binary_available=bool(binary))
23
+ status = "ok" if binary else "missing"
24
+ return {
25
+ "kind": "ollama-status",
26
+ "status": status,
27
+ "binary": binary,
28
+ "version": first_line(version),
29
+ "base_url": base_url,
30
+ "daemon": daemon,
31
+ "models": models,
32
+ "model_count": len(models),
33
+ "install_plan": install_plan() if not binary else None,
34
+ }
35
+
36
+
37
+ def ollama_models() -> dict[str, Any]:
38
+ binary = shutil.which("ollama")
39
+ items = list_local_models(binary_available=bool(binary))
40
+ return {
41
+ "kind": "ollama-models",
42
+ "status": "ok" if binary else "missing",
43
+ "binary": binary,
44
+ "items": items,
45
+ "recommended": recommended_models(installed={item["name"] for item in items}),
46
+ }
47
+
48
+
49
+ def ollama_pull(model: str | None, *, yes: bool = False, dry_run: bool = False) -> dict[str, Any]:
50
+ if not model:
51
+ raise ValueError("ollama pull requires a model name")
52
+ binary = shutil.which("ollama")
53
+ command = ["ollama", "pull", model]
54
+ if dry_run or not yes:
55
+ return {
56
+ "kind": "ollama-pull",
57
+ "status": "planned" if dry_run else "needs-confirmation",
58
+ "ok": bool(dry_run),
59
+ "model": model,
60
+ "binary": binary,
61
+ "command": command,
62
+ "dry_run": dry_run,
63
+ "yes": yes,
64
+ "message": "Use --yes to pull the model." if not dry_run and not yes else "Dry-run only.",
65
+ }
66
+ if not binary:
67
+ return {
68
+ "kind": "ollama-pull",
69
+ "status": "missing",
70
+ "ok": False,
71
+ "model": model,
72
+ "command": command,
73
+ "message": "Ollama binary not found in PATH.",
74
+ "install_plan": install_plan(),
75
+ "exit_code": 2,
76
+ }
77
+ process = run_command(command, timeout=OLLAMA_TIMEOUT_SECONDS * 5)
78
+ return {
79
+ "kind": "ollama-pull",
80
+ "status": "ok" if process.returncode == 0 else "failed",
81
+ "ok": process.returncode == 0,
82
+ "model": model,
83
+ "binary": binary,
84
+ "command": command,
85
+ "exit_code": process.returncode,
86
+ "stdout": safe_tail(process.stdout),
87
+ "stderr": safe_tail(process.stderr),
88
+ }
89
+
90
+
91
+ def ollama_update(*, yes: bool = False, dry_run: bool = False) -> dict[str, Any]:
92
+ command = update_command()
93
+ if dry_run or not yes:
94
+ return {
95
+ "kind": "ollama-update",
96
+ "status": "planned" if dry_run else "needs-confirmation",
97
+ "ok": bool(dry_run),
98
+ "command": command,
99
+ "dry_run": dry_run,
100
+ "yes": yes,
101
+ "message": "Use --yes to run the update command." if not dry_run and not yes else "Dry-run only.",
102
+ }
103
+ process = run_command(command, timeout=OLLAMA_TIMEOUT_SECONDS * 5, shell=True)
104
+ return {
105
+ "kind": "ollama-update",
106
+ "status": "ok" if process.returncode == 0 else "failed",
107
+ "ok": process.returncode == 0,
108
+ "command": command,
109
+ "exit_code": process.returncode,
110
+ "stdout": safe_tail(process.stdout),
111
+ "stderr": safe_tail(process.stderr),
112
+ }
113
+
114
+
115
+ def list_local_models(*, binary_available: bool) -> list[dict[str, Any]]:
116
+ if not binary_available:
117
+ return []
118
+ output = command_output(["ollama", "list"])
119
+ if not output:
120
+ return []
121
+ return parse_ollama_list(output)
122
+
123
+
124
+ def parse_ollama_list(output: str) -> list[dict[str, Any]]:
125
+ items: list[dict[str, Any]] = []
126
+ for line in output.splitlines():
127
+ stripped = line.strip()
128
+ if not stripped or stripped.lower().startswith("name "):
129
+ continue
130
+ parts = stripped.split()
131
+ if not parts:
132
+ continue
133
+ items.append(
134
+ {
135
+ "name": parts[0],
136
+ "id": parts[1] if len(parts) > 1 else None,
137
+ "size": parts[2] if len(parts) > 2 else None,
138
+ "modified": " ".join(parts[3:]) if len(parts) > 3 else None,
139
+ }
140
+ )
141
+ return items
142
+
143
+
144
+ def recommended_models(*, installed: set[str]) -> list[dict[str, Any]]:
145
+ catalog = [
146
+ ("qwen2.5-coder", "coding", "operational code reading and generation"),
147
+ ("deepseek-coder", "coding", "code analysis and mechanical refactors"),
148
+ ("deepseek-r1", "reasoning", "local reasoning drafts with mandatory review"),
149
+ ("llama3.2", "general", "general local summaries and classification"),
150
+ ("mistral", "general", "lightweight operational summaries"),
151
+ ("gemma", "classification", "small extraction and classification tasks"),
152
+ ]
153
+ return [
154
+ {
155
+ "name": name,
156
+ "family": family,
157
+ "recommended_for": recommended_for,
158
+ "installed": any(item == name or item.startswith(f"{name}:") for item in installed),
159
+ }
160
+ for name, family, recommended_for in catalog
161
+ ]
162
+
163
+
164
+ def daemon_status(base_url: str) -> dict[str, Any]:
165
+ url = base_url.rstrip("/") + "/api/tags"
166
+ try:
167
+ with urllib.request.urlopen(url, timeout=2) as response: # noqa: S310 - local configurable daemon URL.
168
+ raw = response.read().decode("utf-8", errors="replace")
169
+ except urllib.error.URLError as exc:
170
+ return {"status": "unavailable", "message": str(exc.reason)}
171
+ try:
172
+ payload = json.loads(raw)
173
+ except json.JSONDecodeError:
174
+ return {"status": "unknown", "message": "Ollama daemon returned non-JSON response."}
175
+ return {"status": "ok", "models": len(payload.get("models") or []) if isinstance(payload, dict) else None}
176
+
177
+
178
+ def install_plan() -> dict[str, Any]:
179
+ system = platform.system().lower()
180
+ if system == "darwin":
181
+ command = "brew install ollama"
182
+ elif system.startswith("win"):
183
+ command = "winget install Ollama.Ollama"
184
+ else:
185
+ command = "curl -fsSL https://ollama.com/install.sh | sh"
186
+ return {"platform": platform_key(), "command": command, "requires_opt_in": True}
187
+
188
+
189
+ def update_command() -> str:
190
+ system = platform.system().lower()
191
+ if system == "darwin":
192
+ return "brew upgrade ollama"
193
+ if system.startswith("win"):
194
+ return "winget upgrade Ollama.Ollama"
195
+ return "curl -fsSL https://ollama.com/install.sh | sh"
196
+
197
+
198
+ def platform_key() -> str:
199
+ system = platform.system().lower()
200
+ if system == "darwin":
201
+ return "darwin"
202
+ if system.startswith("win"):
203
+ return "windows"
204
+ return "linux"
205
+
206
+
207
+ def command_output(command: list[str]) -> str | None:
208
+ try:
209
+ process = run_command(command, timeout=10)
210
+ except OSError:
211
+ return None
212
+ if process.returncode != 0:
213
+ return None
214
+ return process.stdout.strip() or process.stderr.strip()
215
+
216
+
217
+ def run_command(command: list[str] | str, *, timeout: int, shell: bool = False) -> subprocess.CompletedProcess[str]:
218
+ return subprocess.run(
219
+ command,
220
+ shell=shell,
221
+ check=False,
222
+ text=True,
223
+ stdout=subprocess.PIPE,
224
+ stderr=subprocess.PIPE,
225
+ timeout=timeout,
226
+ )
227
+
228
+
229
+ def safe_tail(value: str | None, *, limit: int = 4000) -> str:
230
+ text = value or ""
231
+ return text[-limit:]
232
+
233
+
234
+ def first_line(value: str | None) -> str | None:
235
+ if not value:
236
+ return None
237
+ return value.splitlines()[0]