krnl-code 1.0.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. krnl_agent/__init__.py +9 -0
  2. krnl_agent/__main__.py +7 -0
  3. krnl_agent/agent_registry.py +95 -0
  4. krnl_agent/agent_selector.py +69 -0
  5. krnl_agent/audit_log.py +155 -0
  6. krnl_agent/background.py +94 -0
  7. krnl_agent/checkpoints.py +67 -0
  8. krnl_agent/ci.py +73 -0
  9. krnl_agent/cli.py +1458 -0
  10. krnl_agent/commands.py +42 -0
  11. krnl_agent/config.py +425 -0
  12. krnl_agent/context.py +352 -0
  13. krnl_agent/depaudit.py +63 -0
  14. krnl_agent/deploy.py +245 -0
  15. krnl_agent/doctor.py +106 -0
  16. krnl_agent/events.py +141 -0
  17. krnl_agent/gitignore.py +47 -0
  18. krnl_agent/graph.py +928 -0
  19. krnl_agent/guardrails.py +70 -0
  20. krnl_agent/headless.py +60 -0
  21. krnl_agent/history.py +49 -0
  22. krnl_agent/hooks.py +72 -0
  23. krnl_agent/ingest.py +129 -0
  24. krnl_agent/llm.py +456 -0
  25. krnl_agent/loop.py +779 -0
  26. krnl_agent/mcp_client.py +128 -0
  27. krnl_agent/memory.py +61 -0
  28. krnl_agent/modelrouter.py +151 -0
  29. krnl_agent/monitor.py +112 -0
  30. krnl_agent/notify.py +119 -0
  31. krnl_agent/parallel_executor.py +139 -0
  32. krnl_agent/permissions.py +128 -0
  33. krnl_agent/plugins.py +105 -0
  34. krnl_agent/pricing.py +85 -0
  35. krnl_agent/prompts.py +60 -0
  36. krnl_agent/repomap.py +133 -0
  37. krnl_agent/sandbox.py +69 -0
  38. krnl_agent/scaffold.py +167 -0
  39. krnl_agent/schedules.py +137 -0
  40. krnl_agent/secrets.py +100 -0
  41. krnl_agent/selfheal.py +87 -0
  42. krnl_agent/server.py +302 -0
  43. krnl_agent/sessions.py +258 -0
  44. krnl_agent/settings.py +59 -0
  45. krnl_agent/skills.py +73 -0
  46. krnl_agent/teams.py +38 -0
  47. krnl_agent/tool_schemas.py +431 -0
  48. krnl_agent/tools.py +694 -0
  49. krnl_agent/webtools.py +139 -0
  50. krnl_code-1.0.4.dist-info/METADATA +214 -0
  51. krnl_code-1.0.4.dist-info/RECORD +56 -0
  52. krnl_code-1.0.4.dist-info/WHEEL +5 -0
  53. krnl_code-1.0.4.dist-info/entry_points.txt +2 -0
  54. krnl_code-1.0.4.dist-info/licenses/LICENSE +147 -0
  55. krnl_code-1.0.4.dist-info/licenses/NOTICE +4 -0
  56. krnl_code-1.0.4.dist-info/top_level.txt +1 -0
krnl_agent/context.py ADDED
@@ -0,0 +1,352 @@
1
+ """Context-window management and @file mention expansion.
2
+
3
+ - `estimate_tokens` — a cheap, dependency-free token estimate (~4 chars/token).
4
+ - `expand_mentions` — turns `@path/to/file` in a task into inlined file content.
5
+ - `compact_history` — when the conversation gets too big, trims the oldest tool
6
+ outputs (safe: it never breaks the assistant→tool message pairing) so the
7
+ request stays under the model's budget.
8
+
9
+ Phase 2 additions:
10
+ - `get_graph_aware_context` — uses the code knowledge graph to pull in relevant
11
+ code (callers, callees, parent classes) up to a configurable hop limit.
12
+ - `DifferentialContextTracker` — tracks what's been sent to the model this session
13
+ and sends only diffs on subsequent turns.
14
+ - `compact_with_summarization` — improves compaction by summarizing dropped content.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import re
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import TYPE_CHECKING, Optional
23
+
24
+ if TYPE_CHECKING:
25
+ from .tools import ToolContext
26
+
27
+ _MENTION_RE = re.compile(r"(?<!\w)@([A-Za-z0-9_./\\\-]+)")
28
+ _TRIM_NOTE = "[older tool output trimmed to save context]"
29
+
30
+
31
+ def estimate_tokens(messages: list[dict]) -> int:
32
+ chars = 0
33
+ for m in messages:
34
+ c = m.get("content")
35
+ if isinstance(c, str):
36
+ chars += len(c)
37
+ for tc in m.get("tool_calls") or []:
38
+ chars += len(str(tc.get("function", {}).get("arguments", "")))
39
+ return chars // 4
40
+
41
+
42
+ def expand_mentions(task: str, ctx: "ToolContext") -> str:
43
+ """Inline the contents of any @-mentioned files that exist in the workspace."""
44
+ seen: set[str] = set()
45
+ blocks: list[str] = []
46
+ for match in _MENTION_RE.finditer(task):
47
+ rel = match.group(1).strip(".,;:)")
48
+ if rel in seen:
49
+ continue
50
+ seen.add(rel)
51
+ try:
52
+ if ctx.is_ignored(rel):
53
+ continue
54
+ full = ctx.resolve(rel)
55
+ if not full.is_file():
56
+ continue
57
+ if full.stat().st_size > ctx.cfg.max_file_bytes:
58
+ blocks.append(f"--- @{rel} (too large to inline) ---")
59
+ continue
60
+ text = full.read_text(encoding="utf-8", errors="replace")
61
+ blocks.append(f"--- @{rel} ---\n{text}")
62
+ except Exception:
63
+ continue
64
+ if not blocks:
65
+ return task
66
+ return task + "\n\nReferenced files:\n" + "\n\n".join(blocks)
67
+
68
+
69
+ def compact_history(messages: list[dict], max_tokens: int, keep_recent: int = 6):
70
+ """Return (messages, changed). Trims oldest tool outputs when over budget."""
71
+ if estimate_tokens(messages) <= max_tokens:
72
+ return messages, False
73
+ out = [dict(m) for m in messages]
74
+ # never touch the system message (0) or the most recent `keep_recent`
75
+ end = max(1, len(out) - keep_recent)
76
+ changed = False
77
+ for i in range(1, end):
78
+ if estimate_tokens(out) <= max_tokens:
79
+ break
80
+ m = out[i]
81
+ if m.get("role") == "tool" and m.get("content") != _TRIM_NOTE:
82
+ m["content"] = _TRIM_NOTE
83
+ changed = True
84
+ return out, changed
85
+
86
+
87
+ _SUMMARY_SYS = (
88
+ "Summarize this coding-session transcript into a concise brief a developer "
89
+ "needs to continue: decisions made, files changed, current state, and what's "
90
+ "left. Be factual and specific; no fluff."
91
+ )
92
+
93
+
94
+ def summarize_history(client, messages: list[dict], keep_recent: int = 4):
95
+ """Replace the middle of the conversation with an LLM-written summary.
96
+ Returns (new_messages, changed). Preserves assistant→tool message pairing.
97
+ """
98
+ if len(messages) <= keep_recent + 2:
99
+ return messages, False
100
+ system = messages[0]
101
+ cut = len(messages) - keep_recent
102
+ while cut > 1 and messages[cut].get("role") == "tool":
103
+ cut -= 1
104
+ middle = messages[1:cut]
105
+ recent = messages[cut:]
106
+ if not middle:
107
+ return messages, False
108
+
109
+ lines = []
110
+ for m in middle:
111
+ role = m.get("role", "?")
112
+ content = m.get("content") or ""
113
+ if m.get("tool_calls"):
114
+ names = ", ".join(tc["function"]["name"] for tc in m["tool_calls"])
115
+ content = (content + f" [called: {names}]").strip()
116
+ lines.append(f"{role}: {content}")
117
+ transcript = "\n".join(lines)[:12000]
118
+
119
+ try:
120
+ resp = client.chat(
121
+ [
122
+ {"role": "system", "content": _SUMMARY_SYS},
123
+ {"role": "user", "content": transcript},
124
+ ],
125
+ None,
126
+ None,
127
+ False,
128
+ )
129
+ summary = resp.content
130
+ except Exception:
131
+ return messages, False
132
+
133
+ new = [system, {"role": "user", "content": "[Summary of earlier conversation]\n" + summary}]
134
+ new += recent
135
+ return new, True
136
+
137
+
138
+ # --------------------------------------------------------------------------- #
139
+ # Phase 2: Graph-Aware Context Selection
140
+ # --------------------------------------------------------------------------- #
141
+ @dataclass
142
+ class DifferentialContextTracker:
143
+ """Tracks what content has been sent to the model to enable differential updates."""
144
+ sent_files: dict[str, str] = field(default_factory=dict) # file_path -> content_hash
145
+ sent_graph_nodes: set[str] = field(default_factory=set) # node_ids that have been sent
146
+
147
+ def _hash_content(self, content: str) -> str:
148
+ """Create a hash of content for change detection."""
149
+ return hashlib.md5(content.encode("utf-8")).hexdigest()
150
+
151
+ def update_file(self, file_path: str, content: str) -> bool:
152
+ """Update tracking for a file. Returns True if content changed."""
153
+ content_hash = self._hash_content(content)
154
+ if file_path in self.sent_files:
155
+ if self.sent_files[file_path] == content_hash:
156
+ return False # No change
157
+ self.sent_files[file_path] = content_hash
158
+ return True
159
+
160
+ def add_graph_nodes(self, node_ids: list[str]) -> None:
161
+ """Mark graph nodes as sent."""
162
+ self.sent_graph_nodes.update(node_ids)
163
+
164
+ def get_file_diff(self, file_path: str, content: str) -> Optional[str]:
165
+ """Return content if file changed, None if unchanged."""
166
+ content_hash = self._hash_content(content)
167
+ if file_path in self.sent_files and self.sent_files[file_path] == content_hash:
168
+ return None # Unchanged
169
+ return content
170
+
171
+ def get_new_graph_nodes(self, node_ids: list[str]) -> list[str]:
172
+ """Return node IDs that haven't been sent yet."""
173
+ return [nid for nid in node_ids if nid not in self.sent_graph_nodes]
174
+
175
+
176
+ def get_graph_aware_context(
177
+ task: str,
178
+ ctx: "ToolContext",
179
+ graph_manager,
180
+ hop_limit: int = 1,
181
+ tracker: Optional[DifferentialContextTracker] = None,
182
+ ) -> str:
183
+ """Use the code knowledge graph to pull in relevant code.
184
+
185
+ Given the current task/files-in-focus, use the Phase 1 graph to pull in
186
+ directly connected nodes (callers, callees, parent classes) up to a
187
+ configurable hop limit.
188
+
189
+ If tracker is provided, only returns new/changed content.
190
+ """
191
+ if not graph_manager or not graph_manager.graph.node_by_id:
192
+ return "" # Graph not available or empty
193
+
194
+ # Extract @file mentions from task to find focus files
195
+ focus_files = set()
196
+ for match in _MENTION_RE.finditer(task):
197
+ rel = match.group(1).strip(".,;:)")
198
+ try:
199
+ if not ctx.is_ignored(rel):
200
+ full = ctx.resolve(rel)
201
+ if full.is_file():
202
+ focus_files.add(str(full))
203
+ except Exception:
204
+ continue
205
+
206
+ if not focus_files:
207
+ return "" # No focus files, no graph context needed
208
+
209
+ # Get graph nodes for focus files
210
+ focus_node_ids = set()
211
+ for file_path in focus_files:
212
+ nodes = graph_manager.graph.get_nodes_by_file(file_path)
213
+ focus_node_ids.update(n.id for n in nodes)
214
+
215
+ # Get neighbors within hop limit
216
+ relevant_node_ids = set(focus_node_ids)
217
+ current_level = set(focus_node_ids)
218
+
219
+ for _hop in range(hop_limit):
220
+ next_level = set()
221
+ for nid in current_level:
222
+ neighbors = graph_manager.graph.get_neighbors(nid, direction="out")
223
+ for neighbor in neighbors:
224
+ if neighbor.id not in relevant_node_ids:
225
+ relevant_node_ids.add(neighbor.id)
226
+ next_level.add(neighbor.id)
227
+ current_level = next_level
228
+ if not current_level:
229
+ break
230
+
231
+ # If using differential tracking, filter to new nodes
232
+ if tracker:
233
+ new_node_ids = tracker.get_new_graph_nodes(list(relevant_node_ids))
234
+ relevant_node_ids = set(new_node_ids)
235
+
236
+ # Collect file paths for relevant nodes
237
+ relevant_files = set()
238
+ for nid in relevant_node_ids:
239
+ node = graph_manager.graph.get_node(nid)
240
+ if node:
241
+ relevant_files.add(node.file_path)
242
+
243
+ # Read file contents (with differential tracking)
244
+ context_parts = []
245
+ for file_path in sorted(relevant_files):
246
+ try:
247
+ if ctx.is_ignored(file_path):
248
+ continue
249
+ full_path = Path(file_path)
250
+ if not full_path.is_file():
251
+ continue
252
+ if full_path.stat().st_size > ctx.cfg.max_file_bytes:
253
+ continue
254
+ content = full_path.read_text(encoding="utf-8", errors="replace")
255
+
256
+ # Differential tracking
257
+ if tracker:
258
+ diff_content = tracker.get_file_diff(file_path, content)
259
+ if diff_content is None:
260
+ continue # Unchanged, skip
261
+ content = diff_content
262
+ tracker.update_file(file_path, content)
263
+
264
+ context_parts.append(f"--- {file_path} ---\n{content}")
265
+ except Exception:
266
+ continue
267
+
268
+ # Mark nodes as sent
269
+ if tracker:
270
+ tracker.add_graph_nodes(list(relevant_node_ids))
271
+
272
+ if not context_parts:
273
+ return ""
274
+
275
+ return "\n\n".join(context_parts)
276
+
277
+
278
+ def compact_with_summarization(
279
+ messages: list[dict],
280
+ max_tokens: int,
281
+ keep_recent: int = 6,
282
+ client=None,
283
+ ) -> tuple[list[dict], bool]:
284
+ """Compact history with LLM summarization of dropped content.
285
+
286
+ When compaction triggers, summarize dropped tool output/decisions rather
287
+ than naive oldest-first drop. This improves compaction quality at the
288
+ truncation boundary.
289
+ """
290
+ if estimate_tokens(messages) <= max_tokens:
291
+ return messages, False
292
+
293
+ if not client:
294
+ # Fall back to simple compaction if no client available
295
+ return compact_history(messages, max_tokens, keep_recent)
296
+
297
+ out = [dict(m) for m in messages]
298
+ changed = False
299
+
300
+ # Find content to drop
301
+ to_summarize = []
302
+ keep_indices = {0, len(out) - 1} # Always keep system and last message
303
+ for i in range(len(out) - keep_recent, len(out)):
304
+ keep_indices.add(i)
305
+
306
+ for i in range(1, len(out) - 1):
307
+ if i in keep_indices:
308
+ continue
309
+ if estimate_tokens(out) <= max_tokens:
310
+ break
311
+ m = out[i]
312
+ if m.get("role") in ("user", "assistant"):
313
+ to_summarize.append((i, m))
314
+ out[i] = None # Mark for removal
315
+ changed = True
316
+
317
+ # Remove marked messages
318
+ out = [m for m in out if m is not None]
319
+
320
+ # Generate summary if we dropped content
321
+ if to_summarize and client:
322
+ try:
323
+ summary_text = "\n".join(
324
+ f"{m.get('role', '?')}: {m.get('content', '')[:500]}"
325
+ for _, m in to_summarize
326
+ )
327
+ summary_text = summary_text[:8000] # Limit summary input
328
+
329
+ resp = client.chat(
330
+ [
331
+ {
332
+ "role": "system",
333
+ "content": "Summarize this conversation excerpt concisely. Focus on: decisions made, files changed, errors encountered, and current state.",
334
+ },
335
+ {"role": "user", "content": summary_text},
336
+ ],
337
+ None,
338
+ None,
339
+ False,
340
+ )
341
+
342
+ # Insert summary after system message
343
+ summary_msg = {
344
+ "role": "user",
345
+ "content": f"[Summary of earlier conversation]\n{resp.content}",
346
+ }
347
+ out.insert(1, summary_msg)
348
+ except Exception:
349
+ # If summarization fails, just use simple compaction
350
+ pass
351
+
352
+ return out, changed
krnl_agent/depaudit.py ADDED
@@ -0,0 +1,63 @@
1
+ """Dependency vulnerability audit.
2
+
3
+ Detects the project's package ecosystem(s) and runs the matching audit tool if it
4
+ is installed - `pip-audit` for Python, `npm audit` for Node, `cargo audit` for
5
+ Rust, `govulncheck` for Go. Each is best-effort: if the tool is missing we say so
6
+ and tell the agent how to install it, rather than failing. The agent can then act
7
+ on the findings (pin/upgrade versions) or surface them to the user.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import shutil
12
+ import subprocess
13
+
14
+ # (manifest filename, tool exe, audit command, install hint)
15
+ _ECOSYSTEMS = [
16
+ ("requirements.txt", "pip-audit", "pip-audit -r requirements.txt --progress-spinner off",
17
+ "pip install pip-audit"),
18
+ ("pyproject.toml", "pip-audit", "pip-audit --progress-spinner off", "pip install pip-audit"),
19
+ ("package.json", "npm", "npm audit --omit=dev", "install Node.js / npm"),
20
+ ("Cargo.toml", "cargo-audit", "cargo audit", "cargo install cargo-audit"),
21
+ ("go.mod", "govulncheck", "govulncheck ./...", "go install golang.org/x/vuln/cmd/govulncheck@latest"),
22
+ ]
23
+
24
+
25
+ def audit(ctx, timeout: int = 180) -> tuple[bool, str]:
26
+ """Run dependency audits for every detected ecosystem. Returns (clean, report)."""
27
+ root = ctx.root
28
+ detected = [(mf, exe, cmd, hint) for (mf, exe, cmd, hint) in _ECOSYSTEMS
29
+ if (root / mf).exists()]
30
+ if not detected:
31
+ return True, ("No recognized dependency manifest found "
32
+ "(requirements.txt, pyproject.toml, package.json, Cargo.toml, go.mod).")
33
+
34
+ blocks: list[str] = []
35
+ any_findings = False
36
+ seen_tools: set[str] = set()
37
+ for manifest, exe, cmd, hint in detected:
38
+ if exe in seen_tools:
39
+ continue
40
+ seen_tools.add(exe)
41
+ if not shutil.which(exe):
42
+ blocks.append(f"## {manifest}\n ⚠ '{exe}' not installed — `{hint}` to enable this audit.")
43
+ continue
44
+ try:
45
+ res = subprocess.run(cmd, cwd=root, shell=True, capture_output=True,
46
+ text=True, timeout=timeout)
47
+ except subprocess.TimeoutExpired:
48
+ blocks.append(f"## {manifest}\n ⚠ {exe} timed out after {timeout}s.")
49
+ continue
50
+ except Exception as e: # noqa: BLE001
51
+ blocks.append(f"## {manifest}\n ⚠ {exe} failed to run: {e}")
52
+ continue
53
+ out = (res.stdout + "\n" + res.stderr).strip()
54
+ if len(out) > 6000:
55
+ out = out[:6000] + "\n… (truncated)"
56
+ # Non-zero exit from these tools means vulnerabilities were found.
57
+ status = "VULNERABILITIES FOUND" if res.returncode != 0 else "clean"
58
+ if res.returncode != 0:
59
+ any_findings = True
60
+ blocks.append(f"## {manifest} ({exe}) — {status}\n{out or '(no output)'}")
61
+
62
+ report = "Dependency audit\n\n" + "\n\n".join(blocks)
63
+ return (not any_findings), report
krnl_agent/deploy.py ADDED
@@ -0,0 +1,245 @@
1
+ """Auto-deployment — one sentence to a live URL, across many providers.
2
+
3
+ A registry of deploy *targets*, each described declaratively: the CLI it needs, the
4
+ env var(s) that hold its credential, the headless deploy/rollback commands, the
5
+ config file(s) it expects, and whether it has a real free tier. The agent (or the
6
+ `deploy` / `ship` commands) uses this to:
7
+
8
+ * `readiness()` — which targets are usable right now (CLI installed + token set),
9
+ * `suggest()` — pick a sensible default target for a detected stack,
10
+ * `deploy_command()` / `rollback_command()` — build the exact shell command,
11
+ * `config_template()` — scaffold the platform config file.
12
+
13
+ Nothing here runs a command itself — building the command is separated from running
14
+ it so the loop can route execution through the normal approval + sandbox + audit
15
+ gates. Credentials are referenced by env-var name only and never read into strings.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import shutil
21
+ from dataclasses import dataclass, field
22
+
23
+ # kinds: edge | static | paas | container | cloud | db
24
+ @dataclass
25
+ class Target:
26
+ name: str
27
+ kind: str
28
+ cli: str # executable that must be installed
29
+ token_envs: list[str] # env vars holding the credential (any present = ok)
30
+ deploy_cmd: str # headless, non-interactive
31
+ rollback_cmd: str = "" # "" = no first-class rollback
32
+ config_files: list[str] = field(default_factory=list)
33
+ free_tier: bool = True
34
+ billable: bool = False # may create billable infra even on free plans
35
+ note: str = ""
36
+ health_supported: bool = True
37
+
38
+
39
+ # Ordered roughly by agent-friendliness (one-command source->URL first).
40
+ TARGETS: dict[str, Target] = {
41
+ # --- Edge / serverless (true one-command, perpetual free tier) ----------- #
42
+ "cloudrun": Target(
43
+ "cloudrun", "cloud", "gcloud", ["GOOGLE_APPLICATION_CREDENTIALS"],
44
+ "gcloud run deploy {service} --source . --region {region} --quiet "
45
+ "--allow-unauthenticated --format json",
46
+ "gcloud run services update-traffic {service} --to-revisions {revision}=100 "
47
+ "--region {region} --quiet",
48
+ ["Dockerfile (optional — buildpacks used otherwise)"],
49
+ free_tier=True, note="Always-free 2M req/mo; source->URL in one command."),
50
+ "cloudflare": Target(
51
+ "cloudflare", "edge", "wrangler", ["CLOUDFLARE_API_TOKEN"],
52
+ "npx wrangler deploy",
53
+ "npx wrangler rollback",
54
+ ["wrangler.toml"],
55
+ free_tier=True, note="100k req/day free, no cold start, commercial OK."),
56
+ "cfpages": Target(
57
+ "cfpages", "static", "wrangler", ["CLOUDFLARE_API_TOKEN"],
58
+ "npx wrangler pages deploy {dist}",
59
+ "", ["wrangler.toml (optional)"],
60
+ free_tier=True, note="Static/JAMstack on Cloudflare Pages."),
61
+ # --- PaaS / static (low friction) --------------------------------------- #
62
+ "vercel": Target(
63
+ "vercel", "paas", "vercel", ["VERCEL_TOKEN"],
64
+ "vercel deploy --prod --yes --token $VERCEL_TOKEN",
65
+ "vercel rollback {deployment} --yes --token $VERCEL_TOKEN",
66
+ ["vercel.json"],
67
+ free_tier=True, note="Hobby tier is non-commercial; great for Next.js/front-end."),
68
+ "netlify": Target(
69
+ "netlify", "static", "netlify", ["NETLIFY_AUTH_TOKEN"],
70
+ "netlify deploy --prod --dir {dist}",
71
+ "", ["netlify.toml"],
72
+ free_tier=True, note="Generous free static hosting."),
73
+ "fly": Target(
74
+ "fly", "container", "fly", ["FLY_API_TOKEN"],
75
+ "fly deploy --remote-only",
76
+ "fly releases rollback {version}",
77
+ ["fly.toml"],
78
+ free_tier=False, billable=True, note="No free tier (trial only); needs a card."),
79
+ "railway": Target(
80
+ "railway", "paas", "railway", ["RAILWAY_TOKEN", "RAILWAY_API_TOKEN"],
81
+ "railway up --ci",
82
+ "", ["railway.json"],
83
+ free_tier=False, billable=True, note="Trial credit only (~$5)."),
84
+ "render": Target(
85
+ "render", "paas", "render", ["RENDER_API_KEY"],
86
+ "render deploys create {service} --confirm --wait --output json",
87
+ "render rollback {service} --confirm",
88
+ ["render.yaml"],
89
+ free_tier=True, note="Free web service spins down when idle. Deploy hooks also work."),
90
+ # --- Containers / orchestration ----------------------------------------- #
91
+ "docker": Target(
92
+ "docker", "container", "docker", ["DOCKER_TOKEN", "DOCKER_PASSWORD"],
93
+ "docker buildx build --platform linux/amd64 -t {image} --push .",
94
+ "", ["Dockerfile", ".dockerignore"],
95
+ free_tier=True, note="Builds & pushes an image to a registry; pair with a runtime."),
96
+ "helm": Target(
97
+ "helm", "container", "helm", ["KUBECONFIG"],
98
+ "helm upgrade --install {release} {chart} -f values.yaml --atomic --timeout 5m",
99
+ "helm rollback {release} {revision}",
100
+ ["Chart.yaml", "values.yaml"],
101
+ free_tier=True, billable=True,
102
+ note="--atomic auto-rolls-back on failed deploy (self-healing primitive)."),
103
+ "kubernetes": Target(
104
+ "kubernetes", "container", "kubectl", ["KUBECONFIG"],
105
+ "kubectl apply -f {manifest}",
106
+ "kubectl rollout undo deployment/{deployment}",
107
+ ["*.yaml"],
108
+ free_tier=True, billable=True, note="Cluster costs apply."),
109
+ # --- Big cloud ---------------------------------------------------------- #
110
+ "aws-sam": Target(
111
+ "aws-sam", "cloud", "sam", ["AWS_ACCESS_KEY_ID"],
112
+ "sam deploy --no-confirm-changeset --resolve-s3 --capabilities CAPABILITY_IAM",
113
+ "", ["template.yaml", "samconfig.toml"],
114
+ free_tier=True, billable=True, note="Smoothest AWS path after first --guided run."),
115
+ "aws-apprunner": Target(
116
+ "aws-apprunner", "cloud", "aws", ["AWS_ACCESS_KEY_ID"],
117
+ "aws apprunner create-service --cli-input-json file://apprunner.json",
118
+ "", ["apprunner.json"],
119
+ free_tier=False, billable=True, note="Consumption-billed container service."),
120
+ "azure-aca": Target(
121
+ "azure-aca", "cloud", "az", ["AZURE_CLIENT_ID"],
122
+ "az containerapp up --name {service} --source .",
123
+ "", ["app.yaml (optional)"],
124
+ free_tier=True, billable=True, note="Always-free 2M req/mo; source->URL."),
125
+ # --- Databases (provisioned before the app) ----------------------------- #
126
+ "neon": Target(
127
+ "neon", "db", "neonctl", ["NEON_API_KEY"],
128
+ "neonctl projects create --name {name} --output json",
129
+ "", [], free_tier=True,
130
+ note="Serverless Postgres, instant branches, generous free tier."),
131
+ "supabase": Target(
132
+ "supabase", "db", "supabase", ["SUPABASE_ACCESS_TOKEN"],
133
+ "supabase db push",
134
+ "", ["supabase/config.toml"], free_tier=True,
135
+ note="Full backend: Postgres + auth + storage + edge functions."),
136
+ }
137
+
138
+ # Default target preference order for an autodetected app (free + one-command first).
139
+ _DEFAULT_ORDER = ["cloudrun", "cloudflare", "vercel", "netlify", "render", "fly",
140
+ "railway", "azure-aca", "aws-sam", "helm"]
141
+
142
+
143
+ def target(name: str) -> Target | None:
144
+ return TARGETS.get(name)
145
+
146
+
147
+ def has_token(t: Target) -> bool:
148
+ return any(os.getenv(e) for e in t.token_envs)
149
+
150
+
151
+ def cli_installed(t: Target) -> bool:
152
+ return shutil.which(t.cli) is not None
153
+
154
+
155
+ def readiness() -> list[dict]:
156
+ """One row per target: is the CLI installed, is a token set, is it usable now."""
157
+ rows = []
158
+ for t in TARGETS.values():
159
+ rows.append({
160
+ "name": t.name, "kind": t.kind, "cli": t.cli,
161
+ "cli_ok": cli_installed(t),
162
+ "token_env": " or ".join(t.token_envs),
163
+ "token_ok": has_token(t),
164
+ "ready": cli_installed(t) and has_token(t),
165
+ "free_tier": t.free_tier, "billable": t.billable, "note": t.note,
166
+ })
167
+ return rows
168
+
169
+
170
+ def suggest(detected_stack: str = "", prefer_ready: bool = True) -> str | None:
171
+ """Pick a default deploy target. Prefer one that's ready (CLI+token), else the
172
+ most agent-friendly free option for the order list."""
173
+ order = list(_DEFAULT_ORDER)
174
+ s = (detected_stack or "").lower()
175
+ if any(k in s for k in ("next", "react", "vite", "static", "astro", "vue")):
176
+ order = ["vercel", "netlify", "cloudflare", "cfpages"] + order
177
+ if prefer_ready:
178
+ for name in order:
179
+ t = TARGETS.get(name)
180
+ if t and cli_installed(t) and has_token(t):
181
+ return name
182
+ for name in order:
183
+ if name in TARGETS:
184
+ return name
185
+ return None
186
+
187
+
188
+ def _fill(cmd: str, params: dict) -> str:
189
+ out = cmd
190
+ for k, v in (params or {}).items():
191
+ out = out.replace("{" + k + "}", str(v))
192
+ return out
193
+
194
+
195
+ def deploy_command(name: str, params: dict | None = None) -> tuple[bool, str]:
196
+ """Return (ok, command-or-error). Does NOT run it."""
197
+ t = TARGETS.get(name)
198
+ if not t:
199
+ return False, f"Unknown deploy target '{name}'. Known: {', '.join(TARGETS)}"
200
+ if not cli_installed(t):
201
+ return False, f"'{t.cli}' CLI not installed for target {name}."
202
+ if not has_token(t):
203
+ return False, (f"No credential for {name}: set one of "
204
+ f"{', '.join(t.token_envs)} in the environment.")
205
+ return True, _fill(t.deploy_cmd, params or {})
206
+
207
+
208
+ def rollback_command(name: str, params: dict | None = None) -> tuple[bool, str]:
209
+ t = TARGETS.get(name)
210
+ if not t:
211
+ return False, f"Unknown target '{name}'."
212
+ if not t.rollback_cmd:
213
+ return False, f"{name} has no first-class rollback command."
214
+ return True, _fill(t.rollback_cmd, params or {})
215
+
216
+
217
+ # ---- config-file scaffolds (minimal, safe starting points) ----------------- #
218
+ _CONFIGS = {
219
+ "fly.toml": (
220
+ 'app = "{app}"\nprimary_region = "{region}"\n\n'
221
+ '[build]\n\n[http_service]\n internal_port = {port}\n force_https = true\n'
222
+ ' auto_stop_machines = true\n auto_start_machines = true\n min_machines_running = 0\n'),
223
+ "render.yaml": (
224
+ "services:\n - type: web\n name: {app}\n runtime: docker\n"
225
+ " plan: free\n healthCheckPath: /health\n"),
226
+ "vercel.json": '{\n "version": 2\n}\n',
227
+ "wrangler.toml": (
228
+ 'name = "{app}"\nmain = "{entry}"\ncompatibility_date = "2024-01-01"\n'),
229
+ "Dockerfile.python": (
230
+ "FROM python:3.12-slim\nWORKDIR /app\nCOPY requirements.txt .\n"
231
+ "RUN pip install --no-cache-dir -r requirements.txt\nCOPY . .\n"
232
+ "EXPOSE {port}\nCMD [\"python\", \"{entry}\"]\n"),
233
+ "Dockerfile.node": (
234
+ "FROM node:20-slim\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci --omit=dev\n"
235
+ "COPY . .\nEXPOSE {port}\nCMD [\"node\", \"{entry}\"]\n"),
236
+ }
237
+
238
+
239
+ def config_template(kind: str, params: dict | None = None) -> str | None:
240
+ tpl = _CONFIGS.get(kind)
241
+ if tpl is None:
242
+ return None
243
+ p = {"app": "my-app", "region": "iad", "port": 8080, "entry": "main.py"}
244
+ p.update(params or {})
245
+ return _fill(tpl, p)