nightshift-sdk 0.2.0__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.
nightshift/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from nightshift.sdk.app import NightshiftApp
2
+ from nightshift.sdk.config import AgentConfig
3
+
4
+ __all__ = ["NightshiftApp", "AgentConfig"]
nightshift/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Starts the Nightshift agent entry point"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from nightshift.agent.entry import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
File without changes
@@ -0,0 +1,116 @@
1
+ """In-VM entry point.
2
+
3
+ Runs inside the Firecracker VM on boot. Reads a manifest,
4
+ dynamically imports the agent function, calls it with the prompt,
5
+ and streams each yielded message out via SSE on :8080.
6
+
7
+ Usage: python -m nightshift.agent
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import importlib
14
+ import json
15
+ import os
16
+ import subprocess
17
+ import sys
18
+
19
+ from nightshift.events import (
20
+ CompletedEvent,
21
+ ErrorEvent,
22
+ EventLog,
23
+ StartedEvent,
24
+ )
25
+ from nightshift.protocol.events import serialize_message
26
+
27
+
28
+ async def serve_events(log: EventLog, run_id: str = "vm", port: int = 8080) -> None:
29
+ """Serve an HTTP /events SSE endpoint for the host to subscribe to."""
30
+ import uvicorn
31
+ from fastapi import FastAPI
32
+ from sse_starlette.sse import EventSourceResponse
33
+
34
+ app = FastAPI()
35
+
36
+ @app.get("/health")
37
+ async def health():
38
+ return {"status": "ok"}
39
+
40
+ @app.get("/events")
41
+ async def events():
42
+ return EventSourceResponse(log.stream(run_id))
43
+
44
+ config = uvicorn.Config(app, host="0.0.0.0", port=port, log_level="warning")
45
+ server = uvicorn.Server(config)
46
+ await server.serve()
47
+
48
+
49
+ async def run_agent() -> None:
50
+ """Main entry point for the in-VM agent."""
51
+ workspace = os.environ.get("NIGHTSHIFT_WORKSPACE", "/workspace")
52
+ agent_dir = os.environ.get("NIGHTSHIFT_AGENT_DIR", "/opt/nightshift/agent_pkg")
53
+ manifest_path = os.path.join(agent_dir, "manifest.json")
54
+
55
+ # Read manifest
56
+ with open(manifest_path) as f:
57
+ manifest = json.load(f)
58
+
59
+ module_name = manifest["module"]
60
+ function_name = manifest["function"]
61
+ prompt = manifest["prompt"]
62
+
63
+ log = EventLog()
64
+ run_id = "vm"
65
+
66
+ # Start event server in background
67
+ event_server_task = asyncio.create_task(serve_events(log, run_id=run_id, port=8080))
68
+ await asyncio.sleep(0.5)
69
+
70
+ try:
71
+ await log.publish(
72
+ run_id,
73
+ StartedEvent(workspace=workspace),
74
+ )
75
+
76
+ # Install agent dependencies from pyproject.toml if present
77
+ if manifest.get("has_pyproject"):
78
+ subprocess.run(
79
+ ["uv", "sync", "--project", agent_dir],
80
+ cwd=agent_dir,
81
+ check=True,
82
+ capture_output=True,
83
+ )
84
+
85
+ # Add agent dir to sys.path and import the agent module
86
+ if agent_dir not in sys.path:
87
+ sys.path.insert(0, agent_dir)
88
+ mod = importlib.import_module(module_name)
89
+ agent_fn = getattr(mod, function_name)
90
+
91
+ # Call the agent function (async generator) and stream messages
92
+ async for message in agent_fn(prompt):
93
+ data = serialize_message(message)
94
+ event_type = data.get("type", "agent.message")
95
+ await log.publish_raw(run_id, event_type, data)
96
+
97
+ await log.publish(run_id, CompletedEvent())
98
+
99
+ except Exception as e:
100
+ await log.publish(run_id, ErrorEvent(error=str(e)))
101
+
102
+ finally:
103
+ await asyncio.sleep(1)
104
+ event_server_task.cancel()
105
+ try:
106
+ await event_server_task
107
+ except asyncio.CancelledError:
108
+ pass
109
+
110
+
111
+ def main() -> None:
112
+ asyncio.run(run_agent())
113
+
114
+
115
+ if __name__ == "__main__":
116
+ main()
nightshift/auth.py ADDED
@@ -0,0 +1,57 @@
1
+ """API key authentication for the platform server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import secrets
8
+
9
+ from fastapi import Header, HTTPException
10
+
11
+ from nightshift.registry import AgentRegistry
12
+
13
+
14
+ def hash_api_key(key: str) -> str:
15
+ """SHA-256 hash of an API key."""
16
+ return hashlib.sha256(key.encode()).hexdigest()
17
+
18
+
19
+ def generate_api_key() -> str:
20
+ """Generate a new API key in ns_<32 hex chars> format."""
21
+ return f"ns_{secrets.token_hex(16)}"
22
+
23
+
24
+ async def bootstrap_api_key(registry: AgentRegistry) -> None:
25
+ """Seed the first API key from NIGHTSHIFT_API_KEY env var if set.
26
+
27
+ Idempotent — won't duplicate if the key already exists.
28
+ """
29
+ raw_key = os.environ.get("NIGHTSHIFT_API_KEY")
30
+ if not raw_key:
31
+ return
32
+
33
+ key_hash = hash_api_key(raw_key)
34
+ tenant_id = "default"
35
+ await registry.store_api_key(key_hash, tenant_id, label="bootstrap")
36
+
37
+
38
+ async def get_tenant_id(
39
+ registry: AgentRegistry,
40
+ authorization: str = Header(),
41
+ ) -> str:
42
+ """Extract and verify the API key from the Authorization header.
43
+
44
+ Expected format: 'Bearer ns_...'
45
+ Returns the tenant_id if valid, raises 401 otherwise.
46
+ """
47
+ if not authorization.startswith("Bearer "):
48
+ raise HTTPException(status_code=401, detail="Invalid authorization header")
49
+
50
+ token = authorization[7:] # strip "Bearer "
51
+ key_hash = hash_api_key(token)
52
+ tenant_id = await registry.get_tenant_by_key_hash(key_hash)
53
+
54
+ if tenant_id is None:
55
+ raise HTTPException(status_code=401, detail="Invalid API key")
56
+
57
+ return tenant_id
File without changes
File without changes
@@ -0,0 +1,56 @@
1
+ """nightshift agents — list and manage deployed agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+ import httpx
7
+
8
+ from nightshift.cli.config import get_auth_headers, get_url
9
+
10
+
11
+ @click.group(invoke_without_command=True)
12
+ @click.pass_context
13
+ def agents(ctx) -> None:
14
+ """List deployed agents. Use 'agents rm' to delete one."""
15
+ if ctx.invoked_subcommand is not None:
16
+ return
17
+
18
+ url = get_url()
19
+ headers = get_auth_headers()
20
+
21
+ try:
22
+ r = httpx.get(f"{url}/api/agents", headers=headers, timeout=30)
23
+ r.raise_for_status()
24
+ except httpx.HTTPStatusError as e:
25
+ raise click.ClickException(f"{e.response.status_code}: {e.response.text}")
26
+ except httpx.HTTPError as e:
27
+ raise click.ClickException(str(e))
28
+
29
+ agent_list = r.json()
30
+ if not agent_list:
31
+ click.echo("No agents deployed.")
32
+ return
33
+
34
+ # Table format
35
+ click.echo(f"{'NAME':<20} {'SOURCE':<25} {'UPDATED':<25}")
36
+ click.echo("-" * 70)
37
+ for a in agent_list:
38
+ click.echo(f"{a['name']:<20} {a['source_filename']:<25} {a['updated_at']:<25}")
39
+
40
+
41
+ @agents.command()
42
+ @click.argument("name")
43
+ def rm(name: str) -> None:
44
+ """Delete a deployed agent."""
45
+ url = get_url()
46
+ headers = get_auth_headers()
47
+
48
+ try:
49
+ r = httpx.delete(f"{url}/api/agents/{name}", headers=headers, timeout=30)
50
+ r.raise_for_status()
51
+ except httpx.HTTPStatusError as e:
52
+ raise click.ClickException(f"{e.response.status_code}: {e.response.text}")
53
+ except httpx.HTTPError as e:
54
+ raise click.ClickException(str(e))
55
+
56
+ click.echo(f"Deleted agent: {name}")
@@ -0,0 +1,104 @@
1
+ """nightshift api-key — generate and manage API keys.
2
+
3
+ These commands operate directly on the SQLite DB (no running server required).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+
10
+ import click
11
+
12
+ from nightshift.auth import generate_api_key, hash_api_key
13
+ from nightshift.config import NightshiftConfig
14
+ from nightshift.registry import AgentRegistry
15
+
16
+
17
+ async def _open_registry() -> AgentRegistry:
18
+ config = NightshiftConfig.from_env()
19
+ registry = AgentRegistry(config.db_path)
20
+ await registry.init_db()
21
+ return registry
22
+
23
+
24
+ @click.group("api-key")
25
+ def api_key() -> None:
26
+ """Generate and manage API keys (runs against the local DB)."""
27
+ pass
28
+
29
+
30
+ @api_key.command()
31
+ @click.option("--tenant", default="default", help="Tenant ID to associate with the key")
32
+ @click.option("--label", default="", help="Human-readable label for the key")
33
+ def generate(tenant: str, label: str) -> None:
34
+ """Generate a new API key and print it.
35
+
36
+ The raw key is only shown once — store it somewhere safe.
37
+ """
38
+
39
+ async def _run():
40
+ registry = await _open_registry()
41
+ try:
42
+ raw_key = generate_api_key()
43
+ key_hash = hash_api_key(raw_key)
44
+ await registry.store_api_key(key_hash, tenant, label=label)
45
+ return raw_key
46
+ finally:
47
+ await registry.close()
48
+
49
+ raw_key = asyncio.run(_run())
50
+ click.echo(raw_key)
51
+
52
+
53
+ @api_key.command("list")
54
+ def list_keys() -> None:
55
+ """List all API keys (shows hash prefix, tenant, label, created_at)."""
56
+
57
+ async def _run():
58
+ registry = await _open_registry()
59
+ try:
60
+ rows = await registry.db.execute_fetchall(
61
+ "SELECT key_hash, tenant_id, label, created_at FROM api_keys ORDER BY created_at"
62
+ )
63
+ return rows
64
+ finally:
65
+ await registry.close()
66
+
67
+ rows = asyncio.run(_run())
68
+ if not rows:
69
+ click.echo("No API keys found.")
70
+ return
71
+
72
+ click.echo(f"{'HASH PREFIX':<16} {'TENANT':<16} {'LABEL':<20} {'CREATED'}")
73
+ click.echo("-" * 76)
74
+ for r in rows:
75
+ click.echo(f"{r[0][:12]}... {r[1]:<16} {r[2]:<20} {r[3]}")
76
+
77
+
78
+ @api_key.command()
79
+ @click.argument("hash_prefix")
80
+ def revoke(hash_prefix: str) -> None:
81
+ """Revoke an API key by its hash prefix (from 'api-key list')."""
82
+
83
+ async def _run():
84
+ registry = await _open_registry()
85
+ try:
86
+ rows = await registry.db.execute_fetchall(
87
+ "SELECT key_hash FROM api_keys WHERE key_hash LIKE ?",
88
+ (f"{hash_prefix}%",),
89
+ )
90
+ if not rows:
91
+ raise click.ClickException(f"No key found matching prefix: {hash_prefix}")
92
+ if len(rows) > 1:
93
+ raise click.ClickException(
94
+ f"Prefix '{hash_prefix}' matches {len(rows)} keys — be more specific"
95
+ )
96
+ key_hash = rows[0][0]
97
+ await registry.db.execute("DELETE FROM api_keys WHERE key_hash = ?", (key_hash,))
98
+ await registry.db.commit()
99
+ return key_hash
100
+ finally:
101
+ await registry.close()
102
+
103
+ key_hash = asyncio.run(_run())
104
+ click.echo(f"Revoked key {key_hash[:12]}...")
@@ -0,0 +1,151 @@
1
+ """nightshift deploy — deploy an agent to the platform."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import io
7
+ import json
8
+ import os
9
+ import sys
10
+ import tarfile
11
+
12
+ import click
13
+ import httpx
14
+
15
+ from nightshift.cli.config import get_auth_headers, get_url
16
+
17
+ # Patterns to exclude from the archive
18
+ EXCLUDE_PATTERNS = {".git", "__pycache__", ".venv", ".env", "*.pyc", "node_modules", ".ruff_cache"}
19
+
20
+
21
+ def _should_exclude(path: str) -> bool:
22
+ """Check if a path should be excluded from the archive."""
23
+ parts = path.split(os.sep)
24
+ for part in parts:
25
+ if part in EXCLUDE_PATTERNS:
26
+ return True
27
+ for pattern in EXCLUDE_PATTERNS:
28
+ if pattern.startswith("*") and part.endswith(pattern[1:]):
29
+ return True
30
+ return False
31
+
32
+
33
+ def _make_archive(project_dir: str) -> bytes:
34
+ """Create a tar.gz archive of the project directory."""
35
+ buf = io.BytesIO()
36
+ with tarfile.open(fileobj=buf, mode="w:gz") as tar:
37
+ for root, dirs, files in os.walk(project_dir):
38
+ # Filter out excluded directories in-place
39
+ dirs[:] = [d for d in dirs if not _should_exclude(d)]
40
+
41
+ for f in files:
42
+ if _should_exclude(f):
43
+ continue
44
+ full_path = os.path.join(root, f)
45
+ arcname = os.path.relpath(full_path, project_dir)
46
+ tar.add(full_path, arcname=arcname)
47
+ buf.seek(0)
48
+ return buf.read()
49
+
50
+
51
+ def _discover_agents(file_path: str) -> dict:
52
+ """Import a file and find the NightshiftApp instance to discover agents."""
53
+ file_path = os.path.abspath(file_path)
54
+ module_name = os.path.splitext(os.path.basename(file_path))[0]
55
+
56
+ spec = importlib.util.spec_from_file_location(module_name, file_path)
57
+ if spec is None or spec.loader is None:
58
+ raise click.ClickException(f"Cannot import {file_path}")
59
+
60
+ mod = importlib.util.module_from_spec(spec)
61
+ sys.modules[module_name] = mod
62
+ spec.loader.exec_module(mod)
63
+
64
+ # Find the NightshiftApp instance
65
+ from nightshift.sdk.app import NightshiftApp
66
+
67
+ app = None
68
+ for attr_name in dir(mod):
69
+ obj = getattr(mod, attr_name)
70
+ if isinstance(obj, NightshiftApp):
71
+ app = obj
72
+ break
73
+
74
+ if app is None:
75
+ raise click.ClickException(f"No NightshiftApp instance found in {file_path}")
76
+
77
+ if not app._agents:
78
+ raise click.ClickException(f"No agents registered in {file_path}")
79
+
80
+ return {
81
+ name: {
82
+ "function_name": agent.fn.__name__,
83
+ "config": {
84
+ "workspace": agent.config.workspace,
85
+ "vcpu_count": agent.config.vcpu_count,
86
+ "mem_size_mib": agent.config.mem_size_mib,
87
+ "timeout_seconds": agent.config.timeout_seconds,
88
+ "forward_env": agent.config.forward_env,
89
+ "env": agent.config.env,
90
+ },
91
+ }
92
+ for name, agent in app._agents.items()
93
+ }
94
+
95
+
96
+ @click.command()
97
+ @click.argument("file", type=click.Path(exists=True))
98
+ def deploy(file: str) -> None:
99
+ """Deploy agents from a Python file to the platform.
100
+
101
+ FILE is the path to a Python file containing a NightshiftApp with
102
+ registered agents (e.g. examples/basic_agent.py).
103
+ """
104
+ url = get_url()
105
+ headers = get_auth_headers()
106
+
107
+ click.echo(f"Discovering agents in {file}...")
108
+ agents = _discover_agents(file)
109
+
110
+ # Determine project directory (where pyproject.toml is, or the file's dir)
111
+ file_dir = os.path.dirname(os.path.abspath(file))
112
+ project_dir = file_dir
113
+
114
+ # Walk up to find pyproject.toml
115
+ check = file_dir
116
+ while check != os.path.dirname(check):
117
+ if os.path.exists(os.path.join(check, "pyproject.toml")):
118
+ project_dir = check
119
+ break
120
+ check = os.path.dirname(check)
121
+
122
+ source_filename = os.path.relpath(os.path.abspath(file), project_dir)
123
+
124
+ click.echo(f"Packaging {project_dir}...")
125
+ archive = _make_archive(project_dir)
126
+ click.echo(f"Archive size: {len(archive)} bytes")
127
+
128
+ for name, info in agents.items():
129
+ click.echo(f"Deploying {name}...")
130
+ try:
131
+ r = httpx.post(
132
+ f"{url}/api/agents",
133
+ data={
134
+ "name": name,
135
+ "source_filename": source_filename,
136
+ "function_name": info["function_name"],
137
+ "config_json": json.dumps(info["config"]),
138
+ },
139
+ files={"archive": ("archive.tar.gz", archive, "application/gzip")},
140
+ headers=headers,
141
+ timeout=120,
142
+ )
143
+ r.raise_for_status()
144
+ data = r.json()
145
+ click.echo(f" {name}: {data['status']} (id: {data['id']})")
146
+ except httpx.HTTPStatusError as e:
147
+ click.echo(f" {name}: FAILED — {e.response.status_code} {e.response.text}", err=True)
148
+ except httpx.HTTPError as e:
149
+ click.echo(f" {name}: FAILED — {e}", err=True)
150
+
151
+ click.echo("Done.")
@@ -0,0 +1,27 @@
1
+ """nightshift login — store platform credentials."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+ import httpx
7
+
8
+ from nightshift.cli.config import save_config
9
+
10
+
11
+ @click.command()
12
+ @click.option("--url", required=True, help="Platform server URL (e.g. http://localhost:3000)")
13
+ @click.option("--api-key", required=True, help="API key (ns_...)")
14
+ def login(url: str, api_key: str) -> None:
15
+ """Authenticate with a Nightshift platform server."""
16
+ # Strip trailing slash
17
+ url = url.rstrip("/")
18
+
19
+ # Verify the connection
20
+ try:
21
+ r = httpx.get(f"{url}/health", timeout=10)
22
+ r.raise_for_status()
23
+ except httpx.HTTPError as e:
24
+ raise click.ClickException(f"Cannot reach server at {url}: {e}")
25
+
26
+ save_config({"url": url, "api_key": api_key})
27
+ click.echo(f"Logged in to {url}")
@@ -0,0 +1,64 @@
1
+ """nightshift logs — stream events from a previous run."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+
8
+ import click
9
+ import httpx
10
+ from httpx_sse import connect_sse
11
+
12
+ from nightshift.cli.config import get_auth_headers, get_url
13
+ from nightshift.events import TERMINAL_EVENTS
14
+
15
+
16
+ @click.command()
17
+ @click.argument("run_id")
18
+ def logs(run_id: str) -> None:
19
+ """Stream events from a run.
20
+
21
+ RUN_ID is the UUID returned by 'nightshift run'.
22
+ """
23
+ url = get_url()
24
+ headers = get_auth_headers()
25
+
26
+ try:
27
+ with httpx.Client(timeout=None, headers=headers) as client:
28
+ with connect_sse(client, "GET", f"{url}/api/runs/{run_id}/events") as sse:
29
+ for event in sse.iter_sse():
30
+ if not event.data:
31
+ continue
32
+ try:
33
+ data = json.loads(event.data)
34
+ except json.JSONDecodeError:
35
+ continue
36
+
37
+ event_type = data.get("type", event.event)
38
+ _print_event(event_type, data)
39
+
40
+ if event_type in TERMINAL_EVENTS:
41
+ return
42
+ except httpx.HTTPStatusError as e:
43
+ raise click.ClickException(f"{e.response.status_code}: {e.response.text}")
44
+ except httpx.HTTPError as e:
45
+ raise click.ClickException(f"Event stream error: {e}")
46
+
47
+
48
+ def _print_event(event_type: str, data: dict) -> None:
49
+ """Pretty-print an SSE event to stdout."""
50
+ if event_type == "nightshift.started":
51
+ click.echo(f"[started] workspace={data.get('workspace', '')}")
52
+ elif event_type == "nightshift.completed":
53
+ click.echo("[completed]")
54
+ elif event_type == "nightshift.error":
55
+ click.echo(f"[error] {data.get('error', '')}", err=True)
56
+ elif event_type == "nightshift.interrupted":
57
+ click.echo(f"[interrupted] reason={data.get('reason', '')}")
58
+ else:
59
+ text = data.get("text", data.get("content", ""))
60
+ if text:
61
+ click.echo(text, nl=False)
62
+ sys.stdout.flush()
63
+ else:
64
+ click.echo(json.dumps(data, indent=2))
@@ -0,0 +1,94 @@
1
+ """nightshift run — start an agent run and optionally follow events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+
9
+ import click
10
+ import httpx
11
+ from httpx_sse import connect_sse
12
+
13
+ from nightshift.cli.config import get_auth_headers, get_url
14
+ from nightshift.events import TERMINAL_EVENTS
15
+
16
+
17
+ @click.command()
18
+ @click.argument("agent_name")
19
+ @click.option("--prompt", "-p", required=True, help="Prompt to send to the agent")
20
+ @click.option("--follow", "-f", is_flag=True, help="Follow the event stream (like tail -f)")
21
+ def run(agent_name: str, prompt: str, follow: bool) -> None:
22
+ """Start a run for an agent on the platform."""
23
+ url = get_url()
24
+ headers = get_auth_headers()
25
+
26
+ # Forward ANTHROPIC_API_KEY from the local environment into the VM
27
+ runtime_env: dict[str, str] = {}
28
+ api_key = os.environ.get("ANTHROPIC_API_KEY")
29
+ if api_key:
30
+ runtime_env["ANTHROPIC_API_KEY"] = api_key
31
+ else:
32
+ click.echo("Warning: ANTHROPIC_API_KEY not set in local environment", err=True)
33
+
34
+ try:
35
+ r = httpx.post(
36
+ f"{url}/api/agents/{agent_name}/runs",
37
+ json={"prompt": prompt, "env": runtime_env},
38
+ headers=headers,
39
+ timeout=30,
40
+ )
41
+ r.raise_for_status()
42
+ except httpx.HTTPStatusError as e:
43
+ raise click.ClickException(f"{e.response.status_code}: {e.response.text}")
44
+ except httpx.HTTPError as e:
45
+ raise click.ClickException(str(e))
46
+
47
+ data = r.json()
48
+ run_id = data["id"]
49
+ click.echo(f"Run started: {run_id}")
50
+
51
+ if follow:
52
+ _follow_events(url, headers, run_id)
53
+
54
+
55
+ def _follow_events(url: str, headers: dict, run_id: str) -> None:
56
+ """Stream SSE events for a run to stdout."""
57
+ try:
58
+ with httpx.Client(timeout=None, headers=headers) as client:
59
+ with connect_sse(client, "GET", f"{url}/api/runs/{run_id}/events") as sse:
60
+ for event in sse.iter_sse():
61
+ if not event.data:
62
+ continue
63
+ try:
64
+ data = json.loads(event.data)
65
+ except json.JSONDecodeError:
66
+ continue
67
+
68
+ event_type = data.get("type", event.event)
69
+ _print_event(event_type, data)
70
+
71
+ if event_type in TERMINAL_EVENTS:
72
+ return
73
+ except httpx.HTTPError as e:
74
+ raise click.ClickException(f"Event stream error: {e}")
75
+
76
+
77
+ def _print_event(event_type: str, data: dict) -> None:
78
+ """Pretty-print an SSE event to stdout."""
79
+ if event_type == "nightshift.started":
80
+ click.echo(f"[started] workspace={data.get('workspace', '')}")
81
+ elif event_type == "nightshift.completed":
82
+ click.echo("[completed]")
83
+ elif event_type == "nightshift.error":
84
+ click.echo(f"[error] {data.get('error', '')}", err=True)
85
+ elif event_type == "nightshift.interrupted":
86
+ click.echo(f"[interrupted] reason={data.get('reason', '')}")
87
+ else:
88
+ # Agent message — print the text content if available
89
+ text = data.get("text", data.get("content", ""))
90
+ if text:
91
+ click.echo(text, nl=False)
92
+ sys.stdout.flush()
93
+ else:
94
+ click.echo(json.dumps(data, indent=2))