branchkit 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.
branchkit/__init__.py ADDED
@@ -0,0 +1,87 @@
1
+ """BranchKit plugin SDK for Python.
2
+
3
+ import asyncio
4
+ import branchkit
5
+
6
+ plugin = branchkit.Plugin()
7
+
8
+ @plugin.handle("my.method")
9
+ async def my_method(params):
10
+ return {"ok": True}
11
+
12
+ asyncio.run(plugin.run())
13
+
14
+ Handlers may be `async def` (run on the loop) or plain `def`
15
+ (auto-offloaded to a thread, so a blocking body cannot freeze the
16
+ plugin). Stdlib-only by design: importing the SDK forces no third-party
17
+ dependency on any plugin."""
18
+
19
+ from . import proxy as _proxy
20
+
21
+ # Route stdlib HTTP through BRANCHKIT_PROXY when sandboxed (per-host
22
+ # tier) — same import-time side effect as the TS SDK's entry module.
23
+ _proxy.install_proxy_from_env()
24
+
25
+ from .plugin import (
26
+ PluginCore,
27
+ RecordingDisabledError,
28
+ RpcCallError,
29
+ api_version,
30
+ error_kind_of,
31
+ models_dir,
32
+ plugin_data_dir,
33
+ plugin_dir,
34
+ )
35
+ from .collection import CollectionMixin, list_opts, scope_collection, scope_group
36
+ from .collection_log import CollectionLogMixin, log_list_opts
37
+ from .debug import DebugMixin
38
+ from .effects import EffectsMixin
39
+ from .hud import HudMixin
40
+ from .log import log
41
+ from .methods_gen import MethodsMixin
42
+ from .mirror import CollectionMirror, MirrorMixin
43
+ from .settings import SettingsMirror, SettingsMixin
44
+ from .commands import (
45
+ CommandBuilder,
46
+ capture,
47
+ command,
48
+ load_commands,
49
+ one_of,
50
+ push_command_group,
51
+ push_command_specs,
52
+ push_commands,
53
+ text,
54
+ word,
55
+ )
56
+ from .listen import Listener, inherited_listener_count, listen_local
57
+ from .settings_route import method_post, method_url
58
+ from .ui import (
59
+ Expr,
60
+ args,
61
+ confirm_button,
62
+ expr,
63
+ input_value,
64
+ post_button,
65
+ signal_button,
66
+ signal_name,
67
+ )
68
+ from .upstream import UpstreamClient, UpstreamResponse
69
+ from .closed_vocab_gen import * # noqa: F401,F403 — error kinds, directives, effects
70
+ from .contracts_gen import * # noqa: F401,F403 — method/hook/event/tag constants
71
+ from .log_events_gen import * # noqa: F401,F403 — observability log-event names
72
+
73
+
74
+ class Plugin(
75
+ CollectionMixin,
76
+ CollectionLogMixin,
77
+ EffectsMixin,
78
+ DebugMixin,
79
+ HudMixin,
80
+ MirrorMixin,
81
+ SettingsMixin,
82
+ MethodsMixin,
83
+ PluginCore,
84
+ ):
85
+ """The plugin: JSON-RPC transport plus the generated method wrappers
86
+ and the state/log/effects/debug/HUD/mirror/settings façades, one
87
+ class. See PluginCore for the transport contract."""
@@ -0,0 +1,95 @@
1
+ # AUTO-GENERATED by emit-sdk — do not edit.
2
+ # Run: just contracts
3
+
4
+ from typing import NotRequired, TypedDict
5
+
6
+ # INPUT_DIRECTIVE_* are the closed-vocabulary keys plugins may write
7
+ # into `_platform.input.directives`. Source of truth:
8
+ # `actuator/src/state.rs::InputDirectives::KNOWN_KEYS`.
9
+ INPUT_DIRECTIVE_LIFT_KEYS_ON_DISPATCH = "lift_keys_on_dispatch"
10
+
11
+ # EFFECT_* are the registered effect names plugins may declare in
12
+ # `provides.effects`. Source of truth:
13
+ # `actuator/src/effects.rs::EffectDef` inventory entries.
14
+ EFFECT_DISABLE_SCREEN_DIM = "disable_screen_dim"
15
+ EFFECT_MUTE_AUDIO_TO_OTHER_APPS = "mute_audio_to_other_apps"
16
+ EFFECT_PAUSE_MICROPHONE_INDICATOR = "pause_microphone_indicator"
17
+ EFFECT_PREVENT_FOCUS_STEAL = "prevent_focus_steal"
18
+ EFFECT_SIGNAL_RECORDING_ACTIVE = "signal_recording_active"
19
+ EFFECT_SUPPRESS_HUDS = "suppress_huds"
20
+ EFFECT_SUPPRESS_KEYBINDS = "suppress_keybinds"
21
+ EFFECT_SUPPRESS_NOTIFICATIONS = "suppress_notifications"
22
+
23
+ # The full closed-vocabulary sets.
24
+ KNOWN_INPUT_DIRECTIVES = (
25
+ "lift_keys_on_dispatch",
26
+ )
27
+
28
+ KNOWN_EFFECTS = (
29
+ "disable_screen_dim",
30
+ "mute_audio_to_other_apps",
31
+ "pause_microphone_indicator",
32
+ "prevent_focus_steal",
33
+ "signal_recording_active",
34
+ "suppress_huds",
35
+ "suppress_keybinds",
36
+ "suppress_notifications",
37
+ )
38
+
39
+ # ERROR_KIND_* are the closed-vocabulary `kind` values the actuator puts
40
+ # in a JSON-RPC error's `data.kind`. Branch on RpcCallError.kind rather
41
+ # than matching the message prose. Source of truth:
42
+ # `actuator/src/fault.rs::ErrorKind`.
43
+ #
44
+ # NOTE: ErrorKind is `str`, deliberately NOT a Literal union of the
45
+ # values below. An actuator newer than this SDK may send a kind that is
46
+ # not listed here, and that must fall through a match rather than fail
47
+ # to parse.
48
+ ErrorKind = str
49
+ ERROR_KIND_NOT_PERMITTED = "not_permitted"
50
+ ERROR_KIND_RECORDING_DISABLED = "recording_disabled"
51
+ ERROR_KIND_NOT_FOUND = "not_found"
52
+ ERROR_KIND_VALIDATION = "validation"
53
+ ERROR_KIND_FORBIDDEN = "forbidden"
54
+ ERROR_KIND_STORAGE = "storage"
55
+ ERROR_KIND_METHOD_NOT_FOUND = "method_not_found"
56
+ ERROR_KIND_INVALID_PARAMS = "invalid_params"
57
+ ERROR_KIND_INTERNAL = "internal"
58
+
59
+ # ERROR_CODE_FOR maps a kind to the JSON-RPC error code the actuator
60
+ # sends with it. Derived from the kind actuator-side.
61
+ ERROR_CODE_FOR: dict[str, int] = {
62
+ "not_permitted": -32002,
63
+ "recording_disabled": -32006,
64
+ "not_found": -32001,
65
+ "validation": -32004,
66
+ "forbidden": -32003,
67
+ "storage": -32005,
68
+ "method_not_found": -32601,
69
+ "invalid_params": -32602,
70
+ "internal": -32603,
71
+ }
72
+
73
+ # KNOWN_ERROR_KINDS lists the full closed-vocabulary set.
74
+ KNOWN_ERROR_KINDS = (
75
+ "not_permitted",
76
+ "recording_disabled",
77
+ "not_found",
78
+ "validation",
79
+ "forbidden",
80
+ "storage",
81
+ "method_not_found",
82
+ "invalid_params",
83
+ "internal",
84
+ )
85
+
86
+ # FaultData is the structured payload of a JSON-RPC error's `data`
87
+ # member. Only `kind` is guaranteed; the rest are populated when they
88
+ # apply. Source of truth: `actuator/src/fault.rs::FaultData`.
89
+ FaultData = TypedDict("FaultData", {
90
+ "kind": str,
91
+ "collection": NotRequired[str],
92
+ "detail": NotRequired[str],
93
+ "id": NotRequired[str],
94
+ "op": NotRequired[str],
95
+ })
@@ -0,0 +1,188 @@
1
+ """State uniform helpers — the collection façade over the generated
2
+ `collection.*` wrappers. Python twin of plugin-sdk-go/collection.go and
3
+ plugin-sdk-ts/src/collection.ts."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any, Callable
8
+
9
+ from .contracts_gen import EVENT_COLLECTION_UPDATED
10
+
11
+
12
+ def scope_collection() -> dict:
13
+ """Every other record THIS PLUGIN owns in the collection is the
14
+ complement: after the call, the records you own here are exactly the
15
+ ones you passed. Other plugins' records, and any the user added through
16
+ Settings, are untouched and invisible to the diff."""
17
+ return {"kind": "collection"}
18
+
19
+
20
+ def scope_group(group: str) -> dict:
21
+ """Narrows further, to this plugin's records carrying the named group
22
+ label — and stamps that label on every entry written. The label must be
23
+ non-empty; an empty one is indistinguishable from "ungrouped" and the
24
+ platform refuses it."""
25
+ return {"kind": "group", "value": group}
26
+
27
+
28
+ def list_opts(
29
+ *,
30
+ since_ms: int | None = None,
31
+ until_ms: int | None = None,
32
+ limit: int | None = None,
33
+ cursor: str | None = None,
34
+ writer: str | None = None,
35
+ ) -> dict:
36
+ """Build a ListOpts with typed scalar values. `writer` filters to
37
+ records owned by that writer — pair with `plugin.id` to ask for your
38
+ own records, the read half of a scoped write."""
39
+ out: dict[str, Any] = {}
40
+ if since_ms is not None:
41
+ out["since_ms"] = since_ms
42
+ if until_ms is not None:
43
+ out["until_ms"] = until_ms
44
+ if limit is not None:
45
+ out["limit"] = limit
46
+ if cursor is not None:
47
+ out["cursor"] = cursor
48
+ if writer is not None:
49
+ out["writer"] = writer
50
+ return out
51
+
52
+
53
+ class CollectionMixin:
54
+ """The uniform state verbs. Composed into `Plugin`."""
55
+
56
+ async def get(self, name: str, id: str) -> dict | None:
57
+ """The record with that id, or None. On a keyed (compacted-changelog)
58
+ log this is the RAW entry — use `get_compacted` for the folded
59
+ current state."""
60
+ res = await self.collection_fetch(id, name)
61
+ rec = (res or {}).get("record")
62
+ return rec if isinstance(rec, dict) else None
63
+
64
+ async def get_compacted(self, name: str, key: str) -> dict | None:
65
+ """A keyed log's folded CURRENT state for one key — the point-read
66
+ half of the compacted-changelog projection (pairs with
67
+ `list_compacted`)."""
68
+ res = await self.collection_fetch_compacted(key, name)
69
+ rec = (res or {}).get("record")
70
+ return rec if isinstance(rec, dict) else None
71
+
72
+ async def list(self, name: str, opts: dict | None = None) -> list[dict]:
73
+ res = await self.collection_list(name, opts)
74
+ return (res or {}).get("records") or []
75
+
76
+ async def list_compacted(self, name: str, opts: dict | None = None) -> list[dict]:
77
+ """The compacted-changelog projection of a keyed log — one folded
78
+ record per key instead of the raw append history."""
79
+ merged = dict(opts or {})
80
+ merged["compacted"] = True
81
+ res = await self.collection_list(name, merged)
82
+ return (res or {}).get("records") or []
83
+
84
+ async def list_page(self, name: str, opts: dict | None = None) -> tuple[list[dict], int]:
85
+ """Like `list` but also returns the unfiltered total."""
86
+ res = await self.collection_list(name, opts)
87
+ res = res or {}
88
+ return res.get("records") or [], res.get("total") or 0
89
+
90
+ async def count(self, name: str) -> int:
91
+ res = await self.collection_count(name)
92
+ return (res or {}).get("count") or 0
93
+
94
+ async def put(self, name: str, id: str, payload: Any) -> None:
95
+ """Single-record upsert. An unregistered name auto-registers as a
96
+ record-keyed dynamic collection — memory-only and EPHEMERAL; declare
97
+ the collection in the manifest for durable storage."""
98
+ await self.collection_put(name, [{"id": id, "payload": payload}])
99
+
100
+ async def put_many(self, name: str, entries: list[dict]) -> int:
101
+ """Bulk upsert. Validation runs across all entries before any
102
+ commit, so a partial batch with one invalid entry leaves the
103
+ backend untouched."""
104
+ if not entries:
105
+ return 0
106
+ res = await self.collection_put(name, entries)
107
+ return (res or {}).get("count") or 0
108
+
109
+ async def replace(
110
+ self,
111
+ name: str,
112
+ entries: list[dict],
113
+ scope: dict,
114
+ *,
115
+ roles: dict | None = None,
116
+ label: str | None = None,
117
+ ) -> dict:
118
+ """Make the records in scope exactly `entries`: upsert what changed,
119
+ delete what is absent, skip what is byte-identical. Returns
120
+ ``{"put": n, "deleted": n, "skipped": n}``.
121
+
122
+ Scope is required and never inferred — pass `scope_collection()` or
123
+ `scope_group(...)`. See notes/DESIGN_COLLECTION_REPLACE.md."""
124
+ # Refused locally rather than sent: guessing between "everything I
125
+ # own here" and "the subset under this key space" is how a refresh
126
+ # silently becomes a wipe.
127
+ if not isinstance(scope, dict) or scope.get("kind") not in ("collection", "group"):
128
+ raise ValueError(
129
+ "replace: scope is required — use scope_collection() or scope_group(name)"
130
+ )
131
+ # No early return on empty `entries`: replacing with the empty set
132
+ # is how a caller CLEARS its scope.
133
+ res = await self.collection_replace(name, scope, entries, label, roles)
134
+ res = res or {}
135
+ return {
136
+ "put": res.get("put") or 0,
137
+ "deleted": res.get("deleted") or 0,
138
+ "skipped": res.get("skipped") or 0,
139
+ }
140
+
141
+ async def put_many_with_roles(self, name: str, entries: list[dict], roles: dict) -> int:
142
+ """Bulk upsert with per-payload-field display roles (field → role).
143
+ Roles persist on the collection — pass them on the first put to a
144
+ new name, then omit."""
145
+ return await self.put_many_with_display(name, entries, roles, "")
146
+
147
+ async def put_many_with_display(
148
+ self, name: str, entries: list[dict], roles: dict | None, label: str
149
+ ) -> int:
150
+ """Bulk upsert that also sets the collection's human-readable label.
151
+ Pass "" to leave the label unchanged; like roles, it persists."""
152
+ if not entries:
153
+ return 0
154
+ res = await self.collection_put(name, entries, None, label or None, roles)
155
+ return (res or {}).get("count") or 0
156
+
157
+ async def patch(self, name: str, id: str, fields: Any) -> None:
158
+ """Errors NOT_FOUND if no record with that id exists, or
159
+ OPERATION_NOT_PERMITTED on collections the state forbids patching."""
160
+ await self.collection_patch(fields, id, name)
161
+
162
+ async def delete(self, name: str, id: str) -> bool:
163
+ """Single-record delete. Returns whether the record existed."""
164
+ res = await self.collection_delete_records(name, [id])
165
+ return ((res or {}).get("deleted") or 0) > 0
166
+
167
+ async def delete_many(self, name: str, ids: list[str]) -> tuple[int, int]:
168
+ """Bulk delete. Returns (deleted, already_absent) so callers can
169
+ detect drift between their view and the platform's."""
170
+ if not ids:
171
+ return 0, 0
172
+ res = await self.collection_delete_records(name, ids)
173
+ res = res or {}
174
+ return res.get("deleted") or 0, res.get("already_absent") or 0
175
+
176
+ def subscribe(self, name: str, fn: Callable) -> None:
177
+ """Run `fn(evt)` whenever `_platform.collection.updated` fires for
178
+ this collection. The ordered pump awaits an async `fn`, so two rapid
179
+ updates cannot race. Subscriptions live for the process lifetime."""
180
+
181
+ async def _on_updated(params):
182
+ evt = params if isinstance(params, dict) else {}
183
+ if evt.get("collection") == name:
184
+ # Await, don't discard: this is what keeps wire order. Dual
185
+ # dispatch, same as any handler — a plain-`def` fn offloads.
186
+ await self._invoke(fn, evt)
187
+
188
+ self.on(EVENT_COLLECTION_UPDATED, _on_updated)
@@ -0,0 +1,97 @@
1
+ """Helpers for log-kind collections — append-only record stores declared
2
+ in the plugin manifest as `kind: "log"`. Sugar over the unified verbs
3
+ (the wire surface is collection.list / collection.fetch /
4
+ collection.delete_records; log-shaped reads are the same list with
5
+ time-window opts). Python twin of collection_log.{go,ts}."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+
12
+ def log_list_opts(
13
+ *,
14
+ since_ms: int | None = None,
15
+ until_ms: int | None = None,
16
+ limit: int | None = None,
17
+ cursor: str | None = None,
18
+ ) -> dict:
19
+ """Build a LogListOpts with typed scalar values — field-identical to
20
+ the unified list opts by design."""
21
+ out: dict[str, Any] = {}
22
+ if since_ms is not None:
23
+ out["since_ms"] = since_ms
24
+ if until_ms is not None:
25
+ out["until_ms"] = until_ms
26
+ if limit is not None:
27
+ out["limit"] = limit
28
+ if cursor is not None:
29
+ out["cursor"] = cursor
30
+ return out
31
+
32
+
33
+ def _record_to_log_entry(r: dict) -> dict:
34
+ """Project the unified record envelope onto the log view. Lossless: log
35
+ records carry their append time in timestamp_ms and owner in writer."""
36
+ return {
37
+ "id": r.get("id"),
38
+ "timestamp_ms": r.get("timestamp_ms"),
39
+ "payload": r.get("payload"),
40
+ "writer": r.get("writer"),
41
+ }
42
+
43
+
44
+ class CollectionLogMixin:
45
+ async def append(self, name: str, payload: Any) -> str:
46
+ """Append an entry to a log-kind collection; the actuator assigns a
47
+ ULID and timestamp. Returns the assigned entry id. Raises
48
+ RecordingDisabledError if the collection's recording flag is off."""
49
+ entry = await self.collection_append(name, payload)
50
+ if not entry:
51
+ raise RuntimeError("collection.append: actuator returned no entry")
52
+ return entry["id"]
53
+
54
+ async def append_entry(self, name: str, payload: Any) -> dict:
55
+ """Like `append` but returns the full LogEntry."""
56
+ entry = await self.collection_append(name, payload)
57
+ if not entry:
58
+ raise RuntimeError("collection.append: actuator returned no entry")
59
+ return entry
60
+
61
+ async def append_keyed(self, name: str, key: str, payload: Any) -> None:
62
+ """Annotate a keyed log (`log` preset, `id_strategy: by_field`):
63
+ appends `payload` with `key` stamped into the key field, as a fresh
64
+ append; same-key appends fold. Read the merged view with
65
+ `list_compacted`. See notes/DESIGN_LOG_ANNOTATION_PROJECTION.md."""
66
+ await self.collection_append_keyed(key, name, payload)
67
+
68
+ async def list_log(self, name: str, opts: dict | None = None) -> list[dict]:
69
+ """List log entries newest-first."""
70
+ entries, _ = await self.list_log_page(name, opts)
71
+ return entries
72
+
73
+ async def list_log_page(self, name: str, opts: dict | None = None) -> tuple[list[dict], int]:
74
+ """Like `list_log` but also returns the unfiltered total."""
75
+ records, total = await self.list_page(name, opts)
76
+ return [_record_to_log_entry(r) for r in records], total
77
+
78
+ async def get_log_entry(self, name: str, id: str) -> dict | None:
79
+ """One entry by id, or None. The RAW entry — on a keyed log use
80
+ `get_compacted` for a key's current state."""
81
+ rec = await self.get(name, id)
82
+ return _record_to_log_entry(rec) if rec else None
83
+
84
+ async def delete_log_entry(self, name: str, id: str) -> bool:
85
+ """Delete one entry by id. Returns whether it existed."""
86
+ return await self.delete(name, id)
87
+
88
+ async def set_collection_recording(self, name: str, enabled: bool) -> None:
89
+ """Toggle the recording flag on a log-kind collection. When False,
90
+ subsequent `append` calls raise RecordingDisabledError."""
91
+ await self.privacy_set_recording(enabled, name)
92
+
93
+ async def get_collection_recording(self, name: str) -> bool:
94
+ """The effective recording flag — the user override if set,
95
+ otherwise the manifest's `default_recording_enabled`."""
96
+ res = await self.privacy_get_recording(name)
97
+ return bool((res or {}).get("enabled", False))
branchkit/commands.py ADDED
@@ -0,0 +1,221 @@
1
+ """Command loading and authoring. Python twin of commands.{go,ts}.
2
+
3
+ File layout:
4
+
5
+ $BRANCHKIT_PLUGIN_DIR/
6
+ commands.json ← base commands (no context)
7
+ commands/ ← optional directory of context files
8
+ warp.json ← context-scoped commands
9
+
10
+ Commands in a context file inherit the context's requires_tags (merged
11
+ with any on the command itself)."""
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ from typing import Any
18
+
19
+ # The actuator's command parser rejects an explicit JSON `null` for these
20
+ # array fields (it accepts an array or an absent field). The builder
21
+ # defaults them to [], but a spec from load_commands carries whatever the
22
+ # file had — coerce None to [] before the wire. Mirrors Go's
23
+ # normalizeCommandSpec.
24
+ _COMMAND_SPEC_ARRAY_FIELDS = (
25
+ "requires_tags",
26
+ "sets_tags",
27
+ "clears_tags",
28
+ "sets_on_partial",
29
+ "variants",
30
+ )
31
+
32
+
33
+ def word(w: str) -> str:
34
+ """A literal spoken word."""
35
+ return w
36
+
37
+
38
+ def one_of(*alts: str) -> list[str]:
39
+ """An alternatives slot: any of the given words matches, sharing one
40
+ action."""
41
+ return list(alts)
42
+
43
+
44
+ def capture(name: str, collection: str) -> str:
45
+ """A list-capture token `<name:collection>` whose matched value binds
46
+ to `name`. An empty name uses the collection as the binding name."""
47
+ return f"<{name}:{collection}>" if name else f"<{collection}>"
48
+
49
+
50
+ def text(name: str = "") -> str:
51
+ """A free-text capture token `<name:text>` (or `<text>`)."""
52
+ return f"<{name}:text>" if name else "<text>"
53
+
54
+
55
+ class CommandBuilder:
56
+ """Accumulates a CommandSpec via chained setters; finish with build()."""
57
+
58
+ def __init__(self, slots: list):
59
+ self._spec: dict[str, Any] = {
60
+ "pattern": list(slots),
61
+ "cancels_bridge": False,
62
+ "requires_tags": [],
63
+ "sets_tags": [],
64
+ "clears_tags": [],
65
+ "sets_on_partial": [],
66
+ "display_sources": {},
67
+ "variants": [],
68
+ }
69
+
70
+ def action(self, type: str, params: dict | None = None) -> "CommandBuilder":
71
+ """Set the action fired on match. `type` is the action's type (a
72
+ built-in like "key" or a dotted plugin action); `params` are merged
73
+ into the action object."""
74
+ self._spec["action"] = {"type": type, **(params or {})}
75
+ return self
76
+
77
+ def requires_tags(self, *tags: str) -> "CommandBuilder":
78
+ self._spec["requires_tags"].extend(tags)
79
+ return self
80
+
81
+ def sets_tags(self, *tags: str) -> "CommandBuilder":
82
+ self._spec["sets_tags"].extend(tags)
83
+ return self
84
+
85
+ def clears_tags(self, *tags: str) -> "CommandBuilder":
86
+ self._spec["clears_tags"].extend(tags)
87
+ return self
88
+
89
+ def display_source(self, capture: str, collection: str) -> "CommandBuilder":
90
+ """Discovery-HUD display override for one capture: enumerate
91
+ `collection` in the HUD instead of the capture's matching
92
+ collection. Matching is untouched."""
93
+ self._spec["display_sources"][capture] = collection
94
+ return self
95
+
96
+ def sets_on_partial(self, *tags: str) -> "CommandBuilder":
97
+ self._spec["sets_on_partial"].extend(tags)
98
+ return self
99
+
100
+ def cancels_bridge(self) -> "CommandBuilder":
101
+ self._spec["cancels_bridge"] = True
102
+ return self
103
+
104
+ def discovery(self, mode: str) -> "CommandBuilder":
105
+ """Declare the command's prefix-discovery affordance ("prefix" or
106
+ "exclusive"). Valid only on a literal-prefix + single-tail-capture
107
+ pattern. See notes/DESIGN_DISCOVERABLE_PREFIX.md."""
108
+ self._spec["discovery"] = mode
109
+ return self
110
+
111
+ def category(self, c: str) -> "CommandBuilder":
112
+ self._spec["category"] = c
113
+ return self
114
+
115
+ def description(self, d: str) -> "CommandBuilder":
116
+ self._spec["description"] = d
117
+ return self
118
+
119
+ def build(self) -> dict:
120
+ return self._spec
121
+
122
+
123
+ def command(*slots) -> CommandBuilder:
124
+ """Start a command builder with the given pattern slots."""
125
+ return CommandBuilder(list(slots))
126
+
127
+
128
+ def _load_command_file(path: str) -> list[dict]:
129
+ """An ABSENT file is not an error — a plugin may ship only context
130
+ files, or none at all. Any other read failure propagates: swallowing
131
+ them is how an unreadable commands.json silently pushed zero
132
+ commands."""
133
+ try:
134
+ with open(path, encoding="utf-8") as f:
135
+ data = f.read()
136
+ except FileNotFoundError:
137
+ return []
138
+ except OSError as e:
139
+ raise RuntimeError(f"{path}: {e}") from e
140
+ return json.loads(data)
141
+
142
+
143
+ def _load_context_file(path: str) -> list[dict]:
144
+ with open(path, encoding="utf-8") as f:
145
+ cf = json.load(f)
146
+ context_tags = ((cf.get("context") or {}).get("requires_tags")) or []
147
+ if not context_tags:
148
+ raise RuntimeError(f"{path}: missing or empty context.requires_tags")
149
+ commands = cf.get("commands") or []
150
+ return [_merge_requires_tags(cmd, context_tags) for cmd in commands]
151
+
152
+
153
+ def _merge_requires_tags(cmd: dict, context_tags: list[str]) -> dict:
154
+ existing = cmd.get("requires_tags") or []
155
+ return {**cmd, "requires_tags": [*context_tags, *existing]}
156
+
157
+
158
+ def load_commands() -> list[dict]:
159
+ """Load commands.json and any context files from commands/ WITHOUT
160
+ pushing — lets a plugin union file-authored static commands with built
161
+ dynamic ones and push them in a single push_command_specs call."""
162
+ plugin_dir = os.environ.get("BRANCHKIT_PLUGIN_DIR")
163
+ if not plugin_dir:
164
+ return []
165
+ raw: list[dict] = []
166
+ raw.extend(_load_command_file(os.path.join(plugin_dir, "commands.json")))
167
+ context_dir = os.path.join(plugin_dir, "commands")
168
+ try:
169
+ entries = sorted(e for e in os.listdir(context_dir) if e.endswith(".json"))
170
+ except OSError:
171
+ entries = []
172
+ for entry in entries:
173
+ raw.extend(_load_context_file(os.path.join(context_dir, entry)))
174
+ return raw
175
+
176
+
177
+ def _normalize_command_spec(spec: dict) -> dict:
178
+ out = dict(spec)
179
+ for field in _COMMAND_SPEC_ARRAY_FIELDS:
180
+ if out.get(field) is None:
181
+ out[field] = []
182
+ return out
183
+
184
+
185
+ async def push_commands(plugin) -> int:
186
+ """Load commands.json + context files and push them all via
187
+ commands.push. Returns the number of command variants registered."""
188
+ specs = load_commands()
189
+ if not specs:
190
+ return 0
191
+ return await push_command_specs(plugin, specs)
192
+
193
+
194
+ async def push_command_specs(plugin, specs: list[dict]) -> int:
195
+ """Register a built/loaded set of commands via commands.push
196
+ (replace-per-plugin semantics). Returns the number of command variants
197
+ registered."""
198
+ resp = await plugin.call(
199
+ "commands.push", {"commands": [_normalize_command_spec(s) for s in specs]}
200
+ )
201
+ return (resp or {}).get("count") or 0
202
+
203
+
204
+ async def push_command_group(plugin, group: str, specs: list[dict]) -> int:
205
+ """Register `specs` as a NAMED GROUP within this plugin's command set,
206
+ replacing only that group. Use whenever a plugin has more than one
207
+ command source — plain push_command_specs replaces the ENTIRE set, so
208
+ two sources pushing independently race. Returns the number of command
209
+ variants now active for the whole plugin."""
210
+ # Refused locally rather than sent: an empty group name is
211
+ # indistinguishable on the wire from an ungrouped push, which replaces
212
+ # EVERY group. A caller must not reach whole-set semantics by accident.
213
+ if not group:
214
+ raise ValueError(
215
+ "push_command_group: group name is required (use push_command_specs to replace the whole set)"
216
+ )
217
+ resp = await plugin.call(
218
+ "commands.push",
219
+ {"commands": [_normalize_command_spec(s) for s in specs], "group": group},
220
+ )
221
+ return (resp or {}).get("count") or 0