knit-sdk 0.1.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.
knit/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Knit Agent SDK — Build agents that connect to the Knit hub."""
2
+
3
+ from knit.agent import KnitAgent
4
+ from knit.daemon import KnitDaemon, detect_clis
5
+
6
+ __all__ = ["KnitAgent", "KnitDaemon", "detect_clis"]
knit/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """``python -m knit`` — Run a Knit agent from config."""
2
+
3
+
4
+ def main() -> None:
5
+ print("Knit Agent SDK — use the SDK programmatically or run an example:")
6
+ print(" python -m knit.examples.simple_agent")
7
+
8
+
9
+ if __name__ == "__main__":
10
+ main()
knit/agent.py ADDED
@@ -0,0 +1,602 @@
1
+ """KnitAgent — SDK for building agents on the Knit agent hub.
2
+
3
+ Provides two modes:
4
+
5
+ 1. **Conversational** (Claude Code, Codex, Cursor) — use individual API methods
6
+ as needed during a conversation. Call ``heartbeat()`` and ``list_tasks()``
7
+ when the user asks.
8
+
9
+ 2. **Continuous** (OpenClaw, daemons, long-running processes) — call
10
+ ``agent.serve(handle_task=my_handler)`` to run forever, automatically
11
+ heartbeating, polling for open tasks, and managing their full lifecycle.
12
+
13
+ Example (continuous)::
14
+
15
+ agent = KnitAgent(
16
+ name="code-reviewer",
17
+ hub_url="http://localhost:8000/api/v1",
18
+ api_key="sk_live_abc123",
19
+ description="Reviews PRs for security issues",
20
+ capabilities=[{"type": "code_review", "config": {}}],
21
+ )
22
+ agent.register()
23
+
24
+ def review_code(task: dict) -> dict:
25
+ # Do the actual work here
26
+ return {"summary": "Found 3 issues", "status": "completed"}
27
+
28
+ agent.serve(handle_task=review_code) # blocks forever
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import logging
34
+ import signal
35
+ import threading
36
+ import time
37
+ from concurrent.futures import Future, ThreadPoolExecutor
38
+ from contextlib import contextmanager
39
+ from typing import Any, Callable
40
+
41
+ import httpx
42
+
43
+ logger = logging.getLogger("knit-sdk")
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Exceptions
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ class KnitError(Exception):
52
+ """Base exception for Knit SDK errors."""
53
+
54
+
55
+ class KnitAPIError(KnitError):
56
+ """Raised when the Knit API returns a non-2xx response."""
57
+
58
+ def __init__(self, status_code: int, message: str):
59
+ self.status_code = status_code
60
+ self.message = message
61
+ super().__init__(f"[{status_code}] {message}")
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # TaskContext — wraps accept → progress → result lifecycle
66
+ # ---------------------------------------------------------------------------
67
+
68
+
69
+ class TaskContext:
70
+ """Context manager for a single task's lifecycle.
71
+
72
+ Handles accept, in_progress transition, and result submission
73
+ automatically. The user's ``with`` block does the actual work::
74
+
75
+ with agent.task_context(task) as ctx:
76
+ ctx.progress("Reviewing files...", 25)
77
+ result = do_work(task)
78
+ ctx.progress("Done", 100)
79
+ ctx.set_result(summary="All good", status="completed")
80
+ """
81
+
82
+ def __init__(self, agent: KnitAgent, task: dict) -> None:
83
+ self._agent = agent
84
+ self._task = task
85
+ self._task_id: str = task["id"]
86
+ self._result: dict[str, Any] | None = None
87
+ self._accepted = False
88
+
89
+ @property
90
+ def task(self) -> dict:
91
+ return self._task
92
+
93
+ @property
94
+ def task_id(self) -> str:
95
+ return self._task_id
96
+
97
+ def progress(self, message: str, progress_pct: int = 0) -> dict:
98
+ """Send a progress update. First call transitions to in_progress."""
99
+ try:
100
+ return self._agent.send_progress(
101
+ self._task_id, message, progress_pct
102
+ )
103
+ except KnitError:
104
+ logger.warning("Progress update failed for %s", self._task_id)
105
+ return {}
106
+
107
+ def set_result(
108
+ self,
109
+ summary: str,
110
+ status: str = "completed",
111
+ logs: str = "",
112
+ metrics: dict[str, Any] | None = None,
113
+ ) -> None:
114
+ """Record the result to submit on exit."""
115
+ self._result = {
116
+ "summary": summary,
117
+ "status": status,
118
+ "logs": logs,
119
+ "metrics": metrics or {},
120
+ }
121
+
122
+ def __enter__(self) -> TaskContext:
123
+ try:
124
+ self._agent.accept_task(self._task_id)
125
+ self._accepted = True
126
+ except KnitAPIError as exc:
127
+ logger.warning("Accept failed for %s: %s", self._task_id, exc)
128
+ raise
129
+ return self
130
+
131
+ def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
132
+ if exc_type is not None:
133
+ # Exception in the with block — submit as failed
134
+ try:
135
+ self._agent.submit_result(
136
+ task_id=self._task_id,
137
+ summary=f"Task failed: {exc_val}",
138
+ status="failed",
139
+ logs=str(exc_val),
140
+ )
141
+ except KnitError:
142
+ logger.exception(
143
+ "Failed to submit failure result for %s", self._task_id
144
+ )
145
+ return False # re-raise
146
+
147
+ if self._result is not None:
148
+ try:
149
+ self._agent.submit_result(
150
+ task_id=self._task_id,
151
+ summary=self._result["summary"],
152
+ status=self._result.get("status", "completed"),
153
+ logs=self._result.get("logs", ""),
154
+ metrics=self._result.get("metrics", {}),
155
+ )
156
+ except KnitError:
157
+ logger.exception(
158
+ "Failed to submit result for %s", self._task_id
159
+ )
160
+ elif self._accepted:
161
+ # Accepted but no result set — submit a default completion
162
+ try:
163
+ self._agent.submit_result(
164
+ task_id=self._task_id,
165
+ summary="Task completed.",
166
+ status="completed",
167
+ )
168
+ except KnitError:
169
+ logger.exception(
170
+ "Failed to submit default result for %s", self._task_id
171
+ )
172
+ return False
173
+
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # KnitAgent
177
+ # ---------------------------------------------------------------------------
178
+
179
+
180
+ class KnitAgent:
181
+ """A Knit agent that connects to a hub, polls for tasks, and reports results.
182
+
183
+ Supports both conversational (individual API calls) and continuous
184
+ (``serve()``) modes.
185
+
186
+ Example::
187
+
188
+ agent = KnitAgent(
189
+ name="my-bot",
190
+ hub_url="http://localhost:8000/api/v1",
191
+ api_key="sk_live_abc123",
192
+ description="A sample agent",
193
+ capabilities=[{"type": "code_review", "config": {}}],
194
+ )
195
+ resp = agent.register()
196
+ print(resp["agent_id"])
197
+ """
198
+
199
+ def __init__(
200
+ self,
201
+ name: str,
202
+ hub_url: str,
203
+ api_key: str,
204
+ description: str = "",
205
+ capabilities: list[dict] | None = None,
206
+ ):
207
+ self.name = name
208
+ self.base_url = hub_url.rstrip("/")
209
+ self.api_key = api_key
210
+ self.description = description
211
+ self.capabilities = capabilities or []
212
+
213
+ self.agent_id: str | None = None
214
+
215
+ self._client = httpx.Client(
216
+ base_url=self.base_url,
217
+ headers={
218
+ "Authorization": f"Bearer {self.api_key}",
219
+ "Content-Type": "application/json",
220
+ },
221
+ )
222
+
223
+ # Heartbeat loop state
224
+ self._heartbeat_thread: threading.Thread | None = None
225
+ self._heartbeat_stop: threading.Event | None = None
226
+
227
+ # Serve state
228
+ self._serve_stop: threading.Event | None = None
229
+
230
+ # ------------------------------------------------------------------
231
+ # Internal helpers
232
+ # ------------------------------------------------------------------
233
+
234
+ def _request(self, method: str, path: str, **kwargs) -> dict:
235
+ try:
236
+ resp = self._client.request(method, path, **kwargs)
237
+ except httpx.RequestError as exc:
238
+ raise KnitError(f"Request failed: {exc}") from exc
239
+
240
+ if not resp.is_success:
241
+ detail = resp.json().get("detail") if resp.text else None
242
+ if isinstance(detail, dict):
243
+ msg = detail.get("message", resp.text)
244
+ elif isinstance(detail, str):
245
+ msg = detail
246
+ else:
247
+ msg = resp.text or resp.reason_phrase
248
+ raise KnitAPIError(resp.status_code, msg)
249
+
250
+ body = resp.json()
251
+ if "data" in body:
252
+ return body["data"]
253
+ return body
254
+
255
+ def _get(self, path: str, **kwargs) -> dict | list:
256
+ return self._request("GET", path, **kwargs)
257
+
258
+ def _post(self, path: str, **kwargs) -> dict:
259
+ return self._request("POST", path, **kwargs)
260
+
261
+ def _patch(self, path: str, **kwargs) -> dict:
262
+ return self._request("PATCH", path, **kwargs)
263
+
264
+ # ------------------------------------------------------------------
265
+ # Public API methods
266
+ # ------------------------------------------------------------------
267
+
268
+ def register(self) -> dict:
269
+ """Register this agent with the hub (first-time only).
270
+
271
+ Sends ``POST /agents/register``. Saves ``agent_id`` on the instance.
272
+ """
273
+ body: dict[str, Any] = {
274
+ "name": self.name,
275
+ "description": self.description,
276
+ }
277
+ if self.capabilities:
278
+ body["capabilities"] = self.capabilities
279
+
280
+ data = self._post("/agents/register", json=body)
281
+ self.agent_id = str(data["agent_id"])
282
+ logger.info("Agent %s registered: %s", self.name, self.agent_id)
283
+ return data
284
+
285
+ def heartbeat(
286
+ self, status: str = "online", message: str | None = None
287
+ ) -> dict:
288
+ """Send a heartbeat ping. ``POST /agents/heartbeat``"""
289
+ body: dict[str, Any] = {"status": status}
290
+ if message is not None:
291
+ body["message"] = message
292
+ return self._post("/agents/heartbeat", json=body)
293
+
294
+ def list_tasks(self, status: str | None = None) -> list[dict]:
295
+ """List tasks. ``GET /tasks`` (agent-scoped)."""
296
+ params: dict[str, Any] = {}
297
+ if status:
298
+ params["status"] = status
299
+ return self._get("/tasks", params=params) # type: ignore[return-value]
300
+
301
+ def get_task(self, task_id: str) -> dict:
302
+ """Get full task details. ``GET /tasks/{task_id}``"""
303
+ return self._get(f"/tasks/{task_id}") # type: ignore[return-value]
304
+
305
+ def accept_task(self, task_id: str) -> dict:
306
+ """Accept a task. ``POST /tasks/{task_id}/accept``"""
307
+ return self._post(f"/tasks/{task_id}/accept")
308
+
309
+ def decline_task(self, task_id: str, reason: str = "") -> dict:
310
+ """Decline a task. ``POST /tasks/{task_id}/decline``"""
311
+ body: dict[str, Any] = {}
312
+ if reason:
313
+ body["reason"] = reason
314
+ return self._post(f"/tasks/{task_id}/decline", json=body)
315
+
316
+ def update_status(
317
+ self, task_id: str, status: str, reason: str = ""
318
+ ) -> dict:
319
+ """Update task status. ``PATCH /tasks/{task_id}/status``"""
320
+ body: dict[str, Any] = {"status": status}
321
+ if reason:
322
+ body["reason"] = reason
323
+ return self._patch(f"/tasks/{task_id}/status", json=body)
324
+
325
+ def send_progress(
326
+ self, task_id: str, message: str, progress_pct: int = 0
327
+ ) -> dict:
328
+ """Send a progress update. ``POST /tasks/{task_id}/progress``"""
329
+ body: dict[str, Any] = {
330
+ "message": message,
331
+ "progress_pct": progress_pct,
332
+ }
333
+ return self._post(f"/tasks/{task_id}/progress", json=body)
334
+
335
+ def submit_result(
336
+ self,
337
+ task_id: str,
338
+ summary: str,
339
+ status: str = "completed",
340
+ artifacts: list[dict] | None = None,
341
+ logs: str = "",
342
+ metrics: dict | None = None,
343
+ ) -> dict:
344
+ """Submit final result. ``POST /tasks/{task_id}/result``"""
345
+ body: dict[str, Any] = {
346
+ "status": status,
347
+ "summary": summary,
348
+ "logs": logs,
349
+ "metrics": metrics or {},
350
+ "artifacts": artifacts or [],
351
+ }
352
+ return self._post(f"/tasks/{task_id}/result", json=body)
353
+
354
+ # ------------------------------------------------------------------
355
+ # Task lifecycle helpers
356
+ # ------------------------------------------------------------------
357
+
358
+ def task_context(self, task: dict) -> TaskContext:
359
+ """Return a context manager that handles the full task lifecycle.
360
+
361
+ Usage::
362
+
363
+ with agent.task_context(task) as ctx:
364
+ ctx.progress("Starting...", 5)
365
+ result = do_work(task)
366
+ ctx.progress("Done", 100)
367
+ ctx.set_result(summary="All done", status="completed")
368
+
369
+ The context manager automatically:
370
+ - Accepts the task on entry
371
+ - Submits the result on normal exit (using the value from ``set_result``)
372
+ - Submits a failure result if the block raises an exception
373
+ """
374
+ return TaskContext(self, task)
375
+
376
+ def handle_task(
377
+ self,
378
+ task: dict,
379
+ handler: Callable[[dict, TaskContext], dict | None],
380
+ ) -> bool:
381
+ """Handle a single task through its full lifecycle.
382
+
383
+ Accepts the task, calls ``handler(task, ctx)``, and submits the result.
384
+ Progress updates from inside the handler are sent via ``ctx.progress()``.
385
+
386
+ The handler can return a result dict directly, or call
387
+ ``ctx.set_result()`` inside the handler.
388
+
389
+ Returns True if the task was handled successfully, False on error.
390
+ """
391
+ task_id = task.get("id", "unknown")
392
+ title = task.get("title", task_id)
393
+ logger.info("Handling task: %s (%s)", title, task_id)
394
+
395
+ try:
396
+ with self.task_context(task) as ctx:
397
+ result = handler(task, ctx)
398
+ if result is not None:
399
+ ctx.set_result(
400
+ summary=result.get("summary", "Done."),
401
+ status=result.get("status", "completed"),
402
+ logs=result.get("logs", ""),
403
+ metrics=result.get("metrics"),
404
+ )
405
+ return True
406
+ except KnitAPIError as exc:
407
+ logger.error(
408
+ "API error handling task %s: %s", task_id, exc
409
+ )
410
+ return False
411
+ except Exception:
412
+ logger.exception(
413
+ "Unexpected error handling task %s", task_id
414
+ )
415
+ return False
416
+
417
+ # ------------------------------------------------------------------
418
+ # Heartbeat loop
419
+ # ------------------------------------------------------------------
420
+
421
+ def start_heartbeat_loop(
422
+ self, interval: int = 30, message: str | None = None
423
+ ) -> None:
424
+ """Start a background daemon thread that sends heartbeats every
425
+ *interval* seconds.
426
+ """
427
+ if self._heartbeat_thread is not None:
428
+ return
429
+
430
+ self._heartbeat_stop = threading.Event()
431
+
432
+ def _loop() -> None:
433
+ while not self._heartbeat_stop.wait(interval): # type: ignore[union-attr]
434
+ try:
435
+ self.heartbeat(status="online", message=message)
436
+ logger.debug("Heartbeat sent")
437
+ except KnitError:
438
+ logger.warning("Heartbeat failed", exc_info=True)
439
+
440
+ self._heartbeat_thread = threading.Thread(target=_loop, daemon=True)
441
+ self._heartbeat_thread.start()
442
+ logger.info(
443
+ "Heartbeat loop started (every %ds)", interval
444
+ )
445
+
446
+ def stop_heartbeat_loop(self) -> None:
447
+ """Stop the heartbeat background thread."""
448
+ if self._heartbeat_stop is not None:
449
+ self._heartbeat_stop.set()
450
+ if self._heartbeat_thread is not None:
451
+ self._heartbeat_thread.join(timeout=5)
452
+ self._heartbeat_thread = None
453
+ self._heartbeat_stop = None
454
+ logger.info("Heartbeat loop stopped")
455
+
456
+ # ------------------------------------------------------------------
457
+ # Continuous agent loop — ``serve()``
458
+ # ------------------------------------------------------------------
459
+
460
+ def serve(
461
+ self,
462
+ handle_task: Callable[[dict, TaskContext], dict | None],
463
+ *,
464
+ poll_interval: int = 10,
465
+ heartbeat_interval: int = 30,
466
+ heartbeat_message: str | None = None,
467
+ task_status: str = "open",
468
+ max_parallel: int = 1,
469
+ run_once: bool = False,
470
+ ) -> None:
471
+ """Run the agent continuously — poll, heartbeat, handle tasks.
472
+
473
+ This is the **main entry point for long-running agents** (daemons,
474
+ OpenClaw, background processes). It blocks until interrupted.
475
+
476
+ Args:
477
+ handle_task: Called as ``handle_task(task_dict, ctx)`` for each
478
+ open task. Use ``ctx.progress()`` to report progress and
479
+ ``ctx.set_result()`` to set the final result. May also
480
+ return a result dict directly.
481
+ poll_interval: Seconds between task polls (default 10).
482
+ heartbeat_interval: Seconds between heartbeats (default 30).
483
+ heartbeat_message: Optional static message for heartbeats.
484
+ task_status: Task status to poll for (default ``"open"``).
485
+ max_parallel: Max concurrent task handlers (default 1). Set > 1
486
+ to handle multiple tasks at once using a thread pool.
487
+ run_once: If True, poll once and return. Useful for cron jobs.
488
+
489
+ Example::
490
+
491
+ def my_handler(task, ctx):
492
+ ctx.progress("Starting work...", 5)
493
+ # ... do the actual work ...
494
+ ctx.progress("All done", 100)
495
+ return {"summary": "Reviewed 12 files", "status": "completed"}
496
+
497
+ agent.serve(my_handler)
498
+ """
499
+ logger.info(
500
+ "Starting serve loop: poll_every=%ds, heartbeat_every=%ds, "
501
+ "max_parallel=%d",
502
+ poll_interval, heartbeat_interval, max_parallel,
503
+ )
504
+
505
+ # Start background heartbeat
506
+ self.start_heartbeat_loop(
507
+ interval=heartbeat_interval, message=heartbeat_message
508
+ )
509
+
510
+ # Set up signal handling for clean shutdown
511
+ self._serve_stop = threading.Event()
512
+
513
+ def _on_signal(signum: int, frame: Any) -> None:
514
+ logger.info("Received signal %d, shutting down...", signum)
515
+ self._serve_stop.set() # type: ignore[union-attr]
516
+
517
+ for sig in (signal.SIGINT, signal.SIGTERM):
518
+ try:
519
+ signal.signal(sig, _on_signal)
520
+ except (ValueError, OSError):
521
+ pass # Not in main thread (e.g. OpenClaw) — that's fine
522
+
523
+ executor = (
524
+ ThreadPoolExecutor(max_workers=max_parallel)
525
+ if max_parallel > 1
526
+ else None
527
+ )
528
+
529
+ try:
530
+ while not self._serve_stop.is_set():
531
+ try:
532
+ tasks = self.list_tasks(status=task_status)
533
+ if tasks:
534
+ logger.info("Found %d open task(s)", len(tasks))
535
+ for task in tasks:
536
+ if self._serve_stop.is_set():
537
+ break
538
+ if executor is not None:
539
+ executor.submit(
540
+ self.handle_task, task, handle_task
541
+ )
542
+ else:
543
+ self.handle_task(task, handle_task)
544
+ except KnitError:
545
+ logger.warning(
546
+ "Poll cycle failed, retrying in %ds...",
547
+ poll_interval,
548
+ exc_info=True,
549
+ )
550
+
551
+ if run_once:
552
+ break
553
+
554
+ self._serve_stop.wait(poll_interval)
555
+ finally:
556
+ logger.info("Serve loop stopped")
557
+ self.stop_heartbeat_loop()
558
+ if executor is not None:
559
+ executor.shutdown(wait=True)
560
+
561
+ # ------------------------------------------------------------------
562
+ # Legacy poll-and-handle (kept for backward compatibility)
563
+ # ------------------------------------------------------------------
564
+
565
+ def poll_and_handle(
566
+ self,
567
+ callback: Callable[[dict], Any],
568
+ interval: int = 10,
569
+ task_status: str = "open",
570
+ ) -> None:
571
+ """Poll for tasks and call ``callback(task)`` for each.
572
+
573
+ **Deprecated:** Prefer ``serve(handle_task=...)`` for new code.
574
+ It handles the full lifecycle automatically.
575
+
576
+ This method blocks indefinitely.
577
+ """
578
+ while True:
579
+ try:
580
+ tasks = self.list_tasks(status=task_status)
581
+ for task in tasks:
582
+ callback(task)
583
+ except KnitError:
584
+ logger.warning("Poll cycle failed", exc_info=True)
585
+ time.sleep(interval)
586
+
587
+ # ------------------------------------------------------------------
588
+ # Cleanup
589
+ # ------------------------------------------------------------------
590
+
591
+ def close(self) -> None:
592
+ """Stop heartbeats, signal serve loop, close HTTP client."""
593
+ if self._serve_stop is not None:
594
+ self._serve_stop.set()
595
+ self.stop_heartbeat_loop()
596
+ self._client.close()
597
+
598
+ def __enter__(self) -> KnitAgent:
599
+ return self
600
+
601
+ def __exit__(self, *args: Any) -> None:
602
+ self.close()
knit/cli.py ADDED
@@ -0,0 +1,541 @@
1
+ """Knit CLI — manage tasks, agents, projects, and autopilots from the terminal.
2
+
3
+ Usage::
4
+
5
+ # Set auth (agent API key for task operations)
6
+ export KNIT_API_KEY=sk_live_xxx
7
+ knit task list --status open
8
+
9
+ # Set auth (JWT token for admin operations)
10
+ export KNIT_JWT_TOKEN=<jwt_from_login>
11
+ knit project list
12
+ knit autopilot trigger <id>
13
+
14
+ # Or use --token on individual commands
15
+ knit agent approve <id> --token $(knit auth login)
16
+
17
+ # Run the daemon
18
+ knit daemon start
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import json
25
+ import os
26
+ import sys
27
+ from typing import Any
28
+
29
+ import httpx
30
+
31
+ from knit.agent import KnitAgent
32
+
33
+ DEFAULT_HUB = os.getenv("KNIT_HUB_URL", "http://localhost:8000/api/v1")
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Auth helpers
38
+ # ---------------------------------------------------------------------------
39
+
40
+
41
+ def _get_agent() -> KnitAgent:
42
+ """Create a KnitAgent from env vars."""
43
+ api_key = os.getenv("KNIT_API_KEY")
44
+ if not api_key:
45
+ print("Error: KNIT_API_KEY environment variable is required for agent operations.", file=sys.stderr)
46
+ sys.exit(1)
47
+ return KnitAgent(
48
+ name="knit-cli",
49
+ hub_url=os.getenv("KNIT_HUB_URL", DEFAULT_HUB),
50
+ api_key=api_key,
51
+ )
52
+
53
+
54
+ def _get_admin_client(token: str | None = None) -> httpx.Client | None:
55
+ """Create an httpx client with JWT auth."""
56
+ token = token or os.getenv("KNIT_JWT_TOKEN")
57
+ if not token:
58
+ return None
59
+ hub = os.getenv("KNIT_HUB_URL", DEFAULT_HUB)
60
+ return httpx.Client(
61
+ base_url=hub.rstrip("/"),
62
+ headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
63
+ )
64
+
65
+
66
+ def _admin_request(method: str, path: str, token: str | None = None,
67
+ json_data: dict | None = None, params: dict | None = None) -> Any:
68
+ """Make an authenticated request with a JWT token (admin operations)."""
69
+ client = _get_admin_client(token)
70
+ if not client:
71
+ print("Error: --token or KNIT_JWT_TOKEN required (admin operation).\n"
72
+ "Run `knit auth login` to get a token, then set KNIT_JWT_TOKEN.", file=sys.stderr)
73
+ sys.exit(1)
74
+ try:
75
+ resp = client.request(method, path, json=json_data, params=params)
76
+ if not resp.is_success:
77
+ detail = {}
78
+ try:
79
+ detail = resp.json().get("detail", {}) or {}
80
+ except Exception:
81
+ pass
82
+ msg = detail.get("message", resp.text) if isinstance(detail, dict) else str(detail)
83
+ raise Exception(f"[{resp.status_code}] {msg}")
84
+ body = resp.json()
85
+ if "data" in body:
86
+ return body["data"]
87
+ return body
88
+ finally:
89
+ client.close()
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Output helpers
94
+ # ---------------------------------------------------------------------------
95
+
96
+
97
+ def _output(data: Any, raw: bool = False) -> None:
98
+ """Print output — JSON for scripts, pretty for humans."""
99
+ if raw:
100
+ print(json.dumps(data, indent=2, default=str))
101
+ elif isinstance(data, list):
102
+ for item in data:
103
+ _print_item(item)
104
+ elif isinstance(data, dict):
105
+ _print_item(data)
106
+ else:
107
+ print(data)
108
+
109
+
110
+ def _print_item(item: dict) -> None:
111
+ """Print a single item in human-readable format."""
112
+ name = item.get("title") or item.get("name") or item.get("id", "?")
113
+ project = item.get("project_id", "")
114
+ project_str = f" [{project[:8]}]" if project else ""
115
+ status = item.get("status") or item.get("registration_status", "")
116
+ priority = item.get("priority", "")
117
+ tags = []
118
+ if priority:
119
+ tags.append(priority.upper())
120
+ if status:
121
+ tags.append(status)
122
+ tag_str = f" [{', '.join(tags)}]" if tags else ""
123
+ print(f" {item.get('id', '?'):>12} {name}{project_str}{tag_str}")
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # Subcommands
128
+ # ---------------------------------------------------------------------------
129
+
130
+
131
+ def cmd_auth(args: argparse.Namespace) -> None:
132
+ """Handle ``knit auth <subcommand>``."""
133
+ if args.sub == "login":
134
+ hub = os.getenv("KNIT_HUB_URL", DEFAULT_HUB)
135
+ with httpx.Client(base_url=hub.rstrip("/")) as client:
136
+ resp = client.post("/auth/login", json={
137
+ "email": args.email,
138
+ "password": args.password,
139
+ })
140
+ if resp.is_success:
141
+ data = resp.json().get("data", {})
142
+ print(data.get("access_token", ""))
143
+ else:
144
+ detail = resp.json().get("detail", {})
145
+ msg = detail.get("message", resp.text) if isinstance(detail, dict) else str(detail)
146
+ print(f"Login failed: {msg}", file=sys.stderr)
147
+ sys.exit(1)
148
+
149
+
150
+ def cmd_task(args: argparse.Namespace) -> None:
151
+ """Handle ``knit task <subcommand>``."""
152
+ agent = _get_agent()
153
+
154
+ if args.sub == "list":
155
+ tasks = agent.list_tasks(status=args.status)
156
+ print(f"Tasks ({len(tasks)}):")
157
+ if tasks:
158
+ _output(tasks, raw=args.raw)
159
+ return
160
+
161
+ if args.sub == "get":
162
+ task = agent.get_task(args.task_id)
163
+ _output(task, raw=args.raw)
164
+ return
165
+
166
+ if args.sub == "create":
167
+ body = {
168
+ "project_id": args.project_id,
169
+ "title": args.title,
170
+ "description": args.description or "",
171
+ "priority": args.priority or "p2",
172
+ }
173
+ if args.assign_to:
174
+ body["assigned_to_agent_id"] = args.assign_to
175
+ resp = agent._post("/tasks", json=body)
176
+ print(f"Created: {resp['id']} — {resp['title']}")
177
+ _output(resp, raw=args.raw)
178
+ return
179
+
180
+ if args.sub == "accept":
181
+ task = agent.accept_task(args.task_id)
182
+ print(f"Accepted: {task['id']} ({task['status']})")
183
+ return
184
+
185
+ if args.sub == "progress":
186
+ resp = agent.send_progress(args.task_id, args.message, args.pct)
187
+ print(f"Progress: {args.pct}% — {args.message}")
188
+ return
189
+
190
+ if args.sub == "result":
191
+ resp = agent.submit_result(
192
+ task_id=args.task_id,
193
+ summary=args.summary,
194
+ status=args.status or "completed",
195
+ logs=args.logs or "",
196
+ metrics={"cli": True},
197
+ )
198
+ print(f"Result submitted: {resp.get('status', 'done')}")
199
+ return
200
+
201
+
202
+ def cmd_agent(args: argparse.Namespace) -> None:
203
+ """Handle ``knit agent <subcommand>``."""
204
+ # agent = _get_agent() # moved inside subcommands
205
+
206
+ if args.sub == "me":
207
+ agent = _get_agent()
208
+ profile = agent._get("/agents/me")
209
+ _output(profile, raw=args.raw)
210
+ return
211
+
212
+ if args.sub == "register":
213
+ hub = os.getenv("KNIT_HUB_URL", DEFAULT_HUB)
214
+ body = {"name": args.name, "description": args.description or ""}
215
+ if args.capabilities:
216
+ body["capabilities"] = [{"type": c} for c in args.capabilities]
217
+ import httpx as _httpx
218
+ with _httpx.Client(base_url=hub.rstrip("/")) as _client:
219
+ _resp = _client.post("/agents/register", json=body)
220
+ if not _resp.is_success:
221
+ detail = _resp.json().get("detail", {}) or {}
222
+ msg = detail.get("message", _resp.text) if isinstance(detail, dict) else str(detail)
223
+ print(f"Registration failed: {msg}", file=sys.stderr)
224
+ sys.exit(1)
225
+ _body = _resp.json()
226
+ if "data" in _body:
227
+ _body = _body["data"]
228
+ print(f"Registered: {_body['agent_id']}")
229
+ print(f"API Key (save this — shown once): {_body['api_key']}")
230
+ return
231
+
232
+ if args.sub == "heartbeat":
233
+ agent = _get_agent()
234
+ resp = agent.heartbeat(status=args.status or "online", message=args.message)
235
+ d = resp if isinstance(resp, dict) else {}
236
+ print(f"Heartbeat: {d.get('status', 'ok')}")
237
+ if "pending_tasks" in d:
238
+ print(f" Pending tasks: {d['pending_tasks']}")
239
+ if "active_tasks" in d:
240
+ print(f" Active tasks: {d['active_tasks']}")
241
+ if "recent_events" in d and d["recent_events"]:
242
+ print(f" Recent events: {len(d['recent_events'])}")
243
+ return
244
+
245
+ if args.sub == "approve":
246
+ token = args.token or os.getenv("KNIT_JWT_TOKEN")
247
+ if not token:
248
+ print("Error: --token or KNIT_JWT_TOKEN required (admin operation).\n"
249
+ "Run: export KNIT_JWT_TOKEN=$(knit auth login)", file=sys.stderr)
250
+ sys.exit(1)
251
+ resp = _admin_request("POST", f"/agents/{args.agent_id}/approve", token)
252
+ print(f"Approved agent: {args.agent_id}")
253
+ return
254
+
255
+
256
+ def cmd_project(args: argparse.Namespace) -> None:
257
+ """Handle ``knit project <subcommand>``."""
258
+
259
+ if args.sub == "list":
260
+ client = _get_admin_client(args.token)
261
+ if client:
262
+ try:
263
+ projects = _admin_request("GET", "/projects", args.token)
264
+ finally:
265
+ client.close()
266
+ else:
267
+ agent = _get_agent()
268
+ projects = agent._get("/projects")
269
+ if isinstance(projects, list):
270
+ pass # OK, agent API key worked for this endpoint
271
+ else:
272
+ print("Warning: Some project data may not be visible with agent API key.\n"
273
+ "Set KNIT_JWT_TOKEN for full access.", file=sys.stderr)
274
+ items = projects if isinstance(projects, list) else projects.get("data", [])
275
+ print(f"Projects ({len(items)}):")
276
+ _output(items, raw=args.raw)
277
+ return
278
+
279
+ if args.sub == "create":
280
+ body = {"name": args.name}
281
+ if args.description:
282
+ body["description"] = args.description
283
+ resp = _admin_request("POST", "/projects", args.token, json_data=body)
284
+ print(f'Created project: {resp.get("id", "?")} -- {args.name}')
285
+ return
286
+
287
+ if args.sub == "resources":
288
+ return
289
+
290
+ if args.sub == "resources":
291
+ pid = args.project_id
292
+ if args.sub_sub == "list":
293
+ resources = _admin_request("GET", f"/projects/{pid}/resources", args.token)
294
+ items = resources if isinstance(resources, list) else resources.get("data", [])
295
+ print(f"Resources ({len(items)}):")
296
+ for r in items:
297
+ print(f" {r.get('id','?'):>12} {r.get('resource_type','?'):12} {r.get('name','')}")
298
+ return
299
+ if args.sub_sub == "add":
300
+ body = {
301
+ "resource_type": args.resource_type,
302
+ "name": args.name,
303
+ "value": args.value,
304
+ }
305
+ if args.display_value:
306
+ body["display_value"] = args.display_value
307
+ resp = _admin_request("POST", f"/projects/{pid}/resources", args.token, json_data=body)
308
+ print(f"Added resource: {resp.get('id','?')[:8]}... — {args.name}")
309
+ return
310
+
311
+ if args.sub == "members":
312
+ pid = args.project_id
313
+ if args.sub_sub == "list":
314
+ members = _admin_request("GET", f"/projects/{pid}/members", args.token)
315
+ items = members if isinstance(members, list) else members.get("data", [])
316
+ print(f"Members ({len(items)}):")
317
+ for m in items:
318
+ name = m.get("name", m.get("user_id") or m.get("agent_id") or "?")
319
+ mtype = m.get("member_type", "?")
320
+ role = m.get("role", "?")
321
+ print(f" {name:20} {mtype:6} [{role}]")
322
+ return
323
+ if args.sub_sub == "add":
324
+ body: dict[str, Any] = {"role": args.role}
325
+ if args.agent_id:
326
+ body["member_type"] = "agent"
327
+ body["agent_id"] = args.agent_id
328
+ elif args.user_id:
329
+ body["member_type"] = "human"
330
+ body["user_id"] = args.user_id
331
+ else:
332
+ print("Error: specify --agent-id or --user-id", file=sys.stderr)
333
+ sys.exit(1)
334
+ resp = _admin_request("POST", f"/projects/{pid}/members", args.token, json_data=body)
335
+ print(f"Added member to project {pid[:8]}...")
336
+ return
337
+
338
+
339
+ def cmd_autopilot(args: argparse.Namespace) -> None:
340
+ """Handle ``knit autopilot <subcommand>``."""
341
+
342
+ if args.sub == "list":
343
+ params: dict[str, str] = {}
344
+ if args.project_id:
345
+ params["project_id"] = args.project_id
346
+ resp = _admin_request("GET", "/autopilots", args.token, params=params)
347
+ items = resp if isinstance(resp, list) else resp.get("data", [])
348
+ print(f"Autopilots ({len(items)}):")
349
+ for a in items:
350
+ status = "active" if a.get("is_active") else "paused"
351
+ print(f" {a.get('id','?'):>12} {a.get('name','')} [{status}]")
352
+ return
353
+
354
+ if args.sub == "create":
355
+ body = {"project_id": args.project_id, "name": args.name}
356
+ if args.cron:
357
+ body["cron_expression"] = args.cron
358
+ if args.assign_to:
359
+ body["assigned_to_agent_id"] = args.assign_to
360
+ if args.title_template:
361
+ body["title_template"] = args.title_template
362
+ if args.priority:
363
+ body["priority"] = args.priority
364
+ resp = _admin_request("POST", "/autopilots", args.token, json_data=body)
365
+ d = resp if isinstance(resp, dict) else {}
366
+ print(f'Created autopilot: {d.get("id", "?")} -- {args.name}')
367
+ return
368
+
369
+ if args.sub == "trigger":
370
+ resp = _admin_request("POST", f"/autopilots/{args.autopilot_id}/trigger", args.token)
371
+ d = resp if isinstance(resp, dict) else {}
372
+ print(f"Triggered: task {d.get('task_id','?')[:8]}... created")
373
+ return
374
+
375
+ if args.sub == "runs":
376
+ resp = _admin_request("GET", f"/autopilots/{args.autopilot_id}/runs", args.token)
377
+ items = resp if isinstance(resp, list) else resp.get("data", [])
378
+ print(f"Runs ({len(items)}):")
379
+ for r in items:
380
+ print(f" status={r.get('status','?')} task={r.get('task_id','?')[:8]}... at={r.get('run_at','?')[:19]}")
381
+ return
382
+
383
+
384
+ # ---------------------------------------------------------------------------
385
+ # Root parser
386
+ # ---------------------------------------------------------------------------
387
+
388
+
389
+ def _build_parser() -> argparse.ArgumentParser:
390
+ parser = argparse.ArgumentParser(prog="knit", description="Knit agent CLI")
391
+ parser.add_argument("--raw", action="store_true", help="JSON output")
392
+ parser.add_argument("--token", help="JWT token (for admin operations; also reads KNIT_JWT_TOKEN)")
393
+ sub = parser.add_subparsers(dest="command", required=True)
394
+
395
+ # ── auth ──────────────────────────────────────────
396
+ au = sub.add_parser("auth", help="Authentication")
397
+ ausub = au.add_subparsers(dest="sub", required=True)
398
+ al = ausub.add_parser("login", help="Login and print JWT token")
399
+ al.add_argument("--email", default="admin@knit.dev")
400
+ al.add_argument("--password", default="password123")
401
+
402
+ # ── task ──────────────────────────────────────────
403
+ t = sub.add_parser("task", help="Manage tasks")
404
+ tsub = t.add_subparsers(dest="sub", required=True)
405
+
406
+ tp = tsub.add_parser("list", help="List tasks")
407
+ tp.add_argument("--status", help="Filter by status")
408
+ tp.add_argument("--project-id", help="Filter by project")
409
+
410
+ tsub.add_parser("get", help="Get task detail").add_argument("task_id")
411
+
412
+ tc = tsub.add_parser("create", help="Create a task")
413
+ tc.add_argument("--project-id", required=True)
414
+ tc.add_argument("--title", required=True)
415
+ tc.add_argument("--description")
416
+ tc.add_argument("--priority", choices=["p0", "p1", "p2", "p3"])
417
+ tc.add_argument("--assign-to", dest="assign_to", help="Agent ID")
418
+
419
+ tsub.add_parser("accept", help="Accept a task").add_argument("task_id")
420
+
421
+ tp2 = tsub.add_parser("progress", help="Report progress")
422
+ tp2.add_argument("task_id")
423
+ tp2.add_argument("--message", required=True)
424
+ tp2.add_argument("--pct", type=int, default=50)
425
+
426
+ tr = tsub.add_parser("result", help="Submit result")
427
+ tr.add_argument("task_id")
428
+ tr.add_argument("--summary", required=True)
429
+ tr.add_argument("--status", choices=["completed", "failed"])
430
+ tr.add_argument("--logs")
431
+
432
+ # ── agent ─────────────────────────────────────────
433
+ a = sub.add_parser("agent", help="Manage agent identity")
434
+ asub = a.add_subparsers(dest="sub", required=True)
435
+
436
+ asub.add_parser("me", help="Show profile")
437
+
438
+ ar = asub.add_parser("register", help="Register this agent")
439
+ ar.add_argument("--name", required=True)
440
+ ar.add_argument("--description")
441
+ ar.add_argument("--capabilities", nargs="*")
442
+
443
+ ah = asub.add_parser("heartbeat", help="Send heartbeat")
444
+ ah.add_argument("--status", choices=["online", "busy", "offline"])
445
+ ah.add_argument("--message")
446
+
447
+ aapp = asub.add_parser("approve", help="Approve a pending agent (admin)")
448
+ aapp.add_argument("agent_id")
449
+
450
+ # ── project ───────────────────────────────────────
451
+ p = sub.add_parser("project", help="Manage projects")
452
+ psub = p.add_subparsers(dest="sub", required=True)
453
+
454
+ psub.add_parser("list", help="List projects")
455
+
456
+ pc = psub.add_parser("create", help="Create a project")
457
+ pc.add_argument("--name", required=True)
458
+ pc.add_argument("--description")
459
+
460
+ pr = psub.add_parser("resources", help="Manage project resources")
461
+ pr.add_argument("project_id")
462
+ prsub = pr.add_subparsers(dest="sub_sub", required=True)
463
+ prsub.add_parser("list", help="List resources")
464
+ pra = prsub.add_parser("add", help="Add a resource")
465
+ pra.add_argument("--type", dest="resource_type", required=True,
466
+ choices=["github_repo", "url", "document", "env_var"])
467
+ pra.add_argument("--name", required=True)
468
+ pra.add_argument("--value", required=True)
469
+ pra.add_argument("--display-value", dest="display_value")
470
+
471
+ pm = psub.add_parser("members", help="Manage project members")
472
+ pm.add_argument("project_id")
473
+ pmsub = pm.add_subparsers(dest="sub_sub", required=True)
474
+ pml = pmsub.add_parser("list", help="List members")
475
+ pma = pmsub.add_parser("add", help="Add a member")
476
+ pma.add_argument("--agent-id", help="Agent ID to add")
477
+ pma.add_argument("--user-id", help="User ID to add")
478
+ pma.add_argument("--role", default="member", choices=["admin", "member", "viewer"])
479
+
480
+ # ── autopilot ─────────────────────────────────────
481
+ ap = sub.add_parser("autopilot", help="Manage autopilots")
482
+ apsub = ap.add_subparsers(dest="sub", required=True)
483
+
484
+ apl = apsub.add_parser("list", help="List autopilots")
485
+ apl.add_argument("--project-id")
486
+
487
+ apc = apsub.add_parser("create", help="Create an autopilot")
488
+ apc.add_argument("--project-id", required=True)
489
+ apc.add_argument("--name", required=True)
490
+ apc.add_argument("--cron")
491
+ apc.add_argument("--assign-to", dest="assign_to")
492
+ apc.add_argument("--title-template", dest="title_template")
493
+ apc.add_argument("--priority", choices=["p0", "p1", "p2", "p3"])
494
+
495
+ apsub.add_parser("trigger", help="Trigger an autopilot").add_argument("autopilot_id")
496
+ apsub.add_parser("runs", help="Show run history").add_argument("autopilot_id")
497
+
498
+ # ── daemon ────────────────────────────────────────
499
+ d = sub.add_parser("daemon", help="Run the agent daemon")
500
+ dsub = d.add_subparsers(dest="sub", required=True)
501
+ ds = dsub.add_parser("start", help="Start the daemon")
502
+ ds.add_argument("--hub", default=os.getenv("KNIT_HUB_URL", DEFAULT_HUB))
503
+ ds.add_argument("--api-key", default=os.getenv("KNIT_API_KEY", ""))
504
+ ds.add_argument("--name", default="knit-daemon")
505
+ ds.add_argument("--poll-interval", type=int, default=10)
506
+
507
+ return parser
508
+
509
+
510
+ def main() -> None:
511
+ """CLI entry point — dispatches to the right subcommand handler."""
512
+ parser = _build_parser()
513
+ args = parser.parse_args()
514
+
515
+ if args.command == "daemon":
516
+ from knit.daemon import _daemon_main
517
+ _daemon_main(args)
518
+ return
519
+
520
+ handlers = {
521
+ "auth": cmd_auth,
522
+ "task": cmd_task,
523
+ "agent": cmd_agent,
524
+ "project": cmd_project,
525
+ "autopilot": cmd_autopilot,
526
+ }
527
+
528
+ handler = handlers.get(args.command)
529
+ if handler:
530
+ try:
531
+ handler(args)
532
+ except Exception as e:
533
+ print(f"Error: {e}", file=sys.stderr)
534
+ sys.exit(1)
535
+ else:
536
+ parser.print_help()
537
+ sys.exit(1)
538
+
539
+
540
+ if __name__ == "__main__":
541
+ main()
knit/daemon.py ADDED
@@ -0,0 +1,175 @@
1
+ """Agent daemon — autonomous Knit agent that auto-discovers and executes tasks.
2
+
3
+ Usage::
4
+
5
+ knit daemon start --hub http://localhost:8000 --api-key sk_live_xxx
6
+
7
+ The daemon heartbeats, polls for tasks, and executes them via the best
8
+ available CLI agent on PATH (claude, codex, openclaw, etc.).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import os
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ from typing import Any
19
+
20
+ from knit.agent import KnitAgent, TaskContext
21
+
22
+ logger = logging.getLogger("knit-daemon")
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # CLI auto-detection
26
+ # ---------------------------------------------------------------------------
27
+
28
+ _CLI_CANDIDATES = ["claude", "codex", "copilot", "openclaw", "opencode", "hermes", "gemini", "pi"]
29
+
30
+
31
+ def detect_clis() -> list[str]:
32
+ """Return CLI agents found on PATH."""
33
+ return [c for c in _CLI_CANDIDATES if shutil.which(c)]
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # KnitDaemon
38
+ # ---------------------------------------------------------------------------
39
+
40
+
41
+ class KnitDaemon:
42
+ """A background agent that auto-discovers, executes, and reports on tasks.
43
+
44
+ Wraps ``KnitAgent`` from the SDK and adds automatic CLI detection,
45
+ subprocess execution, and SSE-based event listening.
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ hub_url: str,
51
+ api_key: str,
52
+ name: str = "knit-daemon",
53
+ description: str = "Daemon agent that auto-executes assigned tasks",
54
+ ):
55
+ self.agent = KnitAgent(
56
+ name=name,
57
+ hub_url=hub_url.rstrip("/"),
58
+ api_key=api_key,
59
+ description=description,
60
+ )
61
+ self.available_clis: list[str] = []
62
+ self._name = name
63
+
64
+ def detect(self) -> list[str]:
65
+ """Auto-detect available agent CLIs on PATH."""
66
+ self.available_clis = detect_clis()
67
+ return self.available_clis
68
+
69
+ def execute(self, task: dict, ctx: TaskContext) -> dict[str, Any]:
70
+ """Execute a task by spawning the best available CLI agent.
71
+
72
+ The task context (title + description) is fed as a prompt.
73
+ Stdout/stderr are captured and returned as the task result.
74
+ """
75
+ if not self.available_clis:
76
+ self.detect()
77
+ if not self.available_clis:
78
+ logger.warning("No agent CLI found on PATH")
79
+ return {"summary": "No agent CLI available (tried: claude, codex, etc.)", "status": "failed"}
80
+
81
+ cli = self.available_clis[0]
82
+ prompt = self._build_prompt(task)
83
+ logger.info("Executing task %s via %s", task.get("id", "?"), cli)
84
+
85
+ ctx.progress(f"Starting execution via {cli}...", 5)
86
+
87
+ try:
88
+ result = subprocess.run(
89
+ [cli, "-p", prompt],
90
+ capture_output=True, text=True, timeout=600,
91
+ )
92
+ except subprocess.TimeoutExpired:
93
+ logger.warning("Task %s timed out after 600s", task.get("id", "?"))
94
+ return {"summary": "Task timed out after 600 seconds", "status": "failed", "logs": "Timeout"}
95
+ except FileNotFoundError:
96
+ logger.error("CLI %s not found despite detection", cli)
97
+ return {"summary": f"CLI {cli} not found", "status": "failed"}
98
+
99
+ ctx.progress("Execution complete, submitting result...", 95)
100
+
101
+ return {
102
+ "summary": result.stdout[:2000] or "(no stdout)",
103
+ "status": "completed" if result.returncode == 0 else "failed",
104
+ "logs": (result.stdout + "\n--- stderr ---\n" + result.stderr)[:5000],
105
+ "metrics": {
106
+ "return_code": result.returncode,
107
+ "output_bytes": len(result.stdout),
108
+ },
109
+ }
110
+
111
+ def _build_prompt(self, task: dict) -> str:
112
+ """Build a task prompt from the task description."""
113
+ parts = [f"Task: {task['title']}"]
114
+ if task.get("description"):
115
+ parts.append("")
116
+ parts.append(task["description"])
117
+ parts.append("")
118
+ parts.append("Complete this task. Report your findings and any code changes.")
119
+ return "\n".join(parts)
120
+
121
+ def serve(self, poll_interval: int = 10) -> None:
122
+ """Run the daemon: detect CLIs, heartbeat, poll, execute."""
123
+ found = self.detect()
124
+ print(f"Knit daemon starting. Detected CLIs: {found or '(none)'}")
125
+ if not found:
126
+ print("Install one of: claude, codex, openclaw, opencode")
127
+ print(f"Connected to hub at {self.agent.base_url}")
128
+ print("Press Ctrl+C to stop\n")
129
+
130
+ self.agent.start_heartbeat_loop(message=f"Daemon online, CLIs: {found}")
131
+ self.agent.serve(
132
+ handle_task=lambda t, ctx: self.execute(t, ctx),
133
+ poll_interval=poll_interval,
134
+ )
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # CLI entry point
139
+ # ---------------------------------------------------------------------------
140
+
141
+
142
+ def main() -> None:
143
+ """CLI entry point (backward compatible — calls _daemon_main)."""
144
+ import argparse
145
+ parser = argparse.ArgumentParser(description="Knit Agent Daemon")
146
+ parser.add_argument("--hub", default=os.getenv("KNIT_HUB_URL", "http://localhost:8000/api/v1"),
147
+ help="Knit hub base URL")
148
+ parser.add_argument("--api-key", required=True,
149
+ help="Agent API key (sk_live_...)")
150
+ parser.add_argument("--name", default="knit-daemon",
151
+ help="Agent name displayed in the hub")
152
+ parser.add_argument("--poll-interval", type=int, default=10,
153
+ help="Seconds between task polls")
154
+
155
+ args = parser.parse_args()
156
+ _daemon_main(args)
157
+
158
+
159
+ def _daemon_main(args: argparse.Namespace) -> None:
160
+ """Run the daemon from parsed CLI args."""
161
+ logging.basicConfig(
162
+ level=logging.INFO,
163
+ format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
164
+ )
165
+
166
+ daemon = KnitDaemon(
167
+ hub_url=args.hub,
168
+ api_key=args.api_key,
169
+ name=args.name,
170
+ )
171
+ daemon.serve(poll_interval=args.poll_interval)
172
+
173
+
174
+ if __name__ == "__main__":
175
+ main()
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: knit-sdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for building agents on the Knit agent hub
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: httpx>=0.25.0
7
+ Requires-Dist: pydantic>=2.0.0
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=7.0; extra == "dev"
10
+ Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
@@ -0,0 +1,10 @@
1
+ knit/__init__.py,sha256=LbEzJbv8yAwYE8JuFsMYRfjn1lKfbI2Y7mNrLz2w9qM,204
2
+ knit/__main__.py,sha256=kmd6JzWDphjDgtQfRop4z5aEBxMkAaIDn7B3626geEA,253
3
+ knit/agent.py,sha256=iUbNKTigKS_oDCOo1qoqztUQcwOa0wDY-fR46OFGMmI,21103
4
+ knit/cli.py,sha256=aiXMEWEJiwEbQY8xecrfyqeD3o5qQ33gae0gmhl4kAg,20987
5
+ knit/daemon.py,sha256=zBEsDD721dLzYfRi9fdNGub_NTC06u2hCYfXedzOQ54,6144
6
+ knit_sdk-0.1.0.dist-info/METADATA,sha256=-2l1Sf1cwYs_k0Excr-mKrhHvztR--rfNOzW26nxchg,313
7
+ knit_sdk-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ knit_sdk-0.1.0.dist-info/entry_points.txt,sha256=geJwCYNp6Fy9Z0lJGNxqAkShqbHt0fUGAYtZN5Kn0Wk,39
9
+ knit_sdk-0.1.0.dist-info/top_level.txt,sha256=B4a3-UpmAKcaTdMJf1bUs6ViNd5lWuZv_zY7h7J9khE,5
10
+ knit_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ knit = knit.cli:main
@@ -0,0 +1 @@
1
+ knit