openmagpie 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.
Files changed (64) hide show
  1. openmagpie/__init__.py +1 -0
  2. openmagpie/action_template.yaml +18 -0
  3. openmagpie/api/__init__.py +56 -0
  4. openmagpie/api/_params.py +20 -0
  5. openmagpie/api/activity.py +39 -0
  6. openmagpie/api/auth.py +167 -0
  7. openmagpie/api/delivery.py +36 -0
  8. openmagpie/api/engine.py +34 -0
  9. openmagpie/api/feed.py +151 -0
  10. openmagpie/api/watch.py +100 -0
  11. openmagpie/cli.py +60 -0
  12. openmagpie/commands/__init__.py +0 -0
  13. openmagpie/commands/_shared/__init__.py +74 -0
  14. openmagpie/commands/_shared/authoring.py +80 -0
  15. openmagpie/commands/_shared/choices.py +31 -0
  16. openmagpie/commands/_shared/columns/__init__.py +37 -0
  17. openmagpie/commands/_shared/columns/extract.py +145 -0
  18. openmagpie/commands/_shared/columns/options.py +54 -0
  19. openmagpie/commands/_shared/columns/render.py +180 -0
  20. openmagpie/commands/_shared/errors.py +85 -0
  21. openmagpie/commands/_shared/files.py +93 -0
  22. openmagpie/commands/_shared/output.py +125 -0
  23. openmagpie/commands/activity.py +297 -0
  24. openmagpie/commands/auth.py +304 -0
  25. openmagpie/commands/auth_token.py +96 -0
  26. openmagpie/commands/delivery.py +111 -0
  27. openmagpie/commands/feed/__init__.py +11 -0
  28. openmagpie/commands/feed/_apps.py +31 -0
  29. openmagpie/commands/feed/_crud.py +299 -0
  30. openmagpie/commands/feed/_items.py +112 -0
  31. openmagpie/commands/feed/_sources.py +342 -0
  32. openmagpie/commands/watch/__init__.py +11 -0
  33. openmagpie/commands/watch/_actions.py +210 -0
  34. openmagpie/commands/watch/_apps.py +22 -0
  35. openmagpie/commands/watch/_crud.py +286 -0
  36. openmagpie/config.py +205 -0
  37. openmagpie/console.py +151 -0
  38. openmagpie/constants.py +42 -0
  39. openmagpie/context.py +166 -0
  40. openmagpie/feed_template.yaml +29 -0
  41. openmagpie/http.py +311 -0
  42. openmagpie/routes.py +149 -0
  43. openmagpie/sources_template.yaml +45 -0
  44. openmagpie/watch_template.yaml +29 -0
  45. openmagpie-0.1.0.dist-info/METADATA +9 -0
  46. openmagpie-0.1.0.dist-info/RECORD +64 -0
  47. openmagpie-0.1.0.dist-info/WHEEL +4 -0
  48. openmagpie-0.1.0.dist-info/entry_points.txt +2 -0
  49. openmagpie_schema/__init__.py +7 -0
  50. openmagpie_schema/configs.py +60 -0
  51. openmagpie_schema/email_enums.py +24 -0
  52. openmagpie_schema/engine.py +46 -0
  53. openmagpie_schema/feed.py +320 -0
  54. openmagpie_schema/waitlist_enums.py +79 -0
  55. openmagpie_schema/watch.py +323 -0
  56. openmagpie_schema/watch_actions/__init__.py +55 -0
  57. openmagpie_schema/watch_actions/_delivery.py +42 -0
  58. openmagpie_schema/watch_actions/_secrets.py +48 -0
  59. openmagpie_schema/watch_actions/base.py +62 -0
  60. openmagpie_schema/watch_actions/log.py +49 -0
  61. openmagpie_schema/watch_actions/semantic_filter.py +84 -0
  62. openmagpie_schema/watch_actions/webhook.py +166 -0
  63. openmagpie_schema/watch_enums.py +151 -0
  64. openmagpie_schema/wire.py +13 -0
openmagpie/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,18 @@
1
+ # A single watch action: `{kind, config}`. Author it for:
2
+ # magpie watch action add --watch <watch_id> -f action.yaml # new action
3
+ # magpie watch action edit <action_id> -f action.yaml # replace one
4
+ # Or run either with no -f to fill in this template (add) / edit the
5
+ # action's current config (edit) in $EDITOR.
6
+ #
7
+ # `kind` selects the action type; `config` is its kind-specific settings.
8
+ # A chain's first action is typically a semantic_filter (an LLM relevance
9
+ # gate that STOPS the chain when an item scores below `threshold`); delivery
10
+ # actions (log, webhook) follow. Each kind has its own `config` shape; the
11
+ # semantic_filter below is the common starting point (see `magpie watch
12
+ # template` for a full chain, or `magpie watch action get <id>` for a live one).
13
+
14
+ kind: semantic_filter
15
+ config:
16
+ instructions: posts about an interesting new open-source LLM release
17
+ threshold: 0.7 # pass when relevance >= this (0.0 < threshold <= 1.0)
18
+ # engine: {kind: "", model: ""} # optional; empty = server default
@@ -0,0 +1,56 @@
1
+ """Top-level API coordinator.
2
+
3
+ `Api` owns the underlying MagpieClient and exposes resource sub-clients
4
+ lazily via `@cached_property`. Call sites read like an SDK:
5
+
6
+ ac.api.auth.me()
7
+ ac.api.auth.create_device_session()
8
+ ac.api.feed.list()
9
+ ac.api.feed.create({...})
10
+
11
+ Adding a resource = one new file in `api/`, one cached_property here.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from functools import cached_property
17
+
18
+ from ..http import MagpieClient
19
+ from .activity import ActivityApi
20
+ from .auth import AuthApi
21
+ from .delivery import DeliveryApi
22
+ from .engine import EngineApi
23
+ from .feed import FeedApi
24
+ from .watch import WatchApi
25
+
26
+
27
+ class Api:
28
+ def __init__(self, http: MagpieClient) -> None:
29
+ self._http = http
30
+
31
+ @cached_property
32
+ def auth(self) -> AuthApi:
33
+ return AuthApi(self._http)
34
+
35
+ @cached_property
36
+ def feed(self) -> FeedApi:
37
+ return FeedApi(self._http)
38
+
39
+ @cached_property
40
+ def watch(self) -> WatchApi:
41
+ return WatchApi(self._http)
42
+
43
+ @cached_property
44
+ def activity(self) -> ActivityApi:
45
+ return ActivityApi(self._http)
46
+
47
+ @cached_property
48
+ def delivery(self) -> DeliveryApi:
49
+ return DeliveryApi(self._http)
50
+
51
+ @cached_property
52
+ def engine(self) -> EngineApi:
53
+ return EngineApi(self._http)
54
+
55
+
56
+ __all__ = ["ActivityApi", "Api", "AuthApi", "DeliveryApi", "EngineApi", "FeedApi", "WatchApi"]
@@ -0,0 +1,20 @@
1
+ """Shared query-param helper for the cursor-paginated observability lists."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def list_params(
7
+ *, state: str | None = None, after: str | None = None, limit: int | None = None, window: str | None = None
8
+ ) -> dict[str, str] | None:
9
+ """The cursor-list query params shared by the activity / delivery list
10
+ endpoints, dropping the unset ones. None when empty so httpx sends none."""
11
+ params: dict[str, str] = {}
12
+ if state:
13
+ params["state"] = state
14
+ if after:
15
+ params["after"] = after
16
+ if limit is not None:
17
+ params["limit"] = str(limit)
18
+ if window:
19
+ params["window"] = window
20
+ return params or None
@@ -0,0 +1,39 @@
1
+ """Activity (run audit) API resource client.
2
+
3
+ The flat observability noun over one action's `WatchActionRun`s: the list
4
+ (`/v1/actions/<id>/activity`, whose first page also carries the summary
5
+ rollup) and one run's detail (`/v1/action-activity/<id>`). Shapes live once in
6
+ `openmagpie_schema.watch`; the server is the authority.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from openmagpie_schema.watch import WatchActionRunListResponse, WatchActionRunView
12
+
13
+ from .. import routes
14
+ from ..http import MagpieClient
15
+ from ._params import list_params
16
+
17
+
18
+ class ActivityApi:
19
+ def __init__(self, http: MagpieClient) -> None:
20
+ self._http = http
21
+
22
+ def list(
23
+ self,
24
+ action_id: str,
25
+ *,
26
+ state: str | None = None,
27
+ after: str | None = None,
28
+ limit: int | None = None,
29
+ window: str | None = None,
30
+ ) -> WatchActionRunListResponse:
31
+ # `window` is the summary preset (server resolves it to bounds); the
32
+ # first page carries the summary rollup, paged calls don't.
33
+ params = list_params(state=state, after=after, limit=limit, window=window)
34
+ raw = self._http.get(routes.actions.runs(action_id), params=params)
35
+ return WatchActionRunListResponse.model_validate(raw)
36
+
37
+ def get(self, activity_id: str) -> WatchActionRunView:
38
+ raw = self._http.get(routes.action_activity.detail(activity_id))
39
+ return WatchActionRunView.model_validate(raw)
openmagpie/api/auth.py ADDED
@@ -0,0 +1,167 @@
1
+ """Auth API resource client + response models.
2
+
3
+ `AuthApi` wraps the transport client with typed entrypoints for every
4
+ `/v1/auth/*` endpoint. Accessed via `Api.auth` (see `api/__init__.py`),
5
+ so call sites read `ac.api.auth.me()` rather than threading the raw
6
+ http client through each handler.
7
+
8
+ Models match the Django response shapes (see `core/auth_api/`); update
9
+ both sides together when the contract changes.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from datetime import datetime
15
+ from typing import Literal
16
+
17
+ from pydantic import BaseModel
18
+
19
+ from .. import routes
20
+ from ..constants import DeviceSessionStatus
21
+ from ..http import MagpieClient, client_info
22
+
23
+
24
+ class AuthUser(BaseModel):
25
+ id: str
26
+ email: str
27
+ account_id: str | None = None
28
+ created_at: str
29
+
30
+
31
+ class TokenPair(BaseModel):
32
+ access_token: str
33
+ refresh_token: str
34
+ expires_in: int
35
+ token_type: Literal["Bearer"] # OAuth2 token_type per RFC 6750
36
+ user: AuthUser
37
+
38
+
39
+ class DeviceSessionCreated(BaseModel):
40
+ session_id: str
41
+ authorize_url: str
42
+ user_code: str
43
+ # Bearer credential the CLI presents on each poll. Returned by the
44
+ # server ONCE at create time; never persisted to disk. Identifies
45
+ # *this CLI process* as the legitimate poller for this session_id.
46
+ device_secret: str
47
+ expires_in: int
48
+
49
+
50
+ class DeviceSessionPending(BaseModel):
51
+ status: Literal[DeviceSessionStatus.PENDING]
52
+
53
+
54
+ class DeviceSessionExpired(BaseModel):
55
+ status: Literal[DeviceSessionStatus.EXPIRED]
56
+
57
+
58
+ class DeviceSessionCompleted(TokenPair):
59
+ """Completed bag is a TokenPair plus the status tag for the union
60
+ discriminator. Inheriting keeps `AppContext.sign_in(bundle)` polymorphic
61
+ across the device-flow completion path and any future direct-login path.
62
+ """
63
+
64
+ status: Literal[DeviceSessionStatus.COMPLETED]
65
+
66
+
67
+ DeviceSessionPoll = DeviceSessionPending | DeviceSessionCompleted | DeviceSessionExpired
68
+
69
+
70
+ class CliToken(BaseModel):
71
+ """Metadata for one personal access token. Never carries the raw
72
+ secret; only the mint response (`CliTokenCreated`) does, once."""
73
+
74
+ id: str
75
+ name: str
76
+ last_four: str
77
+ created_at: datetime
78
+ last_used_at: datetime | None = None
79
+ expires_at: datetime | None = None
80
+
81
+
82
+ class CliTokenCreated(CliToken):
83
+ """`POST` response: metadata plus the raw `token`, shown once."""
84
+
85
+ token: str
86
+
87
+
88
+ class TokensApi:
89
+ """Resource client for `/v1/auth/tokens/*`, bearer lifecycle."""
90
+
91
+ def __init__(self, http: MagpieClient) -> None:
92
+ self._http = http
93
+
94
+ def revoke(self) -> None:
95
+ """Invalidate the current bearer token server-side. Best-effort:
96
+ the server returns 200 even if the token is already revoked, so
97
+ this won't raise except on transport errors.
98
+ """
99
+ self._http.post(routes.auth.tokens.revoke)
100
+
101
+
102
+ class CliTokensApi:
103
+ """Resource client for `/v1/auth/cli-tokens`, personal access tokens."""
104
+
105
+ def __init__(self, http: MagpieClient) -> None:
106
+ self._http = http
107
+
108
+ def create(self, *, name: str, expires_in_days: int | None = None) -> CliTokenCreated:
109
+ body: dict[str, object] = {"name": name}
110
+ if expires_in_days is not None:
111
+ body["expires_in_days"] = expires_in_days
112
+ raw = self._http.post(routes.auth.cli_tokens, json_body=body)
113
+ return CliTokenCreated.model_validate(raw)
114
+
115
+ def list(self) -> list[CliToken]:
116
+ raw = self._http.get(routes.auth.cli_tokens)
117
+ return [CliToken.model_validate(t) for t in raw]
118
+
119
+ def revoke(self, token_id: str) -> None:
120
+ self._http.delete(routes.auth.cli_token(token_id))
121
+
122
+
123
+ class AuthApi:
124
+ """Resource client for `/v1/auth/*`."""
125
+
126
+ def __init__(self, http: MagpieClient) -> None:
127
+ self._http = http
128
+ self.tokens = TokensApi(http)
129
+ self.cli_tokens = CliTokensApi(http)
130
+
131
+ def create_device_session(self) -> DeviceSessionCreated:
132
+ raw = self._http.post(
133
+ routes.auth.device_sessions,
134
+ with_auth=False,
135
+ # Structured CLI identity. Server stores it under `initiator`
136
+ # in the cache bag; the authorize page renders the fields
137
+ # directly with no User-Agent parsing.
138
+ json_body={"client": client_info()},
139
+ )
140
+ return DeviceSessionCreated.model_validate(raw)
141
+
142
+ def poll_device_session(self, session_id: str, *, device_secret: str) -> DeviceSessionPoll:
143
+ raw = self._http.get(
144
+ routes.auth.device_session(session_id),
145
+ # (Re-)login: authenticated by the device secret, NOT the stored
146
+ # bearer. Sending a stale/revoked credential here would get the
147
+ # poll rejected with 401 before the device secret is checked.
148
+ with_auth=False,
149
+ headers={"X-Device-Secret": device_secret},
150
+ )
151
+ status = raw.get("status")
152
+ match status:
153
+ case DeviceSessionStatus.COMPLETED:
154
+ return DeviceSessionCompleted.model_validate(raw)
155
+ case DeviceSessionStatus.EXPIRED:
156
+ return DeviceSessionExpired.model_validate(raw)
157
+ case DeviceSessionStatus.PENDING:
158
+ return DeviceSessionPending.model_validate(raw)
159
+ case _:
160
+ # An unknown status means the server contract changed
161
+ # out from under us. Failing loud beats quietly treating
162
+ # it as pending and looping forever.
163
+ raise RuntimeError(f"unknown device-session status from server: {status!r}")
164
+
165
+ def me(self) -> AuthUser:
166
+ raw = self._http.get(routes.auth.me)
167
+ return AuthUser.model_validate(raw)
@@ -0,0 +1,36 @@
1
+ """Delivery (outbound HTTP call audit) API resource client.
2
+
3
+ The flat observability noun over one action's `WatchActionDelivery`s: the list
4
+ (`/v1/actions/<id>/deliveries`, lean rows) and one delivery's detail
5
+ (`/v1/action-deliveries/<id>`, including the sent request payload). Shapes live
6
+ once in `openmagpie_schema.watch`; the server is the authority.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from openmagpie_schema.watch import WatchActionDeliveryListResponse, WatchActionDeliveryView
12
+
13
+ from .. import routes
14
+ from ..http import MagpieClient
15
+ from ._params import list_params
16
+
17
+
18
+ class DeliveryApi:
19
+ def __init__(self, http: MagpieClient) -> None:
20
+ self._http = http
21
+
22
+ def list(
23
+ self,
24
+ action_id: str,
25
+ *,
26
+ state: str | None = None,
27
+ after: str | None = None,
28
+ limit: int | None = None,
29
+ ) -> WatchActionDeliveryListResponse:
30
+ params = list_params(state=state, after=after, limit=limit)
31
+ raw = self._http.get(routes.actions.deliveries(action_id), params=params)
32
+ return WatchActionDeliveryListResponse.model_validate(raw)
33
+
34
+ def get(self, delivery_id: str) -> WatchActionDeliveryView:
35
+ raw = self._http.get(routes.deliveries.detail(delivery_id))
36
+ return WatchActionDeliveryView.model_validate(raw)
@@ -0,0 +1,34 @@
1
+ """Engines API resource client.
2
+
3
+ Wraps `/v1/engines`. Response models live in the shared
4
+ `openmagpie_schema.engine` module and are imported verbatim.
5
+
6
+ Intended to back a future pre-flight check (surfacing "LLM unreachable"
7
+ at config time beats discovering it via 500-per-judge-cycle once polling
8
+ starts); no `magpie` command consumes it yet.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from openmagpie_schema.engine import EngineListResponse, EngineStatus
14
+
15
+ from .. import routes
16
+ from ..http import MagpieClient
17
+
18
+ __all__ = ["EngineApi", "EngineListResponse", "EngineStatus"]
19
+
20
+
21
+ class EngineApi:
22
+ """Resource client for `/v1/engines`."""
23
+
24
+ def __init__(self, http: MagpieClient) -> None:
25
+ self._http = http
26
+
27
+ def list(self) -> list[EngineStatus]:
28
+ """GET /v1/engines — registered engines + reachability snapshot.
29
+
30
+ Returns the unwrapped items list; the envelope is internal and
31
+ callers always want the per-engine entries.
32
+ """
33
+ raw = self._http.get(routes.engines.collection)
34
+ return EngineListResponse.model_validate(raw).items
openmagpie/api/feed.py ADDED
@@ -0,0 +1,151 @@
1
+ """Feeds API resource client.
2
+
3
+ Wraps the `/v1/feeds` endpoints. Response
4
+ models live ONCE in the shared `openmagpie_schema.feed` package
5
+ (populated by the server, imported verbatim here). Only `FeedEnvelope`
6
+ (the request envelope the CLI *constructs*) is CLI-owned.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from pydantic import BaseModel
14
+
15
+ from openmagpie_schema.feed import (
16
+ FeedItemListResponse,
17
+ FeedItemWire,
18
+ FeedListResponse,
19
+ FeedMutationResponse,
20
+ FeedView,
21
+ FeedWire,
22
+ SourceInput,
23
+ SourceSetResult,
24
+ SourceWire,
25
+ )
26
+ from openmagpie_schema.wire import ConfigBlob
27
+
28
+ from .. import routes
29
+ from ..http import MagpieClient
30
+
31
+ __all__ = [
32
+ "FeedApi",
33
+ "FeedEnvelope",
34
+ "FeedItemListResponse",
35
+ "FeedItemWire",
36
+ "FeedListResponse",
37
+ "FeedMutationResponse",
38
+ "FeedView",
39
+ "FeedWire",
40
+ "SourceSetResult",
41
+ "SourceWire",
42
+ ]
43
+
44
+
45
+ class FeedEnvelope(BaseModel):
46
+ """The envelope the CLI constructs for a feed write (request side).
47
+ CLI-owned, distinct from the server-emitted models. `data` carries
48
+ the kind-specific config (retention + default_field_map), opaque
49
+ here; the server validates it. `sources` is the optional starter
50
+ source list for curated feeds (server creates Source rows
51
+ atomically with the Feed). Extra keys ignored (so the edit seed's
52
+ read-only fields drop on round-trip)."""
53
+
54
+ name: str
55
+ kind: str = "curated"
56
+ poll_interval_seconds: int = 300
57
+ data: ConfigBlob = {}
58
+ sources: list[SourceInput] = []
59
+
60
+ model_config = {"extra": "ignore"}
61
+
62
+
63
+ class FeedApi:
64
+ """Resource client for `/v1/feeds`."""
65
+
66
+ def __init__(self, http: MagpieClient) -> None:
67
+ self._http = http
68
+
69
+ def create(self, body: dict[str, Any], *, dry_run: bool = False) -> FeedMutationResponse:
70
+ params = {"dry_run": "true"} if dry_run else None
71
+ raw = self._http.post(routes.feeds.collection, json_body=body, params=params)
72
+ return FeedMutationResponse.model_validate(raw)
73
+
74
+ def list(self, *, after: str | None = None, limit: int | None = None) -> FeedListResponse:
75
+ """One page of feeds (cursor-paginated, newest-first by ULID pk).
76
+ `after` = id of the last feed from the previous page; omit on first
77
+ call. The returned `next_cursor` is None when there are no more rows.
78
+ """
79
+ params: dict[str, Any] = {}
80
+ if after:
81
+ params["after"] = after
82
+ if limit is not None:
83
+ params["limit"] = limit
84
+ raw = self._http.get(routes.feeds.collection, params=params or None)
85
+ return FeedListResponse.model_validate(raw)
86
+
87
+ def get(self, feed_id: str) -> FeedView:
88
+ """GET one feed's CONFIG detail (account-scoped): the kind-independent
89
+ envelope + display `summary` + its current source set. The item log is a
90
+ separate read (`feed item list` / GET /v1/feeds/<id>/items); this view is
91
+ the feed's configuration, not its items."""
92
+ raw = self._http.get(routes.feeds.detail(feed_id))
93
+ return FeedView.model_validate(raw)
94
+
95
+ def update(self, feed_id: str, body: dict[str, Any], *, dry_run: bool = False) -> FeedMutationResponse:
96
+ params = {"dry_run": "true"} if dry_run else None
97
+ raw = self._http.put(routes.feeds.detail(feed_id), json_body=body, params=params)
98
+ return FeedMutationResponse.model_validate(raw)
99
+
100
+ def delete(self, feed_id: str) -> None:
101
+ self._http.delete(routes.feeds.detail(feed_id))
102
+
103
+ # ── Items sub-resource (read-only) ─────────────────────────────────
104
+
105
+ def list_items(self, feed_id: str, *, after: str | None = None, limit: int | None = None) -> FeedItemListResponse:
106
+ """One page of the feed's items (cursor-paginated, newest-first by ULID
107
+ pk). `after` = id of the last item from the previous page; the returned
108
+ `next_cursor` is None when there are no more rows."""
109
+ params: dict[str, Any] = {}
110
+ if after:
111
+ params["after"] = after
112
+ if limit is not None:
113
+ params["limit"] = limit
114
+ raw = self._http.get(routes.feeds.items(feed_id), params=params or None)
115
+ return FeedItemListResponse.model_validate(raw)
116
+
117
+ def get_item(self, item_id: str) -> FeedItemWire:
118
+ """GET one feed item by its own (globally unique) ULID, account-scoped."""
119
+ raw = self._http.get(routes.feed_items.detail(item_id))
120
+ return FeedItemWire.model_validate(raw)
121
+
122
+ # ── Sources sub-resource ───────────────────────────────────────────
123
+
124
+ def list_sources(self, feed_id: str) -> list[SourceWire]:
125
+ raw = self._http.get(routes.feeds.sources(feed_id))
126
+ items = (raw or {}).get("items") or []
127
+ return [SourceWire.model_validate(it) for it in items]
128
+
129
+ def get_source(self, source_id: str) -> SourceWire:
130
+ """GET one source by its own (globally unique) ULID; the server resolves
131
+ its feed (sources address by own id, not feed-scoped)."""
132
+ raw = self._http.get(routes.feed_sources.detail(source_id))
133
+ return SourceWire.model_validate(raw)
134
+
135
+ def set_sources(
136
+ self,
137
+ feed_id: str,
138
+ sources: list[dict[str, Any]],
139
+ *,
140
+ dry_run: bool = False,
141
+ ) -> SourceSetResult:
142
+ raw = self._http.put(
143
+ routes.feeds.sources(feed_id),
144
+ json_body={"sources": sources, "dry_run": dry_run},
145
+ )
146
+ return SourceSetResult.model_validate(raw)
147
+
148
+ def delete_source(self, source_id: str) -> None:
149
+ # By the source's own id; the server resolves its feed (sources address
150
+ # by own id now, not feed-scoped).
151
+ self._http.delete(routes.feed_sources.detail(source_id))
@@ -0,0 +1,100 @@
1
+ """Watches API resource client.
2
+
3
+ Wraps the `/v1/watches` endpoints. ALL shapes live ONCE in the shared
4
+ `openmagpie_schema.watch` package (the server is the authority) and are
5
+ imported verbatim here — including the write envelope `WatchInput`, which
6
+ the CLI constructs. No CLI-side copy. Mirrors `api/feed.py`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import builtins
12
+ from typing import Any
13
+
14
+ from openmagpie_schema.watch import (
15
+ WatchActionWire,
16
+ WatchInput,
17
+ WatchListResponse,
18
+ WatchMutationResponse,
19
+ WatchView,
20
+ WatchWire,
21
+ )
22
+
23
+ from .. import routes
24
+ from ..http import MagpieClient
25
+
26
+ __all__ = [
27
+ "WatchApi",
28
+ "WatchInput",
29
+ "WatchListResponse",
30
+ "WatchMutationResponse",
31
+ "WatchView",
32
+ "WatchWire",
33
+ ]
34
+
35
+
36
+ class WatchApi:
37
+ """Resource client for `/v1/watches`."""
38
+
39
+ def __init__(self, http: MagpieClient) -> None:
40
+ self._http = http
41
+
42
+ def create(self, body: dict[str, Any], *, dry_run: bool = False) -> WatchMutationResponse:
43
+ params = {"dry_run": "true"} if dry_run else None
44
+ raw = self._http.post(routes.watches.collection, json_body=body, params=params)
45
+ return WatchMutationResponse.model_validate(raw)
46
+
47
+ def list(self, *, after: str | None = None, limit: int | None = None) -> WatchListResponse:
48
+ """One page of watches (cursor-paginated, newest-first by ULID pk).
49
+ `after` = id of the last watch from the previous page; omit on the
50
+ first call. `next_cursor` is None when there are no more rows."""
51
+ params: dict[str, Any] = {}
52
+ if after:
53
+ params["after"] = after
54
+ if limit is not None:
55
+ params["limit"] = limit
56
+ raw = self._http.get(routes.watches.collection, params=params or None)
57
+ return WatchListResponse.model_validate(raw)
58
+
59
+ def get(self, watch_id: str) -> WatchView:
60
+ """GET one watch (account-scoped). Carries the initial path's
61
+ ordered action chain."""
62
+ raw = self._http.get(routes.watches.detail(watch_id))
63
+ return WatchView.model_validate(raw)
64
+
65
+ def update(self, watch_id: str, body: dict[str, Any], *, dry_run: bool = False) -> WatchMutationResponse:
66
+ params = {"dry_run": "true"} if dry_run else None
67
+ raw = self._http.put(routes.watches.detail(watch_id), json_body=body, params=params)
68
+ return WatchMutationResponse.model_validate(raw)
69
+
70
+ def delete(self, watch_id: str) -> None:
71
+ self._http.delete(routes.watches.detail(watch_id))
72
+
73
+ # ── Actions sub-resource (the chain) ────────────────────────────────
74
+
75
+ def list_actions(self, watch_id: str) -> builtins.list[WatchActionWire]:
76
+ raw = self._http.get(routes.watches.actions(watch_id))
77
+ items = (raw or {}).get("items") or []
78
+ return [WatchActionWire.model_validate(it) for it in items]
79
+
80
+ def get_action(self, action_id: str) -> WatchActionWire:
81
+ """GET one action's definition (kind + redacted config + summary) by its
82
+ own id; the watch/chain is resolved server-side, not in the path."""
83
+ raw = self._http.get(routes.actions.detail(action_id))
84
+ return WatchActionWire.model_validate(raw)
85
+
86
+ def add_action(
87
+ self, watch_id: str, kind: str, config: dict[str, Any], *, rank: int | None = None
88
+ ) -> WatchActionWire:
89
+ body: dict[str, Any] = {"kind": kind, "config": config}
90
+ if rank is not None:
91
+ body["rank"] = rank
92
+ raw = self._http.post(routes.watches.actions(watch_id), json_body=body)
93
+ return WatchActionWire.model_validate(raw)
94
+
95
+ def edit_action(self, action_id: str, kind: str, config: dict[str, Any]) -> WatchActionWire:
96
+ raw = self._http.put(routes.actions.detail(action_id), json_body={"kind": kind, "config": config})
97
+ return WatchActionWire.model_validate(raw)
98
+
99
+ def delete_action(self, action_id: str) -> None:
100
+ self._http.delete(routes.actions.detail(action_id))
openmagpie/cli.py ADDED
@@ -0,0 +1,60 @@
1
+ """Root Typer app + subcommand registration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from .commands.activity import activity_app
8
+ from .commands.auth import auth_app
9
+ from .commands.delivery import delivery_app
10
+ from .commands.feed import feed_app
11
+ from .commands.watch import watch_app
12
+ from .context import AppContext, bind_app_ctx, unbind_app_ctx
13
+
14
+ app = typer.Typer(
15
+ name="magpie",
16
+ help="The magpie CLI. Talk to an OpenMagpie server.",
17
+ no_args_is_help=True,
18
+ )
19
+
20
+
21
+ @app.callback()
22
+ def main(
23
+ ctx: typer.Context,
24
+ server: str | None = typer.Option(
25
+ None,
26
+ "--server",
27
+ "-s",
28
+ help="OpenMagpie server URL override for this invocation.",
29
+ ),
30
+ ) -> None:
31
+ """Build the shared AppContext (config + resource API) and bind it
32
+ into the ContextVar so every subcommand can pull it via `app_ctx()`.
33
+ """
34
+ obj = AppContext(server_url=server)
35
+ token = bind_app_ctx(obj)
36
+ ctx.call_on_close(obj.close)
37
+ ctx.call_on_close(lambda: unbind_app_ctx(token))
38
+
39
+
40
+ app.add_typer(auth_app, name="auth", help="Sign in / out and inspect identity.")
41
+ app.add_typer(
42
+ feed_app,
43
+ name="feed",
44
+ help="Curate + read feeds (the source set watches subscribe to).",
45
+ )
46
+ app.add_typer(
47
+ watch_app,
48
+ name="watch",
49
+ help="Build + manage watches (a feed subscription + action chain).",
50
+ )
51
+ app.add_typer(
52
+ activity_app,
53
+ name="activity",
54
+ help="Audit an action's runs: summary / list / one in full.",
55
+ )
56
+ app.add_typer(
57
+ delivery_app,
58
+ name="delivery",
59
+ help="Audit an action's outbound webhook calls.",
60
+ )