luge-cli 0.17.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.
Files changed (59) hide show
  1. luge_cli/__init__.py +7 -0
  2. luge_cli/__main__.py +85 -0
  3. luge_cli/client/__init__.py +37 -0
  4. luge_cli/client/artifacts.py +31 -0
  5. luge_cli/client/automations.py +46 -0
  6. luge_cli/client/base.py +81 -0
  7. luge_cli/client/boards.py +78 -0
  8. luge_cli/client/channels.py +42 -0
  9. luge_cli/client/chat.py +56 -0
  10. luge_cli/client/employees.py +25 -0
  11. luge_cli/client/schedules.py +40 -0
  12. luge_cli/client/settings.py +41 -0
  13. luge_cli/client/skills.py +32 -0
  14. luge_cli/client/webhooks.py +42 -0
  15. luge_cli/client/workflows.py +70 -0
  16. luge_cli/commands/__init__.py +1 -0
  17. luge_cli/commands/_common.py +38 -0
  18. luge_cli/commands/activity_cmd.py +86 -0
  19. luge_cli/commands/agent_cmd.py +165 -0
  20. luge_cli/commands/artifact_cmd.py +109 -0
  21. luge_cli/commands/auth_cmd.py +40 -0
  22. luge_cli/commands/board_cmd.py +42 -0
  23. luge_cli/commands/card/__init__.py +16 -0
  24. luge_cli/commands/card/read.py +123 -0
  25. luge_cli/commands/card/write.py +148 -0
  26. luge_cli/commands/channel_cmd.py +161 -0
  27. luge_cli/commands/claude_cmd.py +50 -0
  28. luge_cli/commands/colleagues_cmd.py +49 -0
  29. luge_cli/commands/dm_cmd.py +74 -0
  30. luge_cli/commands/schedule_cmd.py +194 -0
  31. luge_cli/commands/settings_cmd.py +145 -0
  32. luge_cli/commands/skill_cmd.py +142 -0
  33. luge_cli/commands/webhook_cmd.py +210 -0
  34. luge_cli/commands/workflow_cmd.py +237 -0
  35. luge_cli/config.py +72 -0
  36. luge_cli/errors.py +12 -0
  37. luge_cli/filters.py +96 -0
  38. luge_cli/render/__init__.py +83 -0
  39. luge_cli/render/_core.py +100 -0
  40. luge_cli/render/artifacts.py +51 -0
  41. luge_cli/render/automations.py +77 -0
  42. luge_cli/render/boards.py +130 -0
  43. luge_cli/render/chat.py +124 -0
  44. luge_cli/render/config.py +19 -0
  45. luge_cli/render/messages.py +43 -0
  46. luge_cli/render/schedules.py +67 -0
  47. luge_cli/render/settings.py +117 -0
  48. luge_cli/render/skills.py +40 -0
  49. luge_cli/render/webhooks.py +83 -0
  50. luge_cli/render/workflows.py +80 -0
  51. luge_cli/resolve.py +155 -0
  52. luge_cli/schedule_spec.py +105 -0
  53. luge_cli/skill/luge-platform/SKILL.md +247 -0
  54. luge_cli/workflow_graph.py +48 -0
  55. luge_cli-0.17.0.dist-info/METADATA +346 -0
  56. luge_cli-0.17.0.dist-info/RECORD +59 -0
  57. luge_cli-0.17.0.dist-info/WHEEL +4 -0
  58. luge_cli-0.17.0.dist-info/entry_points.txt +2 -0
  59. luge_cli-0.17.0.dist-info/licenses/LICENSE +21 -0
luge_cli/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Luge kanban CLI — read and work boards over the Luge REST API."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ # Single source of truth is pyproject's version, read from the installed
6
+ # distribution metadata — no literal to keep in sync here.
7
+ __version__ = version("luge-cli")
luge_cli/__main__.py ADDED
@@ -0,0 +1,85 @@
1
+ """Entry point: assembles the Typer app and wraps it in one error boundary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import typer
8
+
9
+ from . import __version__, render
10
+ from .commands import (
11
+ activity_cmd,
12
+ agent_cmd,
13
+ artifact_cmd,
14
+ auth_cmd,
15
+ board_cmd,
16
+ card,
17
+ channel_cmd,
18
+ claude_cmd,
19
+ colleagues_cmd,
20
+ dm_cmd,
21
+ schedule_cmd,
22
+ settings_cmd,
23
+ skill_cmd,
24
+ webhook_cmd,
25
+ workflow_cmd,
26
+ )
27
+ from .errors import LugeCliError
28
+
29
+ app = typer.Typer(
30
+ name="luge-cli",
31
+ help="Read and work your Luge kanban boards from the command line.",
32
+ no_args_is_help=True,
33
+ add_completion=False,
34
+ )
35
+
36
+ for module in (
37
+ board_cmd,
38
+ card,
39
+ schedule_cmd,
40
+ workflow_cmd,
41
+ activity_cmd,
42
+ channel_cmd,
43
+ dm_cmd,
44
+ agent_cmd,
45
+ colleagues_cmd,
46
+ artifact_cmd,
47
+ webhook_cmd,
48
+ skill_cmd,
49
+ settings_cmd,
50
+ auth_cmd,
51
+ claude_cmd,
52
+ ):
53
+ module.register(app)
54
+
55
+
56
+ def _show_version(value: bool) -> None:
57
+ if value:
58
+ typer.echo(__version__)
59
+ raise typer.Exit()
60
+
61
+
62
+ @app.callback()
63
+ def main(
64
+ version: bool = typer.Option(
65
+ False,
66
+ "--version",
67
+ callback=_show_version,
68
+ is_eager=True,
69
+ help="Show the version and exit.",
70
+ ),
71
+ ) -> None:
72
+ """Read and work your Luge kanban boards from the command line."""
73
+
74
+
75
+ def run() -> None:
76
+ """Console-script entry: run the app, surface expected errors cleanly."""
77
+ try:
78
+ app()
79
+ except LugeCliError as exc:
80
+ render.error(str(exc))
81
+ sys.exit(1)
82
+
83
+
84
+ if __name__ == "__main__":
85
+ run()
@@ -0,0 +1,37 @@
1
+ """HTTP client for the Luge API. ``LugeClient`` composes per-domain endpoint
2
+ mixins onto the transport core; import it (and ``LugeError``) from here."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from .artifacts import ArtifactsMixin
7
+ from .automations import AutomationsMixin
8
+ from .base import BaseClient, LugeError
9
+ from .boards import BoardsMixin
10
+ from .channels import ChannelsMixin
11
+ from .chat import ChatMixin
12
+ from .employees import EmployeesMixin
13
+ from .schedules import SchedulesMixin
14
+ from .settings import SettingsMixin
15
+ from .skills import SkillsMixin
16
+ from .webhooks import WebhooksMixin
17
+ from .workflows import WorkflowsMixin
18
+
19
+
20
+ class LugeClient(
21
+ BoardsMixin,
22
+ SchedulesMixin,
23
+ WorkflowsMixin,
24
+ AutomationsMixin,
25
+ SettingsMixin,
26
+ ChannelsMixin,
27
+ ChatMixin,
28
+ ArtifactsMixin,
29
+ WebhooksMixin,
30
+ EmployeesMixin,
31
+ SkillsMixin,
32
+ BaseClient,
33
+ ):
34
+ """One method per Luge endpoint the CLI needs, grouped by domain mixin."""
35
+
36
+
37
+ __all__ = ["LugeClient", "LugeError"]
@@ -0,0 +1,31 @@
1
+ """Artifact endpoints (`/artifacts`).
2
+
3
+ An artifact is a per-room deliverable (markdown/html/code/pdf). It always lives
4
+ in a room — the ``room_id`` is a conversation/channel id (a channel's
5
+ ``conversation_id``). There is no update-in-place: ``POST`` always inserts.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ class ArtifactsMixin:
12
+ """Mixed into ``LugeClient``; uses ``_request`` from ``BaseClient``."""
13
+
14
+ def list_room_artifacts(self, room_id: str) -> dict:
15
+ """All artifacts in one room, with full content."""
16
+ return self._request("GET", f"artifacts/rooms/{room_id}")
17
+
18
+ def my_artifacts(self, limit: int = 50, offset: int = 0) -> dict:
19
+ """Artifacts across the caller's own rooms (summaries, no content)."""
20
+ return self._request("GET", "artifacts/mine", params={"limit": limit, "offset": offset})
21
+
22
+ def get_artifact(self, artifact_id: str) -> dict:
23
+ return self._request("GET", f"artifacts/{artifact_id}")
24
+
25
+ def create_artifact(self, body: dict) -> dict:
26
+ """Create an artifact in a room. ``body`` needs ``room_id``, ``title``,
27
+ ``content``; ``content_type`` and ``language`` are optional."""
28
+ return self._request("POST", "artifacts", json=body)
29
+
30
+ def delete_artifact(self, artifact_id: str) -> dict:
31
+ return self._request("DELETE", f"artifacts/{artifact_id}")
@@ -0,0 +1,46 @@
1
+ """Automation endpoints (`/automations`) — the cross-surface run activity view.
2
+
3
+ An automation is one Execution (a run) projected across every trigger surface
4
+ (webhook, schedule, workflow, notetaker, agent_task, outbound_call). There is no
5
+ create — runs are born when a trigger fires. ``automation_id`` (the room id, the
6
+ ``id`` field of a list row) is what get/retry key on.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+
12
+ class AutomationsMixin:
13
+ """Mixed into ``LugeClient``; uses ``_request`` from ``BaseClient``."""
14
+
15
+ def list_automations(
16
+ self,
17
+ status: str | None = None,
18
+ trigger_kind: str | None = None,
19
+ search: str | None = None,
20
+ limit: int = 50,
21
+ offset: int = 0,
22
+ ) -> dict:
23
+ return self._request(
24
+ "GET",
25
+ "automations",
26
+ params={
27
+ "status": status,
28
+ "trigger_kind": trigger_kind,
29
+ "search": search,
30
+ "limit": limit,
31
+ "offset": offset,
32
+ },
33
+ )
34
+
35
+ def get_automation(self, automation_id: str) -> dict:
36
+ """Detail for one run: summary + full message thread + human tasks."""
37
+ return self._request("GET", f"automations/{automation_id}")
38
+
39
+ def retry_automation(self, automation_id: str) -> dict:
40
+ """Re-run a failed/cancelled automation's underlying Execution."""
41
+ return self._request("POST", f"automations/{automation_id}/retry")
42
+
43
+ def automation_stats(self, days: int = 14, trigger_kind: str | None = None) -> dict:
44
+ return self._request(
45
+ "GET", "automations/stats", params={"days": days, "trigger_kind": trigger_kind}
46
+ )
@@ -0,0 +1,81 @@
1
+ """Transport core for the Luge client: request plumbing and error mapping.
2
+
3
+ ``base_url`` is the full API base including any deployment prefix (e.g.
4
+ ``https://luge.example.com/api``). Request paths are **relative** (no leading
5
+ slash) so httpx joins them onto that base rather than replacing its path — a
6
+ leading slash would drop the ``/api`` prefix.
7
+
8
+ Per-domain endpoint methods live in sibling mixins (``boards``, ``schedules``,
9
+ …); ``LugeClient`` in ``__init__`` composes them onto this base.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any, Self
15
+
16
+ import httpx
17
+
18
+ from ..errors import LugeCliError
19
+
20
+
21
+ class LugeError(LugeCliError):
22
+ """An API call failed — transport error or non-2xx response."""
23
+
24
+
25
+ _STATUS_HINTS = {
26
+ 401: "Unauthorized — check your API token (`luge-cli auth show`).",
27
+ 403: "Forbidden — your token cannot access this resource.",
28
+ 404: "Not found — no such resource, or you lack access.",
29
+ }
30
+
31
+
32
+ class BaseClient:
33
+ def __init__(
34
+ self,
35
+ base_url: str,
36
+ token: str,
37
+ *,
38
+ transport: httpx.BaseTransport | None = None,
39
+ timeout: float = 30.0,
40
+ ) -> None:
41
+ self._http = httpx.Client(
42
+ base_url=base_url.rstrip("/") + "/",
43
+ headers={"Authorization": f"Bearer {token}"},
44
+ timeout=timeout,
45
+ transport=transport,
46
+ )
47
+
48
+ def __enter__(self) -> Self:
49
+ return self
50
+
51
+ def __exit__(self, *exc: object) -> None:
52
+ self._http.close()
53
+
54
+ def _raw(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
55
+ # Drop None query params — httpx would send them as an empty `key=`,
56
+ # which the API reads as an empty-string filter, not an absent one.
57
+ params = kwargs.get("params")
58
+ if isinstance(params, dict):
59
+ kwargs["params"] = {k: v for k, v in params.items() if v is not None}
60
+ try:
61
+ resp = self._http.request(method, path, **kwargs)
62
+ except httpx.RequestError as exc:
63
+ raise LugeError(f"Cannot reach Luge at {self._http.base_url}: {exc}") from exc
64
+ if resp.status_code >= 400:
65
+ hint = _STATUS_HINTS.get(resp.status_code) or self._detail(resp)
66
+ raise LugeError(f"Luge API error {resp.status_code}: {hint}")
67
+ return resp
68
+
69
+ def _request(self, method: str, path: str, **kwargs: Any) -> Any:
70
+ resp = self._raw(method, path, **kwargs)
71
+ return resp.json() if resp.content else {}
72
+
73
+ @staticmethod
74
+ def _detail(resp: httpx.Response) -> str:
75
+ try:
76
+ body = resp.json()
77
+ except ValueError:
78
+ return resp.text[:200] or resp.reason_phrase
79
+ if isinstance(body, dict) and "detail" in body:
80
+ return str(body["detail"])
81
+ return resp.text[:200] or resp.reason_phrase
@@ -0,0 +1,78 @@
1
+ """Board & card endpoints — one method per endpoint the CLI needs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import mimetypes
6
+ from pathlib import Path
7
+
8
+ import httpx
9
+
10
+
11
+ class BoardsMixin:
12
+ """Board, card, comment and attachment endpoints. Mixed into ``LugeClient``;
13
+ relies on the transport helpers (``_raw`` / ``_request``) from ``BaseClient``."""
14
+
15
+ # ── Reads ─────────────────────────────────────────────────────────────
16
+ def list_boards(self, limit: int = 100, offset: int = 0) -> dict:
17
+ return self._request("GET", "boards", params={"limit": limit, "offset": offset})
18
+
19
+ def get_board(self, board_id: str) -> dict:
20
+ return self._request("GET", f"boards/{board_id}")
21
+
22
+ def assigned_cards(self, limit: int = 100) -> dict:
23
+ return self._request("GET", "boards/assigned-cards", params={"limit": limit})
24
+
25
+ def list_comments(self, board_id: str, card_id: str) -> dict:
26
+ return self._request("GET", f"boards/{board_id}/cards/{card_id}/comments")
27
+
28
+ def list_attachments(self, board_id: str, card_id: str) -> dict:
29
+ return self._request("GET", f"boards/{board_id}/cards/{card_id}/attachments")
30
+
31
+ def attachment_content(self, board_id: str, card_id: str, link_id: str) -> httpx.Response:
32
+ """Raw content of one attachment (artifact JSON or document bytes),
33
+ served through the board façade — board read access is enough."""
34
+ return self._raw("GET", f"boards/{board_id}/cards/{card_id}/attachments/{link_id}/content")
35
+
36
+ # ── Writes ────────────────────────────────────────────────────────────
37
+ def create_card(self, board_id: str, body: dict) -> dict:
38
+ """Create a card in a board column. ``body`` needs ``column_id`` and
39
+ ``title``; notes / tags / priority / color / due_date / assignee_id are
40
+ optional. Returns the created card."""
41
+ return self._request("POST", f"boards/{board_id}/cards", json=body)
42
+
43
+ def comment_card(self, board_id: str, card_id: str, body: str) -> dict:
44
+ return self._request(
45
+ "POST", f"boards/{board_id}/cards/{card_id}/comments", json={"body": body}
46
+ )
47
+
48
+ def move_card(self, board_id: str, card_id: str, column_id: str, position: int = 0) -> dict:
49
+ return self._request(
50
+ "PATCH",
51
+ f"boards/{board_id}/cards/{card_id}/move",
52
+ json={"column_id": column_id, "position": position},
53
+ )
54
+
55
+ def set_completed(self, board_id: str, card_id: str, completed: bool) -> dict:
56
+ return self._request(
57
+ "PATCH", f"boards/{board_id}/cards/{card_id}", json={"completed": completed}
58
+ )
59
+
60
+ def upload_document(self, path: Path) -> dict:
61
+ """Upload a local file as a document (the tenant corpus). Returns the
62
+ created document, whose ``id`` the card-attach step then links."""
63
+ ctype = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
64
+ with path.open("rb") as handle:
65
+ return self._request("POST", "documents", files={"file": (path.name, handle, ctype)})
66
+
67
+ def attach_document(self, board_id: str, card_id: str, document_id: str) -> dict:
68
+ """Link an existing document to a card. Returns the card's refreshed
69
+ attachment list."""
70
+ return self._request(
71
+ "POST",
72
+ f"boards/{board_id}/cards/{card_id}/attachments",
73
+ json={"document_id": document_id},
74
+ )
75
+
76
+ def detach_attachment(self, board_id: str, card_id: str, link_id: str) -> dict:
77
+ """Detach an attachment from a card (the document itself is untouched)."""
78
+ return self._request("DELETE", f"boards/{board_id}/cards/{card_id}/attachments/{link_id}")
@@ -0,0 +1,42 @@
1
+ """Team chat-channel endpoints (`/team-channels`) — the group-chat surface.
2
+
3
+ A channel is a named, multi-member room. Messages live on the backing room
4
+ (``conversation_id``, materialised lazily). Posting a plain message is silent;
5
+ the AI answers only when the text ``@luge``-mentions it and ``luge_enabled``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ class ChannelsMixin:
12
+ """Mixed into ``LugeClient``; uses ``_request`` from ``BaseClient``."""
13
+
14
+ def list_channels(self) -> dict:
15
+ return self._request("GET", "team-channels")
16
+
17
+ def browse_channels(self) -> dict:
18
+ """Public channels the caller can join but hasn't yet."""
19
+ return self._request("GET", "team-channels/browse")
20
+
21
+ def get_channel(self, channel_id: str) -> dict:
22
+ return self._request("GET", f"team-channels/{channel_id}")
23
+
24
+ def channel_messages(self, channel_id: str, limit: int = 50, offset: int = 0) -> dict:
25
+ """A channel's message history (oldest-first). Also returns the backing
26
+ ``conversation_id`` (forces room materialisation)."""
27
+ return self._request(
28
+ "GET", f"team-channels/{channel_id}/messages", params={"limit": limit, "offset": offset}
29
+ )
30
+
31
+ def post_channel_message(self, channel_id: str, body: str) -> dict:
32
+ """Post a message. Silent unless the body ``@luge``-mentions the agent."""
33
+ return self._request("POST", f"team-channels/{channel_id}/messages", json={"body": body})
34
+
35
+ def join_channel(self, channel_id: str) -> dict:
36
+ return self._request("POST", f"team-channels/{channel_id}/join")
37
+
38
+ def leave_channel(self, channel_id: str) -> dict:
39
+ return self._request("POST", f"team-channels/{channel_id}/leave")
40
+
41
+ def create_channel(self, body: dict) -> dict:
42
+ return self._request("POST", "team-channels", json=body)
@@ -0,0 +1,56 @@
1
+ """Conversation + agent-chat endpoints (`/conversations`, `/chat`).
2
+
3
+ Two send surfaces with opposite behavior:
4
+ - ``post_conversation_message`` appends a plain user message — no agent runs.
5
+ - ``chat`` dispatches through the agent pipeline and DOES trigger an AI reply,
6
+ asynchronously (returns ``{conversation_id, room_id, status}``; the reply lands
7
+ later — the CLI is fire-and-forget for now, WebSocket streaming is a follow-up).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+
13
+ class ChatMixin:
14
+ """Mixed into ``LugeClient``; uses ``_request`` from ``BaseClient``."""
15
+
16
+ def list_conversations(self, conversation_type: str = "session", limit: int = 50) -> dict:
17
+ # The list route is declared with a trailing slash only; keep it so the
18
+ # client (which does not follow redirects) hits it directly.
19
+ return self._request(
20
+ "GET", "conversations/", params={"conversation_type": conversation_type, "limit": limit}
21
+ )
22
+
23
+ def get_conversation(self, conversation_id: str) -> dict:
24
+ return self._request("GET", f"conversations/{conversation_id}")
25
+
26
+ def create_dm(self, colleague_id: str) -> dict:
27
+ """Create or reopen a 1:1 DM with a colleague (their employee/user id).
28
+ Returns ``{conversation_id, is_new, participant}``."""
29
+ return self._request("POST", "conversations/dm", json={"colleague_id": colleague_id})
30
+
31
+ def conversation_messages(
32
+ self, conversation_id: str, limit: int = 50, before: str | None = None
33
+ ) -> dict:
34
+ return self._request(
35
+ "GET",
36
+ f"conversations/{conversation_id}/messages",
37
+ params={"limit": limit, "before": before},
38
+ )
39
+
40
+ def post_conversation_message(self, conversation_id: str, body: str) -> dict:
41
+ """Append a plain user message (no agent is invoked)."""
42
+ return self._request(
43
+ "POST", f"conversations/{conversation_id}/messages", json={"body": body}
44
+ )
45
+
46
+ def chat(
47
+ self, prompt: str, conversation_id: str | None = None, agent_id: str | None = None
48
+ ) -> dict:
49
+ """Send a prompt to an agent — triggers a reply. Returns immediately with
50
+ ``{conversation_id, room_id, status}``; the reply arrives asynchronously.
51
+ Omit ``conversation_id`` to start a new conversation."""
52
+ return self._request(
53
+ "POST",
54
+ "chat",
55
+ json={"prompt": prompt, "conversation_id": conversation_id, "agent_id": agent_id},
56
+ )
@@ -0,0 +1,25 @@
1
+ """Colleague / employee directory (`/employees`).
2
+
3
+ The full people directory (broader than the tenant's team members) — the source
4
+ for finding someone to DM. Member-safe (``profile.read.others``).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class EmployeesMixin:
11
+ """Mixed into ``LugeClient``; uses ``_request`` from ``BaseClient``."""
12
+
13
+ def list_colleagues(
14
+ self, limit: int = 100, offset: int = 0, employee_type: str | None = None
15
+ ) -> dict:
16
+ # The list route is declared with a trailing slash only.
17
+ return self._request(
18
+ "GET",
19
+ "employees/",
20
+ params={"limit": limit, "offset": offset, "employee_type": employee_type},
21
+ )
22
+
23
+ def get_colleague(self, employee_id: str) -> dict:
24
+ """One colleague's profile (role, department, skills)."""
25
+ return self._request("GET", f"employees/{employee_id}")
@@ -0,0 +1,40 @@
1
+ """Scheduled-task endpoints (`/scheduled-tasks`).
2
+
3
+ A scheduled task is a recurring prompt that fires an agent run. The schedule is
4
+ structured (``recurrence_type`` + ``recurrence_config`` + ``times_of_day``), not
5
+ a cron string — the CLI builds that body from ergonomic flags (see
6
+ ``schedule_spec``). Run history is not on the task; it surfaces via automations.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+
12
+ class SchedulesMixin:
13
+ """Mixed into ``LugeClient``; uses ``_request`` from ``BaseClient``."""
14
+
15
+ def list_schedules(self, enabled_only: bool = False, limit: int = 100, offset: int = 0) -> dict:
16
+ return self._request(
17
+ "GET",
18
+ "scheduled-tasks",
19
+ params={"enabled_only": enabled_only, "limit": limit, "offset": offset},
20
+ )
21
+
22
+ def get_schedule(self, task_id: str) -> dict:
23
+ return self._request("GET", f"scheduled-tasks/{task_id}")
24
+
25
+ def create_schedule(self, body: dict) -> dict:
26
+ return self._request("POST", "scheduled-tasks", json=body)
27
+
28
+ def update_schedule(self, task_id: str, body: dict) -> dict:
29
+ return self._request("PUT", f"scheduled-tasks/{task_id}", json=body)
30
+
31
+ def delete_schedule(self, task_id: str) -> dict:
32
+ return self._request("DELETE", f"scheduled-tasks/{task_id}")
33
+
34
+ def toggle_schedule(self, task_id: str) -> dict:
35
+ """Flip ``enabled`` (pause / resume); server recomputes ``next_run_at``."""
36
+ return self._request("POST", f"scheduled-tasks/{task_id}/toggle")
37
+
38
+ def trigger_schedule(self, task_id: str) -> dict:
39
+ """Run the task now. Returns a run summary (id, status, scheduled_for)."""
40
+ return self._request("POST", f"scheduled-tasks/{task_id}/trigger")
@@ -0,0 +1,41 @@
1
+ """Tenant settings endpoints (`/settings/...`).
2
+
3
+ The tenant-wide slices (memory / pii / compliance / web-search / tenant) are
4
+ single objects at ``settings/<slug>`` — one generic get/put pair covers them
5
+ (PUT is a partial patch: only the keys you send are written). Agents, the
6
+ provider catalogue, team members and credentials are collection reads.
7
+
8
+ Most of these need an **admin-role** key; agents, providers and team are the
9
+ member-safe reads (AGENT_USE / MEMBER_READ).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+
15
+ class SettingsMixin:
16
+ """Mixed into ``LugeClient``; uses ``_request`` from ``BaseClient``."""
17
+
18
+ def get_settings(self, slug: str) -> dict:
19
+ """Read one tenant-settings slice (``memory``/``pii``/``compliance``/
20
+ ``web-search``/``tenant``)."""
21
+ return self._request("GET", f"settings/{slug}")
22
+
23
+ def put_settings(self, slug: str, body: dict) -> dict:
24
+ """Partial-patch one tenant-settings slice — only the keys in ``body``
25
+ are written."""
26
+ return self._request("PUT", f"settings/{slug}", json=body)
27
+
28
+ def list_agents(self) -> dict:
29
+ return self._request("GET", "settings/agents")
30
+
31
+ def get_agent(self, agent_id: str) -> dict:
32
+ return self._request("GET", f"settings/agents/{agent_id}")
33
+
34
+ def providers_catalog(self) -> dict:
35
+ return self._request("GET", "settings/providers/catalog")
36
+
37
+ def list_team(self) -> dict:
38
+ return self._request("GET", "settings/team/members")
39
+
40
+ def list_credentials(self) -> dict:
41
+ return self._request("GET", "settings/credentials")
@@ -0,0 +1,32 @@
1
+ """Personal Luge skills (`/skills`).
2
+
3
+ A skill is an authored instruction set (name, description, instructions body,
4
+ required tools, trigger phrases) that agents can use. These methods target the
5
+ caller's **personal** skills (``scope=personal``, visible only to them). Reads
6
+ are member-safe; create/update/delete need the skill-manage capability.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+
12
+ class SkillsMixin:
13
+ """Mixed into ``LugeClient``; uses ``_request`` from ``BaseClient``."""
14
+
15
+ def list_skills(self) -> dict:
16
+ """All skills visible to the caller (tenant + personal). Filter on
17
+ ``scope == "personal"`` for the caller's own."""
18
+ return self._request("GET", "skills/list")
19
+
20
+ def get_personal_skill(self, skill_name: str) -> dict:
21
+ return self._request("GET", f"skills/personal/{skill_name}")
22
+
23
+ def create_personal_skill(self, body: dict) -> dict:
24
+ """Create a personal skill. ``body`` needs ``skill_name``; description /
25
+ instructions / requires / triggers are optional."""
26
+ return self._request("POST", "skills/personal", json=body)
27
+
28
+ def update_personal_skill(self, skill_name: str, body: dict) -> dict:
29
+ return self._request("PUT", f"skills/personal/{skill_name}", json=body)
30
+
31
+ def delete_personal_skill(self, skill_name: str) -> dict:
32
+ return self._request("DELETE", f"skills/personal/{skill_name}")
@@ -0,0 +1,42 @@
1
+ """Inbound webhook-endpoint management (`/settings/webhook-endpoints`).
2
+
3
+ A webhook endpoint is a receiver Luge hosts: an external system POSTs to its
4
+ ``inbound_url``, and the delivery is routed to the endpoint's destination (an
5
+ agent or a workflow) or just logged. The CLI manages these endpoints and inspects
6
+ their deliveries — it does not receive the webhooks itself. All member-safe.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+
12
+ class WebhooksMixin:
13
+ """Mixed into ``LugeClient``; uses ``_request`` from ``BaseClient``."""
14
+
15
+ def list_webhooks(self) -> dict:
16
+ return self._request("GET", "settings/webhook-endpoints")
17
+
18
+ def get_webhook(self, endpoint_id: str) -> dict:
19
+ return self._request("GET", f"settings/webhook-endpoints/{endpoint_id}")
20
+
21
+ def create_webhook(self, body: dict) -> dict:
22
+ """Create an endpoint. The response carries ``inbound_url`` and (once)
23
+ the signing ``secret`` — capture it, it is not returned again."""
24
+ return self._request("POST", "settings/webhook-endpoints", json=body)
25
+
26
+ def update_webhook(self, endpoint_id: str, body: dict) -> dict:
27
+ return self._request("PUT", f"settings/webhook-endpoints/{endpoint_id}", json=body)
28
+
29
+ def delete_webhook(self, endpoint_id: str) -> dict:
30
+ return self._request("DELETE", f"settings/webhook-endpoints/{endpoint_id}")
31
+
32
+ def webhook_deliveries(self, endpoint_id: str, limit: int = 50) -> dict:
33
+ """The inbound-delivery journal for one endpoint (what has arrived)."""
34
+ return self._request(
35
+ "GET", f"settings/webhook-endpoints/{endpoint_id}/deliveries", params={"limit": limit}
36
+ )
37
+
38
+ def webhook_delivery(self, endpoint_id: str, delivery_id: str) -> dict:
39
+ """One delivery in full (payload + headers)."""
40
+ return self._request(
41
+ "GET", f"settings/webhook-endpoints/{endpoint_id}/deliveries/{delivery_id}"
42
+ )