@invokehq/cli 0.2.7 → 0.2.9

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 CHANGED
@@ -30,7 +30,7 @@ invoke init support-agent --template crm-guardrail
30
30
  cd support-agent
31
31
 
32
32
  # 4. Run its local MCP server
33
- invoke dev
33
+ invoke dev install
34
34
 
35
35
  # 5. Register its tools with Invoke
36
36
  invoke deploy
package/agentify.py CHANGED
@@ -1236,12 +1236,18 @@ def read_project_config(root: Path) -> dict[str, Any]:
1236
1236
  return config
1237
1237
 
1238
1238
 
1239
- def is_agent_project(config: dict[str, Any]) -> bool:
1239
+ def is_agent_project(root: Path, config: dict[str, Any]) -> bool:
1240
1240
  runtime = str(config.get("runtime") or "").lower()
1241
1241
  agent_type = str(config.get("agent_type") or config.get("type") or "").lower()
1242
- return bool(config.get("entrypoint")) and (
1242
+ has_entrypoint = bool(config.get("entrypoint")) or any(
1243
+ (root / candidate).exists() for candidate in ("src/index.ts", "src/index.js", "index.ts", "index.js")
1244
+ )
1245
+ tools = config.get("tools")
1246
+ empty_tools = tools is None or tools == []
1247
+ return has_entrypoint and (
1243
1248
  runtime in {"node", "python", "claude-agent-sdk"}
1244
1249
  or agent_type in {"agent", "claude-agent", "claude-agent-sdk"}
1250
+ or empty_tools
1245
1251
  )
1246
1252
 
1247
1253
 
@@ -1403,7 +1409,7 @@ def save_deployment(record: dict[str, Any]) -> None:
1403
1409
  def deploy_command(args: argparse.Namespace) -> int:
1404
1410
  root = Path(args.path)
1405
1411
  raw_config = read_project_config(root)
1406
- if is_agent_project(raw_config):
1412
+ if is_agent_project(root, raw_config):
1407
1413
  from dataclasses import asdict
1408
1414
 
1409
1415
  from invoke.deploy import deploy_claude_agent
@@ -1418,7 +1424,8 @@ def deploy_command(args: argparse.Namespace) -> int:
1418
1424
  "name": result.plan.app_name,
1419
1425
  "provider_id": result.deployment_id,
1420
1426
  "slug": result.plan.app_name,
1421
- "gateway_url": result.modal_app_name,
1427
+ "gateway_url": result.endpoint_url,
1428
+ "dashboard_url": result.dashboard_url,
1422
1429
  "base_url": "modal",
1423
1430
  "tools": ["agent.run"],
1424
1431
  "deployed_at": dt.datetime.now(dt.timezone.utc).isoformat(),
package/invoke/deploy.py CHANGED
@@ -34,6 +34,7 @@ class DeployPlan:
34
34
  modal_volume: str
35
35
  modal_image: str = DEFAULT_MODAL_IMAGE
36
36
  env: dict[str, str] = field(default_factory=dict)
37
+ endpoint_name: str = "invoke"
37
38
  tracing_enabled: bool = True
38
39
  persistence: dict[str, str] = field(default_factory=dict)
39
40
  robustness: dict[str, bool] = field(default_factory=dict)
@@ -49,7 +50,10 @@ class DeployResult:
49
50
  status: str
50
51
  message: str
51
52
  modal_app_name: str | None = None
53
+ endpoint_url: str | None = None
54
+ dashboard_url: str | None = None
52
55
  trace_path: str | None = None
56
+ modal_source_path: str | None = None
53
57
  onyx_suggestions: list[OnyxSuggestion] = field(default_factory=list)
54
58
 
55
59
 
@@ -175,6 +179,11 @@ def deploy_claude_agent(
175
179
 
176
180
  suggestions = analyze_traces(trace_store.recent(limit=100)) if run_onyx else []
177
181
 
182
+ from .sandbox import write_modal_source
183
+
184
+ modal_source_path = write_modal_source(plan)
185
+ dashboard_url = f"https://modal.com/apps/{plan.app_name}"
186
+
178
187
  if dry_run:
179
188
  result = DeployResult(
180
189
  success=True,
@@ -182,7 +191,9 @@ def deploy_claude_agent(
182
191
  deployment_id=deployment_id,
183
192
  status="planned",
184
193
  message="Deployment plan generated. Modal deploy was not executed.",
194
+ dashboard_url=dashboard_url,
185
195
  trace_path=str(trace_store.path),
196
+ modal_source_path=str(modal_source_path),
186
197
  onyx_suggestions=suggestions,
187
198
  )
188
199
  write_local_deploy_record(result)
@@ -198,7 +209,10 @@ def deploy_claude_agent(
198
209
  status="deployed",
199
210
  message="Agent deployed to Modal with Invoke tracing and persistence enabled.",
200
211
  modal_app_name=modal_result.get("app_name", plan.app_name),
212
+ endpoint_url=modal_result.get("endpoint_url"),
213
+ dashboard_url=modal_result.get("dashboard_url", dashboard_url),
201
214
  trace_path=str(trace_store.path),
215
+ modal_source_path=str(modal_source_path),
202
216
  onyx_suggestions=suggestions,
203
217
  )
204
218
  write_local_deploy_record(result)
package/invoke/sandbox.py CHANGED
@@ -1,106 +1,388 @@
1
- """Modal sandbox definitions for hosted Invoke agents."""
1
+ """Modal sandbox generation for hosted Invoke agents."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from dataclasses import asdict
5
+ import json
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ import textwrap
6
10
  from pathlib import Path
7
11
  from typing import Any
8
12
 
9
13
  from .deploy import DeployPlan
10
14
 
11
15
 
12
- def _import_modal():
13
- try:
14
- import modal # type: ignore
15
- except ImportError as exc:
16
- raise RuntimeError(
17
- "Modal is required for hosted agent deployment. Install it with `pip install modal` "
18
- "and run `modal setup`, or use `--dry-run` to inspect the deployment plan."
19
- ) from exc
20
- return modal
16
+ ENDPOINT_RE = re.compile(r"https://[^\s\"']+modal\.run[^\s\"']*")
21
17
 
22
18
 
23
- def modal_objects(plan: DeployPlan):
24
- """Return Modal app/image/volume objects for a deployment plan."""
25
-
26
- modal = _import_modal()
27
- app = modal.App(plan.app_name)
28
- image = (
29
- modal.Image.debian_slim(python_version="3.11")
30
- .apt_install("nodejs", "npm")
31
- .pip_install("anthropic", "httpx")
32
- .env(plan.env)
19
+ def _require_modal_cli() -> None:
20
+ if shutil.which("modal"):
21
+ return
22
+ raise RuntimeError(
23
+ "Modal CLI is required for `invoke deploy` agent deployments. "
24
+ "Install and authenticate it with `pip install modal` and `modal setup`, "
25
+ "or run `invoke deploy --dry-run` to inspect the generated deployment first."
33
26
  )
34
- volume = modal.Volume.from_name(plan.modal_volume, create_if_missing=True)
35
- return modal, app, image, volume
36
27
 
37
28
 
38
- def deploy_modal_app(plan: DeployPlan) -> dict[str, Any]:
39
- """Deploy the Modal app skeleton for one Invoke agent.
40
-
41
- The actual Modal CLI invocation happens outside this module. This function
42
- validates that Modal is importable and returns the deployable object names,
43
- which lets the Python CLI remain deterministic in tests.
44
- """
45
-
46
- modal, app, image, volume = modal_objects(plan)
47
- project_root = Path(plan.project_root)
48
- entrypoint = plan.entrypoint
49
-
50
- @app.function(
51
- image=image,
52
- volumes={"/state": volume},
53
- timeout=60 * 20,
54
- secrets=[],
29
+ def _remote_entrypoint_command(entrypoint: str) -> str:
30
+ quoted = json.dumps(entrypoint)
31
+ return textwrap.dedent(
32
+ f"""\
33
+ def agent_command(prompt):
34
+ entrypoint = {quoted}
35
+ source = Path("/workspace") / entrypoint
36
+ if entrypoint.endswith(".ts"):
37
+ compiled = Path("/workspace/dist") / Path(entrypoint).with_suffix(".js").name
38
+ if compiled.exists():
39
+ return ["node", str(compiled), prompt]
40
+ return ["npx", "tsx", str(source), prompt]
41
+ return ["node", str(source), prompt]
42
+ """
55
43
  )
56
- def run_agent(prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
57
- """Run one agent task inside the Modal sandbox."""
44
+
45
+
46
+ def modal_source(plan: DeployPlan) -> str:
47
+ """Build the deployable Modal source file for one agent project."""
48
+
49
+ app_name = json.dumps(plan.app_name)
50
+ volume_name = json.dumps(plan.modal_volume)
51
+ project_root = json.dumps(plan.project_root)
52
+ env = json.dumps(plan.env, sort_keys=True)
53
+ endpoint_name = json.dumps(plan.endpoint_name)
54
+ agent_command_source = _remote_entrypoint_command(plan.entrypoint).rstrip()
55
+
56
+ template = textwrap.dedent(
57
+ f"""\
58
+ # Generated by Invoke. Do not edit by hand; rerun `invoke deploy`.
59
+ from __future__ import annotations
58
60
 
59
61
  import json
62
+ import os
60
63
  import subprocess
61
64
  import time
62
65
  import uuid
66
+ from pathlib import Path
67
+ from typing import Any
68
+
69
+ import modal
70
+ from fastapi import Request
63
71
 
64
- execution_id = "exec_" + uuid.uuid4().hex[:12]
65
- started_at = time.time()
66
- trace = {
67
- "execution_id": execution_id,
68
- "event": "agent_run_started",
69
- "prompt": prompt,
70
- "context": context or {},
71
- "entrypoint": entrypoint,
72
- "started_at": started_at,
73
- }
74
- with open("/state/traces.jsonl", "a", encoding="utf-8") as fh:
75
- fh.write(json.dumps(trace, sort_keys=True) + "\n")
76
-
77
- command = ["node", entrypoint, prompt]
78
- completed = subprocess.run(
79
- command,
80
- cwd="/workspace",
81
- text=True,
82
- capture_output=True,
83
- timeout=60 * 15,
84
- check=False,
72
+
73
+ APP_NAME = {app_name}
74
+ VOLUME_NAME = {volume_name}
75
+ PROJECT_ROOT = {project_root}
76
+ INVOKE_ENV = {env}
77
+
78
+ app = modal.App(APP_NAME)
79
+ volume = modal.Volume.from_name(VOLUME_NAME, create_if_missing=True)
80
+
81
+ image = (
82
+ modal.Image.debian_slim(python_version="3.11")
83
+ .apt_install("nodejs", "npm")
84
+ .pip_install("fastapi[standard]")
85
+ .add_local_dir(
86
+ PROJECT_ROOT,
87
+ remote_path="/workspace",
88
+ copy=True,
89
+ ignore=[
90
+ ".git",
91
+ ".invoke",
92
+ "node_modules",
93
+ "dist",
94
+ ".next",
95
+ "__pycache__",
96
+ "*.tgz",
97
+ ],
98
+ )
99
+ .run_commands(
100
+ "cd /workspace && if [ -f package-lock.json ]; then npm ci; elif [ -f package.json ]; then npm install; fi",
101
+ "cd /workspace && if [ -f package.json ]; then npm run build --if-present; fi",
102
+ )
103
+ .env(INVOKE_ENV)
85
104
  )
86
- final = {
87
- "execution_id": execution_id,
88
- "event": "agent_run_completed",
89
- "returncode": completed.returncode,
90
- "stdout": completed.stdout[-8000:],
91
- "stderr": completed.stderr[-8000:],
92
- "latency_ms": round((time.time() - started_at) * 1000, 2),
93
- }
94
- with open("/state/traces.jsonl", "a", encoding="utf-8") as fh:
95
- fh.write(json.dumps(final, sort_keys=True) + "\n")
96
- return final
97
105
 
106
+
107
+ def utc_ms():
108
+ return int(time.time() * 1000)
109
+
110
+
111
+ def append_jsonl(path, payload):
112
+ path.parent.mkdir(parents=True, exist_ok=True)
113
+ with path.open("a", encoding="utf-8") as fh:
114
+ fh.write(json.dumps(payload, sort_keys=True) + "\\n")
115
+
116
+
117
+ def read_jsonl(path):
118
+ if not path.exists():
119
+ return []
120
+ rows = []
121
+ for line in path.read_text(encoding="utf-8").splitlines():
122
+ try:
123
+ rows.append(json.loads(line))
124
+ except json.JSONDecodeError:
125
+ continue
126
+ return rows
127
+
128
+
129
+ def evaluate_policy(action, params):
130
+ lowered = str(action).lower()
131
+ sql = str(params.get("sql", "")).lower() if isinstance(params, dict) else ""
132
+ if action == "database.execute" or "disable row level security" in sql or "drop table" in sql:
133
+ return {{
134
+ "effect": "block",
135
+ "risk": "high",
136
+ "reason": "Direct database execution can bypass application policy or row-level security.",
137
+ "guardrails": ["policy_block", "sandbox_required"],
138
+ }}
139
+ if any(word in lowered for word in ("delete", "refund", "charge", "transfer")):
140
+ return {{
141
+ "effect": "require_approval",
142
+ "risk": "high",
143
+ "reason": "Financial or destructive action requires approval and reconciliation.",
144
+ "guardrails": ["approval", "idempotency_key", "state_reconciliation"],
145
+ }}
146
+ return {{
147
+ "effect": "allow",
148
+ "risk": "low",
149
+ "reason": "No blocking policy matched.",
150
+ "guardrails": ["trace"],
151
+ }}
152
+
153
+
154
+ def reconcile_state(expected, live):
155
+ drift = []
156
+ if isinstance(expected, dict) and isinstance(live, dict):
157
+ for key, expected_value in expected.items():
158
+ if live.get(key) != expected_value:
159
+ drift.append({{"key": key, "expected": expected_value, "live": live.get(key)}})
160
+ return {{"status": "changed" if drift else "valid", "drift": drift, "checked_at_ms": utc_ms()}}
161
+
162
+
163
+ def extract_action(payload):
164
+ raw = payload.get("action") or {{}}
165
+ if isinstance(raw, str):
166
+ return raw, payload.get("params") or {{}}
167
+ if isinstance(raw, dict):
168
+ return raw.get("action") or raw.get("name") or "agent.run", raw.get("params") or {{}}
169
+ return "agent.run", {{}}
170
+
171
+
172
+ def checkpoint_path():
173
+ return Path("/state/checkpoints.jsonl")
174
+
175
+
176
+ def trace_path():
177
+ return Path("/state/traces.jsonl")
178
+
179
+
180
+ def freeze_execution(execution_id, action, params, context):
181
+ checkpoint = {{
182
+ "checkpoint_id": "freeze_" + uuid.uuid4().hex[:12],
183
+ "execution_id": execution_id,
184
+ "action": action,
185
+ "params": params,
186
+ "context_snapshot": context,
187
+ "created_at_ms": utc_ms(),
188
+ }}
189
+ append_jsonl(checkpoint_path(), checkpoint)
190
+ return checkpoint
191
+
192
+
193
+ def find_checkpoint(checkpoint_id):
194
+ for checkpoint in reversed(read_jsonl(checkpoint_path())):
195
+ if checkpoint.get("checkpoint_id") == checkpoint_id:
196
+ return checkpoint
197
+ return None
198
+
199
+
200
+ __AGENT_COMMAND_SOURCE__
201
+
202
+
203
+ @app.function(image=image, volumes={{"/state": volume}}, timeout=60 * 20)
204
+ @modal.fastapi_endpoint(method="POST", label={endpoint_name})
205
+ async def invoke(request: Request):
206
+ started = utc_ms()
207
+ payload = await request.json()
208
+ execution_id = payload.get("execution_id") or "exec_" + uuid.uuid4().hex[:12]
209
+ prompt = payload.get("prompt") or payload.get("input") or ""
210
+ agent_id = payload.get("agent_id") or APP_NAME
211
+ action, params = extract_action(payload)
212
+ events = []
213
+
214
+ def event(step, status, detail=None):
215
+ item = {{"step": step, "status": status, "timestamp_ms": utc_ms(), "detail": detail or {{}}}}
216
+ events.append(item)
217
+ return item
218
+
219
+ event("request_received", "ok", {{"agent_id": agent_id, "action": action}})
220
+
221
+ if not isinstance(params, dict):
222
+ event("schema_checked", "failed", {{"reason": "params must be an object"}})
223
+ trace = {{
224
+ "execution_id": execution_id,
225
+ "agent_id": agent_id,
226
+ "action": action,
227
+ "status": "blocked",
228
+ "risk": "medium",
229
+ "final_outcome": "blocked_by_schema",
230
+ "events": events,
231
+ "latency_ms": utc_ms() - started,
232
+ }}
233
+ append_jsonl(trace_path(), trace)
234
+ return {{"success": False, "execution": trace, "error": "params must be an object"}}
235
+ event("schema_checked", "ok")
236
+
237
+ if payload.get("resume_checkpoint_id"):
238
+ checkpoint = find_checkpoint(payload["resume_checkpoint_id"])
239
+ if not checkpoint:
240
+ event("checkpoint_loaded", "missing", {{"checkpoint_id": payload["resume_checkpoint_id"]}})
241
+ return {{"success": False, "execution_id": execution_id, "error": "checkpoint not found", "events": events}}
242
+ event("checkpoint_loaded", "ok", {{"checkpoint_id": checkpoint["checkpoint_id"]}})
243
+ revalidation = reconcile_state(checkpoint.get("context_snapshot") or {{}}, payload.get("live_context") or {{}})
244
+ event("state_revalidated", revalidation["status"], revalidation)
245
+ if revalidation["status"] == "changed":
246
+ trace = {{
247
+ "execution_id": execution_id,
248
+ "agent_id": agent_id,
249
+ "action": action,
250
+ "status": "requeued",
251
+ "risk": "high",
252
+ "final_outcome": "requeued_due_to_state_drift",
253
+ "events": events,
254
+ "latency_ms": utc_ms() - started,
255
+ }}
256
+ append_jsonl(trace_path(), trace)
257
+ return {{"success": True, "status": "requeued", "execution": trace}}
258
+
259
+ policy = evaluate_policy(action, params)
260
+ event("policy_evaluated", "ok", policy)
261
+ if policy["effect"] == "block":
262
+ trace = {{
263
+ "execution_id": execution_id,
264
+ "agent_id": agent_id,
265
+ "action": action,
266
+ "status": "blocked",
267
+ "risk": policy["risk"],
268
+ "policy": policy,
269
+ "final_outcome": "blocked_by_policy",
270
+ "events": events,
271
+ "latency_ms": utc_ms() - started,
272
+ }}
273
+ append_jsonl(trace_path(), trace)
274
+ return {{"success": True, "status": "blocked", "execution": trace, "certificate_returned": True}}
275
+
276
+ if policy["effect"] == "require_approval" and not payload.get("approved"):
277
+ checkpoint = freeze_execution(
278
+ execution_id,
279
+ action,
280
+ params,
281
+ payload.get("context_snapshot") or payload.get("expected_state") or {{}},
282
+ )
283
+ event("execution_frozen", "pending_approval", {{"checkpoint_id": checkpoint["checkpoint_id"]}})
284
+ trace = {{
285
+ "execution_id": execution_id,
286
+ "agent_id": agent_id,
287
+ "action": action,
288
+ "status": "pending_approval",
289
+ "risk": policy["risk"],
290
+ "policy": policy,
291
+ "checkpoint": checkpoint,
292
+ "final_outcome": "frozen_for_human_review",
293
+ "events": events,
294
+ "latency_ms": utc_ms() - started,
295
+ }}
296
+ append_jsonl(trace_path(), trace)
297
+ return {{"success": True, "status": "pending_approval", "execution": trace, "certificate_returned": True}}
298
+
299
+ if payload.get("expected_state") is not None or payload.get("live_state") is not None:
300
+ reconciliation = reconcile_state(payload.get("expected_state") or {{}}, payload.get("live_state") or {{}})
301
+ event("state_reconciled", reconciliation["status"], reconciliation)
302
+ if reconciliation["status"] == "changed":
303
+ trace = {{
304
+ "execution_id": execution_id,
305
+ "agent_id": agent_id,
306
+ "action": action,
307
+ "status": "blocked",
308
+ "risk": "high",
309
+ "policy": policy,
310
+ "reconciliation": reconciliation,
311
+ "final_outcome": "blocked_due_to_state_drift",
312
+ "events": events,
313
+ "latency_ms": utc_ms() - started,
314
+ }}
315
+ append_jsonl(trace_path(), trace)
316
+ return {{"success": True, "status": "blocked", "execution": trace, "certificate_returned": True}}
317
+
318
+ event("agent_execution_started", "ok")
319
+ completed = subprocess.run(
320
+ agent_command(str(prompt)),
321
+ cwd="/workspace",
322
+ text=True,
323
+ capture_output=True,
324
+ timeout=int(payload.get("timeout_seconds") or 60 * 15),
325
+ check=False,
326
+ )
327
+ status = "succeeded" if completed.returncode == 0 else "failed"
328
+ event("agent_execution_completed", status, {{"returncode": completed.returncode}})
329
+ trace = {{
330
+ "execution_id": execution_id,
331
+ "agent_id": agent_id,
332
+ "action": action,
333
+ "status": status,
334
+ "risk": policy["risk"],
335
+ "policy": policy,
336
+ "final_outcome": status,
337
+ "events": events,
338
+ "latency_ms": utc_ms() - started,
339
+ "stdout": completed.stdout[-8000:],
340
+ "stderr": completed.stderr[-8000:],
341
+ "certificate_returned": True,
342
+ }}
343
+ append_jsonl(trace_path(), trace)
344
+ return {{"success": completed.returncode == 0, "execution": trace, "certificate_returned": True}}
345
+ """
346
+ )
347
+ return template.replace("__AGENT_COMMAND_SOURCE__", agent_command_source)
348
+
349
+
350
+ def write_modal_source(plan: DeployPlan) -> Path:
351
+ state_dir = Path(plan.project_root) / ".invoke"
352
+ state_dir.mkdir(parents=True, exist_ok=True)
353
+ path = state_dir / "modal_app.py"
354
+ path.write_text(modal_source(plan), encoding="utf-8")
355
+ return path
356
+
357
+
358
+ def _parse_endpoint(output: str) -> str | None:
359
+ match = ENDPOINT_RE.search(output)
360
+ return match.group(0).rstrip(".,)")
361
+
362
+
363
+ def deploy_modal_app(plan: DeployPlan) -> dict[str, Any]:
364
+ """Deploy the generated Modal app and return endpoint metadata."""
365
+
366
+ _require_modal_cli()
367
+ source_path = write_modal_source(plan)
368
+ completed = subprocess.run(
369
+ ["modal", "deploy", str(source_path)],
370
+ cwd=plan.project_root,
371
+ text=True,
372
+ capture_output=True,
373
+ timeout=60 * 10,
374
+ check=False,
375
+ )
376
+ output = (completed.stdout or "") + "\n" + (completed.stderr or "")
377
+ if completed.returncode != 0:
378
+ raise RuntimeError(f"Modal deploy failed with exit code {completed.returncode}:\n{output.strip()}")
379
+
380
+ endpoint_url = _parse_endpoint(output)
98
381
  return {
99
382
  "app_name": plan.app_name,
100
383
  "volume": plan.modal_volume,
101
- "entrypoint": plan.entrypoint,
102
- "project_root": str(project_root),
103
- "plan": asdict(plan),
104
- "modal_app": app,
105
- "modal_run_function": run_agent,
384
+ "endpoint_url": endpoint_url,
385
+ "dashboard_url": f"https://modal.com/apps/{plan.app_name}",
386
+ "source_path": str(source_path),
387
+ "deploy_output": output.strip(),
106
388
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invokehq/cli",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "CLI for Invoke, execution reliability infrastructure for AI agents.",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {