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 +4 -0
- nightshift/__main__.py +8 -0
- nightshift/agent/__init__.py +0 -0
- nightshift/agent/entry.py +116 -0
- nightshift/auth.py +57 -0
- nightshift/cli/__init__.py +0 -0
- nightshift/cli/commands/__init__.py +0 -0
- nightshift/cli/commands/agents.py +56 -0
- nightshift/cli/commands/api_key.py +104 -0
- nightshift/cli/commands/deploy.py +151 -0
- nightshift/cli/commands/login.py +27 -0
- nightshift/cli/commands/logs.py +64 -0
- nightshift/cli/commands/run.py +94 -0
- nightshift/cli/config.py +52 -0
- nightshift/cli/main.py +39 -0
- nightshift/config.py +38 -0
- nightshift/events.py +124 -0
- nightshift/protocol/__init__.py +4 -0
- nightshift/protocol/events.py +36 -0
- nightshift/protocol/packaging.py +71 -0
- nightshift/registry.py +267 -0
- nightshift/sdk/__init__.py +4 -0
- nightshift/sdk/app.py +51 -0
- nightshift/sdk/config.py +15 -0
- nightshift/server.py +302 -0
- nightshift/task.py +111 -0
- nightshift/vm/__init__.py +0 -0
- nightshift/vm/manager.py +411 -0
- nightshift/vm/network.py +95 -0
- nightshift/vm/rootfs.py +126 -0
- nightshift_sdk-0.2.0.dist-info/METADATA +112 -0
- nightshift_sdk-0.2.0.dist-info/RECORD +35 -0
- nightshift_sdk-0.2.0.dist-info/WHEEL +4 -0
- nightshift_sdk-0.2.0.dist-info/entry_points.txt +2 -0
- nightshift_sdk-0.2.0.dist-info/licenses/LICENSE +191 -0
nightshift/__init__.py
ADDED
nightshift/__main__.py
ADDED
|
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))
|