@tapcue/extension-sdk 0.1.0

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.
@@ -0,0 +1,277 @@
1
+ /**
2
+ * The closed component vocabulary, as JSX components (docs/architecture/extension-ui-interactive.md §5).
3
+ *
4
+ * Each component is an inert tag: the JSX factory reads its `__tag` and builds a `ViewNode`;
5
+ * the function body is never run (except that `<For>`'s child render function and `<Show>`'s
6
+ * structure are read by the factory). You cannot emit a lowercase intrinsic (`<div>`) — the
7
+ * `JSX.IntrinsicElements` set is empty on purpose. The shell renders these natively; an
8
+ * unknown tag is dropped, never guessed at.
9
+ */
10
+
11
+ import type { JsonValue } from "./json.js";
12
+ import type { PermissionUnit } from "./manifest.js";
13
+ import type { ActionSpec, Bindable, Signal } from "./reactive.js";
14
+ import type { Chart, IconRef } from "./scene.js";
15
+ import type { CommandItem } from "./types.js";
16
+ import type { ViewNode } from "./view.js";
17
+
18
+ type Node = ViewNode;
19
+ type Children = Node | Node[] | boolean | null | undefined;
20
+ type Str = Bindable<string>;
21
+
22
+ interface Tag<P> {
23
+ (props: P): ViewNode;
24
+ readonly __tag: string;
25
+ }
26
+
27
+ function tag<P>(type: string): Tag<P> {
28
+ const component = ((_props: P) => undefined as unknown as ViewNode) as Tag<P> & { __tag: string };
29
+ component.__tag = type;
30
+ return component as Tag<P>;
31
+ }
32
+
33
+ // ── Surfaces ──────────────────────────────────────────────────────────────────
34
+
35
+ // The interactive row-scope roots — `<List>` / `<Grid>` / `<TwoColumn>` and their `.Item` /
36
+ // `.Section` — are planned but **not shipped**: the shell renders a `<Detail>` pane, not an
37
+ // interactive row list (extension-ui-interactive.md §5). They return with the shell rendering that
38
+ // draws them and the first extension that needs them, per the grow-on-evidence discipline — a tag
39
+ // the shell would silently drop is the "believe in a handler the shell never calls" trap.
40
+
41
+ export interface DetailProps {
42
+ title?: Str;
43
+ subtitle?: Str;
44
+ icon?: Bindable<IconRef>;
45
+ children?: Children;
46
+ }
47
+ export const Detail = tag<DetailProps>("detail");
48
+
49
+ // ── Content blocks ────────────────────────────────────────────────────────────
50
+
51
+ export interface MarkdownProps {
52
+ text: Str;
53
+ }
54
+ export const Markdown = tag<MarkdownProps>("markdown");
55
+
56
+ export interface ChartProps {
57
+ chart: Chart;
58
+ }
59
+ export const ChartView = tag<ChartProps>("chart");
60
+
61
+ export interface MetadataRowProps {
62
+ label: Str;
63
+ value: Str;
64
+ icon?: Bindable<IconRef>;
65
+ }
66
+ export interface MetadataSectionProps {
67
+ title?: Str;
68
+ children?: Children;
69
+ }
70
+ export const Metadata = Object.assign(tag<{ children?: Children }>("metadata"), {
71
+ Section: tag<MetadataSectionProps>("metadata.section"),
72
+ Row: tag<MetadataRowProps>("metadata.row"),
73
+ });
74
+
75
+ // ── Inputs ────────────────────────────────────────────────────────────────────
76
+ //
77
+ // These are the first two input widgets, and they ship on evidence: the `fonts` extension needs
78
+ // exactly them — a text field to type a specimen into, and a picker to choose the family. The
79
+ // wider form vocabulary (`TextField`, `Checkbox`, `DatePicker`, `TagPicker`) still waits for an
80
+ // extension that needs it.
81
+ //
82
+ // **Two-way binding costs no IPC.** Bind a `signal` to `value` and the *shell* writes the user's
83
+ // edits straight back into its own copy of the model — the isolate is not woken per keystroke, and
84
+ // is very likely evicted while the user types. That is what makes a live preview affordable: the
85
+ // text and the chosen family are both model cells the shell already holds, so re-rendering as you
86
+ // type is a local operation. An `onChange` action exists for when the extension genuinely needs to
87
+ // *react* (persist a choice, refetch); binding alone needs no action at all.
88
+
89
+ export interface TextAreaProps {
90
+ /** Bind a `signal` for two-way editing; a plain string renders read-only text. */
91
+ value: Bindable<string>;
92
+ placeholder?: Str;
93
+ /** Visible lines. The shell owns the real metrics, this is a hint. */
94
+ rows?: number;
95
+ /**
96
+ * **Render the text in this font family** — the one place the contract lets an extension
97
+ * influence typography, and a deliberate, narrow exception to "the shell owns type"
98
+ * (extension-ui §1).
99
+ *
100
+ * It is content, not styling, in the same sense `color:` is: the family *is* the thing being
101
+ * shown, known only at runtime, and no packaged asset could carry it. The rule that keeps it on
102
+ * the content side of that line: **the family must be one the host reported from
103
+ * `ctx.fonts.list()`.** A family the host never listed is not a styling escape hatch, it is a
104
+ * value the shell refuses. Size, colour, spacing, and accessibility scaling stay the shell's.
105
+ */
106
+ font?: Bindable<string>;
107
+ fontSize?: Bindable<number>;
108
+ /** 100–900, matching `FontFace.weight`. */
109
+ fontWeight?: Bindable<number>;
110
+ italic?: Bindable<boolean>;
111
+ /**
112
+ * Fired once an edit settles — not per keystroke. The shell coalesces a burst of typing and
113
+ * sends the value it ended on; the bound cell was already written locally, long before this
114
+ * runs, so the preview never waits on it. Omit it for a pure live preview.
115
+ */
116
+ onChange?: ActionSpec;
117
+ }
118
+ export const TextArea = tag<TextAreaProps>("textarea");
119
+
120
+ export interface SelectOptionProps {
121
+ /** The value written back into the bound cell when this option is chosen. */
122
+ value: Str;
123
+ /** Defaults to `value`. */
124
+ title?: Str;
125
+ icon?: Bindable<IconRef>;
126
+ }
127
+ export interface SelectProps {
128
+ /** Bind a `signal`; the shell writes the chosen option's `value` into it. */
129
+ value: Bindable<string>;
130
+ placeholder?: Str;
131
+ /** Fired after the choice settles. The bound cell is written locally either way. */
132
+ onChange?: ActionSpec;
133
+ children?: Children;
134
+ }
135
+ /**
136
+ * A single-choice picker. Pair it with `<For>` to build the options from a list the extension
137
+ * fetched — a font picker is `<For each={families}>` emitting one `<Select.Option>` each, and the
138
+ * shell expands it without waking the isolate.
139
+ */
140
+ export const Select = Object.assign(tag<SelectProps>("select"), {
141
+ Option: tag<SelectOptionProps>("select.option"),
142
+ });
143
+
144
+ // ── Actions ───────────────────────────────────────────────────────────────────
145
+
146
+ export interface ActionProps {
147
+ title: Str;
148
+ onAction: ActionSpec;
149
+ icon?: Bindable<IconRef>;
150
+ /** Overrides the unit inferred from the action; usually omit. */
151
+ requires?: PermissionUnit;
152
+ destructive?: boolean;
153
+ /**
154
+ * **Which menu this action belongs to.** `row` (the default) is ⌘K — the actions menu, which
155
+ * speaks for whatever is selected. `scope` is ⇧⌘K, the scope handle's menu, which speaks for
156
+ * the surface you are *in*.
157
+ *
158
+ * The split is the one the two keystrokes already promise, and it is about the action's
159
+ * subject, not its importance: "Quit Process" acts on the row under the cursor and belongs in
160
+ * ⌘K; "Refresh Every 5 Seconds" acts on the whole table and belongs in ⇧⌘K, next to "Exit
161
+ * Processes". Putting a surface-wide setting in the row menu makes it read as something that
162
+ * happens to the selected row.
163
+ *
164
+ * Only a rowless surface (`pane`) has both menus, so this is where it applies. Elsewhere it is
165
+ * ignored rather than honoured into a menu the shell does not draw.
166
+ */
167
+ placement?: "row" | "scope";
168
+ }
169
+ export const Action = tag<ActionProps>("action");
170
+
171
+ // ── Non-content states ────────────────────────────────────────────────────────
172
+ // `<Empty>` / `<Error>` are shipped (the shell renders them as pane states). `<Loading>` is planned
173
+ // — the shell has no loading-root render yet, so it waits, like the other unshipped roots above.
174
+
175
+ export interface EmptyProps {
176
+ title: Str;
177
+ description?: Str;
178
+ icon?: Bindable<IconRef>;
179
+ }
180
+ export const Empty = tag<EmptyProps>("empty");
181
+ export interface ErrorProps {
182
+ title: Str;
183
+ description?: Str;
184
+ children?: Children;
185
+ }
186
+ export const ErrorView = tag<ErrorProps>("error");
187
+
188
+ /**
189
+ * A multi-column table of rows the view describes, rather than rows the scope hands back.
190
+ *
191
+ * The scope-level `table` layout (extension-ui §5) is the other way to draw one, and the two are
192
+ * for different things. That one's rows are `CommandItem`s: Tapcue fuzzy-matches them, ranks them,
193
+ * drops actions whose permission was denied, and drives keyboard navigation through them. This one
194
+ * is a view node — the shell draws exactly what is described and searches none of it — so it is for
195
+ * a table that is *part of a pane*, beside other blocks, whose contents the extension owns.
196
+ *
197
+ * `rows` binds a signal, so a `whileVisible` setup writing to it re-renders the table in the shell
198
+ * with no isolate involved. Each row is an object keyed by column id.
199
+ */
200
+ export interface TableColumnSpec {
201
+ id: string;
202
+ title: Str;
203
+ /** Character-ish width hint; the shell resolves it against the pane. */
204
+ width?: number;
205
+ align?: "leading" | "trailing";
206
+ /** Right-align and sort by value rather than by text, so `9 MB` sorts below `10 MB`. */
207
+ numeric?: boolean;
208
+ /**
209
+ * Draw each row's `icon` before this column's text — for the leading name column. The icon is
210
+ * the row's (`CommandItem.icon`, e.g. `process:<pid>`), resolved and drawn by the shell; the
211
+ * same key the scope-level `table` scheme's columns use.
212
+ */
213
+ showsIcon?: boolean;
214
+ }
215
+
216
+ export interface TableProps {
217
+ columns: TableColumnSpec[];
218
+ /**
219
+ * The rows, as the same `CommandItem`s a scope's `items` returns.
220
+ *
221
+ * A row is a row on both paths: `id` gives it identity, `cells` fill the columns, `aliases` add
222
+ * search keys no column shows, and `defaultActionId`/`defaultActionTitle`/`requires` give it an
223
+ * action the shell runs on Return, labels in the footer, and drops when the permission was not
224
+ * granted. None of that is re-invented here — it is the vocabulary rows already had.
225
+ *
226
+ * `undefined` and `[]` are different claims. A signal whose value is still `undefined` is a
227
+ * table whose first data has not arrived — the shell draws it as loading. An empty array is a
228
+ * table with genuinely nothing in it — the shell shows `empty`. A live table (a `whileVisible`
229
+ * setup filling the signal) should start from `undefined`, or its first frame asserts an
230
+ * emptiness nobody has measured yet.
231
+ */
232
+ rows: Signal<CommandItem[] | undefined> | Signal<CommandItem[]> | CommandItem[];
233
+ /** Column id to sort by when the table first appears; omit to keep the order given. */
234
+ defaultSortColumn?: string;
235
+ /** Sort descending on first appearance. */
236
+ defaultSortDescending?: boolean;
237
+ /** Shown in place of the table when `rows` is empty — genuinely empty, not merely unloaded. */
238
+ empty?: Str;
239
+ /**
240
+ * Filter the rows against what the person typed, matching across every cell.
241
+ *
242
+ * The filtering is the shell's and happens on the model it already holds, so typing costs no
243
+ * round trip and the extension is not woken to answer a keystroke. It is opt-in because a table
244
+ * is not always a list to search — a summary of four figures is not improved by hiding three of
245
+ * them — and because a scope that also declares rows already has search over those.
246
+ *
247
+ * In a `pane`, this is also what puts the query field in the band above it: a pane is otherwise
248
+ * drawn with a titlebar, having nothing to filter. So the one word asks for the filtering and
249
+ * for the thing to type into, and the scope's `placeholder` describes it.
250
+ */
251
+ search?: boolean;
252
+ }
253
+ export const Table = tag<TableProps>("table");
254
+
255
+ // ── Control flow (compiles to bindings; resolved by the shell, no isolate) ─────
256
+
257
+ export interface ForProps<T> {
258
+ each: Signal<T[]> | T[];
259
+ children: (item: T, index: number) => ViewNode;
260
+ }
261
+ export function For<T>(_props: ForProps<T>): ViewNode {
262
+ return undefined as unknown as ViewNode;
263
+ }
264
+ (For as unknown as { __tag: string }).__tag = "for";
265
+
266
+ export interface ShowProps {
267
+ when: Bindable<boolean>;
268
+ fallback?: ViewNode;
269
+ children?: Children;
270
+ }
271
+ export function Show(_props: ShowProps): ViewNode {
272
+ return undefined as unknown as ViewNode;
273
+ }
274
+ (Show as unknown as { __tag: string }).__tag = "show";
275
+
276
+ /** Grouping with no wrapper node; the factory splices its children into the parent. */
277
+ export const Fragment = tag<{ children?: Children }>("fragment");
@@ -0,0 +1,205 @@
1
+ /**
2
+ * `defineExtension` — the entry point an extension's `main.js` default-exports.
3
+ *
4
+ * Top-level code must do nothing but define the extension: the host evaluates
5
+ * the module under a load deadline and expects no I/O, no side effects, and no
6
+ * capability use before the first invocation.
7
+ */
8
+
9
+ import { ExtensionError } from "./errors.js";
10
+ import type { JsonValue } from "./json.js";
11
+ import type {
12
+ CommandHandlers,
13
+ CommandItem,
14
+ ContributionHandlers,
15
+ ExtensionContext,
16
+ ItemScope,
17
+ ScopeSpec,
18
+ } from "./types.js";
19
+ import { applySettingChange, settingSignal } from "./reactive.js";
20
+ import { applyAction, driveView, renderView } from "./view-runtime.js";
21
+
22
+ /**
23
+ * Narrow a capability the manifest declares as **required**.
24
+ *
25
+ * A required capability cannot be missing at runtime — denying it means the
26
+ * extension was never enabled — but the SDK's types cannot read your manifest, so
27
+ * they hand you `ctx.http | undefined` regardless. This asserts the manifest's
28
+ * promise. If it ever throws, the host is broken, not your extension.
29
+ *
30
+ * Do not use it on an *optional* capability: there, absence is a state you must
31
+ * handle, and `if (!ctx.clipboard?.write)` is the whole check.
32
+ */
33
+ export function requireCapability<T>(capability: T | undefined, name: string): T {
34
+ if (capability === undefined) {
35
+ throw new ExtensionError({
36
+ code: "permission-denied",
37
+ message: `${name} is declared required but was not granted`,
38
+ });
39
+ }
40
+ return capability;
41
+ }
42
+
43
+ /**
44
+ * A registered scope plus the type-safe way to build items that enter it.
45
+ *
46
+ * The `type` string and the subject type `S` are linked here, so an item built
47
+ * with `scope.entry(...)` can never carry a subject the scope's handlers cannot
48
+ * read — which a bare `scope: { type, subject }` object literal could.
49
+ */
50
+ export interface DefinedScope<S extends JsonValue> {
51
+ readonly spec: ScopeSpec<S>;
52
+ readonly type: string;
53
+ /** Build a scope-face item: no `defaultActionId`, so Enter enters the scope. */
54
+ entry(
55
+ item: Omit<CommandItem<S>, "scope" | "defaultActionId">,
56
+ subject: S,
57
+ options?: Omit<ItemScope<S>, "type" | "subject">,
58
+ ): CommandItem<S>;
59
+ }
60
+
61
+ export function defineScope<S extends JsonValue>(spec: ScopeSpec<S>): DefinedScope<S> {
62
+ return {
63
+ spec,
64
+ type: spec.type,
65
+ entry(item, subject, options) {
66
+ return {
67
+ ...item,
68
+ // The scheme travels with the row: an extension declares its scopes in code, so the
69
+ // shell only learns a scope's layout from an item that enters it. A `canvas` scope must
70
+ // reach the shell as `canvas`, or it is rendered as the wrong thing; a `table` scope must
71
+ // carry its `columns` and `live` flag the same way. `queryMode` travels for the same reason
72
+ // — the shell only knows whether to filter the scope's rows locally (`catalog`, the default)
73
+ // or forward the query to the scope (`remote`) from the row that enters it, since the scope
74
+ // is declared in code, not the manifest.
75
+ scope: {
76
+ type: spec.type,
77
+ subject,
78
+ scheme: spec.scheme,
79
+ queryMode: spec.queryMode ?? "catalog",
80
+ ...options,
81
+ },
82
+ };
83
+ },
84
+ };
85
+ }
86
+
87
+ export interface ExtensionSpec {
88
+ /**
89
+ * `CommandHandlers<any>`, not a union of `CommandHandlers<never> | CommandHandlers<JsonValue>`:
90
+ * TypeScript gives no contextual types to a parameter whose target is a union, so a
91
+ * union here would silently turn every inline `query(request, ctx)` into
92
+ * `query(request: any, ctx: any)` and stop checking the return type — under
93
+ * `strict`, it does not even compile. Commands are keyed by id and each carries its
94
+ * own subject type, so there is nothing for one shared type parameter to say.
95
+ */
96
+ commands: Record<string, CommandHandlers<any>>;
97
+ /** Scopes this extension's items can enter. Declared in code, not in the manifest. */
98
+ scopes?: DefinedScope<any>[];
99
+ /**
100
+ * **Rows this extension adds to scopes it does not own**, keyed by the `contributes[].id` the
101
+ * manifest declared (architecture §11.3).
102
+ *
103
+ * Keyed like `commands` rather than listed like `scopes`, because that is what a contribution
104
+ * is: declared in the manifest — the user enters the scope, so Tapcue has to know who to ask
105
+ * without running anything — and implemented here. **Both halves are required and the host
106
+ * checks them against each other at load:** a manifest entry with no handler promises rows
107
+ * nobody wrote, and a handler with no manifest entry is code that will never be called. Either
108
+ * way somebody wrote half a contribution, and half of one failing loudly beats it failing in
109
+ * silence.
110
+ *
111
+ * `ContributionHandlers<any>` for the reason `commands` is `CommandHandlers<any>`: a union
112
+ * target gives inline handler parameters no contextual type, so each entry carries its own
113
+ * scope type instead of one shared parameter.
114
+ */
115
+ contributions?: Record<string, ContributionHandlers<any>>;
116
+ /**
117
+ * **Slots this extension fills**, keyed by the `provides[].id` the manifest declared.
118
+ *
119
+ * The mirror image of `contributions`: there, Tapcue owns a place and the extension adds rows to
120
+ * it; here, Tapcue owns a *question* and the extension adds an answer to it. Both halves are
121
+ * required and checked against each other at load, for the same reason.
122
+ *
123
+ * A slot handler is a **query, never an effect** — it is handed an `ExtensionContext`, not an
124
+ * `ActionContext`, because nobody pressed anything. Tapcue asks when the launcher opens and
125
+ * caches the answer; a slow one is late, not felt.
126
+ */
127
+ provisions?: Record<string, ProvisionHandlers>;
128
+ /** Called before the isolate is dropped. Best-effort: a forced termination skips it. */
129
+ dispose?(reason: string): void | Promise<void>;
130
+ }
131
+
132
+ /** What Tapcue asks when it needs a slot filled. */
133
+ export interface SlotRequest {
134
+ provisionId: string;
135
+ slot: string;
136
+ /** The most answers Tapcue will keep. Returning more is not an error; the tail is dropped. */
137
+ limit: number;
138
+ }
139
+
140
+ /**
141
+ * One project folder the user works in.
142
+ *
143
+ * `path` is absolute, and the host still checks it: it must exist, be a directory, and sit under
144
+ * the user's home. An extension that names a folder is making a claim about where the user's files
145
+ * are, not being handed the right to read them — nothing here opens anything, and every row Tapcue
146
+ * builds from one shows the full path it landed on.
147
+ */
148
+ export interface WorkspaceRoot {
149
+ path: string;
150
+ /** What to call it, when the tool knows a better name than the folder's own. */
151
+ name?: string;
152
+ /** Seconds since the epoch, for ordering. Freshest first is the useful order. */
153
+ usedAt?: number;
154
+ }
155
+
156
+ /**
157
+ * The handler shape per slot. A union of one today; adding a slot adds a member, and the manifest's
158
+ * `slot` field is what selects between them.
159
+ */
160
+ export interface ProvisionHandlers {
161
+ roots?(request: SlotRequest, ctx: ExtensionContext): Promise<WorkspaceRoot[]> | WorkspaceRoot[];
162
+ }
163
+
164
+ export interface ExtensionDefinition {
165
+ commands: Record<string, CommandHandlers<any>>;
166
+ scopes: DefinedScope<any>[];
167
+ /**
168
+ * Optional on the *definition*, required in nothing: a bundle built against an SDK that
169
+ * predates §11.3 has no contributions at all, and the glue that reads this must load it
170
+ * anyway — the manifest's `api` range is a compatibility promise, so an absent key here has
171
+ * to mean "declares none" rather than "malformed".
172
+ */
173
+ contributions?: Record<string, ContributionHandlers<any>>;
174
+ /** Absent means "declares none", for the reason `contributions` is optional here. */
175
+ provisions?: Record<string, ProvisionHandlers>;
176
+ dispose?(reason: string): void | Promise<void>;
177
+ /**
178
+ * The interactive-view reactive loop (view-runtime.ts), attached here so the host glue
179
+ * (bootstrap.js) can drive a `view` handler without importing the SDK: the SDK is bundled
180
+ * into the extension, so this is how the two meet. Not part of the authored surface.
181
+ */
182
+ runtime: {
183
+ renderView: typeof renderView;
184
+ applyAction: typeof applyAction;
185
+ driveView: typeof driveView;
186
+ /**
187
+ * The settings-as-state pair (reactive.ts). The host glue builds `ctx.settings`, so it is the
188
+ * glue that has to create a settings-following cell and to announce a write — and the glue
189
+ * cannot import the SDK. Same reason `driveView` is here.
190
+ */
191
+ settingSignal: typeof settingSignal;
192
+ applySettingChange: typeof applySettingChange;
193
+ };
194
+ }
195
+
196
+ export function defineExtension(spec: ExtensionSpec): ExtensionDefinition {
197
+ return {
198
+ commands: spec.commands as Record<string, CommandHandlers<any>>,
199
+ scopes: (spec.scopes ?? []) as DefinedScope<any>[],
200
+ contributions: (spec.contributions ?? {}) as Record<string, ContributionHandlers<any>>,
201
+ provisions: spec.provisions ?? {},
202
+ dispose: spec.dispose,
203
+ runtime: { renderView, applyAction, driveView, settingSignal, applySettingChange },
204
+ };
205
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Structured, user-safe errors. Never put secrets, authorization headers, host
3
+ * paths, or raw service responses in `message` — the shell shows it to the user
4
+ * and the host records it in diagnostics.
5
+ */
6
+
7
+ /**
8
+ * Closed vocabulary for the codes the host itself raises. Extensions may add
9
+ * their own namespaced codes (`weather.no-such-place`), so the type stays open.
10
+ */
11
+ export type ErrorCode =
12
+ | "permission-denied"
13
+ | "cancelled"
14
+ | "deadline-exceeded"
15
+ | "quota-exceeded"
16
+ | "invalid-request"
17
+ | "invalid-result"
18
+ | "network"
19
+ | "unavailable"
20
+ | "not-found"
21
+ | (string & {});
22
+
23
+ export interface ExtensionErrorInit {
24
+ code: ErrorCode;
25
+ message: string;
26
+ /** Whether retrying the same invocation could plausibly succeed. */
27
+ retryable?: boolean;
28
+ /** Optional recovery the shell may offer. */
29
+ recovery?: ExtensionErrorRecovery;
30
+ }
31
+
32
+ /**
33
+ * A recovery is always **a command of this same package, run again** — either the
34
+ * same command with different arguments, or a sibling command. An extension
35
+ * cannot name a host action: it must not be able to put Tapcue's own permission
36
+ * or settings UI behind a title it wrote.
37
+ *
38
+ * Permission recovery therefore has no place here by design. A required
39
+ * permission cannot be denied at runtime (the extension would not be enabled),
40
+ * and a denied *optional* permission is something the extension degrades around
41
+ * — turning it back on is the shell's and settings' business, not an error's.
42
+ */
43
+ export interface ExtensionErrorRecovery {
44
+ title: string;
45
+ /** Must be a command declared in this extension's manifest. */
46
+ commandId: string;
47
+ arguments?: Record<string, string>;
48
+ }
49
+
50
+ export class ExtensionError extends Error {
51
+ readonly code: ErrorCode;
52
+ readonly retryable: boolean;
53
+ readonly recovery?: ExtensionErrorRecovery;
54
+
55
+ constructor(init: ExtensionErrorInit) {
56
+ super(init.message);
57
+ this.name = "ExtensionError";
58
+ this.code = init.code;
59
+ this.retryable = init.retryable ?? false;
60
+ this.recovery = init.recovery;
61
+ }
62
+ }
63
+
64
+ export function isExtensionError(value: unknown): value is ExtensionError {
65
+ return value instanceof ExtensionError;
66
+ }
67
+
68
+ /** The host raises this (and rejects pending capability calls) on cancellation. */
69
+ export function cancelledError(reason = "Invocation was cancelled"): ExtensionError {
70
+ return new ExtensionError({ code: "cancelled", message: reason, retryable: false });
71
+ }
72
+
73
+ /**
74
+ * **The code behind a rejection, without reading the prose.**
75
+ *
76
+ * The host tags what it refuses — `deadline-exceeded: exec "op-item-list" ran past its deadline` —
77
+ * and an extension that has to tell those apart by matching English will get it wrong. The wrong
78
+ * answer is rarely harmless: a *timeout* read as a missing binary is how somebody with the tool
79
+ * installed gets told to install it, while the dialog they were meant to answer is still on screen.
80
+ *
81
+ * Returns `undefined` for an error that carries no code, which is not the same as a code you do
82
+ * not recognise: an unknown code should degrade to the generic path, never to the wrong one.
83
+ */
84
+ export function errorCode(error: unknown): ErrorCode | undefined {
85
+ if (error instanceof ExtensionError) return error.code;
86
+ const message = error instanceof Error ? error.message : String(error ?? "");
87
+ // `<code>: <message>`, the shape every coded host refusal has.
88
+ const match = /^([a-z][a-z-]*[a-z]):\s/.exec(message);
89
+ return match?.[1];
90
+ }
package/src/i18n.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Message catalogs.
3
+ *
4
+ * Tapcue's own UI is localized; extension rows sitting next to it in English are
5
+ * not the extension author's fault, they are a missing platform mechanism. So the
6
+ * package ships catalogs as data:
7
+ *
8
+ * locales/en.json { "command.title": "Weather", … }
9
+ * locales/zh-CN.json { "command.title": "天气", … }
10
+ *
11
+ * Manifest strings reference a key with a leading `@` (`"title": "@command.title"`);
12
+ * a string without one is a literal. Runtime strings go through `ctx.t("key")`.
13
+ * Catalogs are data files covered by `checksums.json`, so they are reviewable and
14
+ * translatable without touching the bundle.
15
+ *
16
+ * Numbers, dates, and plurals are `Intl`'s job — it is available in the isolate.
17
+ */
18
+
19
+ export type LocaleCatalog = Record<string, string>;
20
+ export type LocaleCatalogs = Record<string, LocaleCatalog>;
21
+
22
+ export type Translate = (key: string, params?: Record<string, string | number>) => string;
23
+
24
+ /**
25
+ * Fallback chain: exact tag (`zh-CN`) → language (`zh`) → the package's
26
+ * `defaultLocale` → the key itself. A missing key never throws: a scene with a
27
+ * raw key in it is bad, a scene that fails to render is worse.
28
+ */
29
+ export function resolveCatalog(
30
+ catalogs: LocaleCatalogs,
31
+ locale: string,
32
+ defaultLocale: string,
33
+ ): LocaleCatalog {
34
+ const language = locale.split("-")[0];
35
+ return {
36
+ ...(catalogs[defaultLocale] ?? {}),
37
+ ...(catalogs[language] ?? {}),
38
+ ...(catalogs[locale] ?? {}),
39
+ };
40
+ }
41
+
42
+ export function createTranslator(catalog: LocaleCatalog, onMissing?: (key: string) => void): Translate {
43
+ return (key, params) => {
44
+ const template = catalog[key];
45
+ if (template === undefined) {
46
+ onMissing?.(key);
47
+ return key;
48
+ }
49
+ if (!params) return template;
50
+ return template.replace(/\{(\w+)\}/g, (whole, name: string) =>
51
+ name in params ? String(params[name]) : whole,
52
+ );
53
+ };
54
+ }
55
+
56
+ /** `"@command.title"` → `"command.title"`; a literal string → `undefined`. */
57
+ export function catalogKey(value: string): string | undefined {
58
+ return value.startsWith("@") ? value.slice(1) : undefined;
59
+ }
60
+
61
+ export function localizeString(value: string, translate: Translate): string {
62
+ const key = catalogKey(value);
63
+ return key === undefined ? value : translate(key);
64
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ export * from "./json.js";
2
+ export * from "./errors.js";
3
+ export * from "./i18n.js";
4
+ export * from "./scene.js";
5
+ export * from "./capabilities.js";
6
+ export * from "./types.js";
7
+ export * from "./define-extension.js";
8
+ export * from "./manifest.js";
9
+ export * from "./permission-units.js";
10
+ export * from "./view.js";
11
+ export * from "./reactive.js";
12
+ export * from "./components.js";
13
+ export * from "./view-runtime.js";
package/src/json.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The value vocabulary that may cross the guest ↔ host boundary.
3
+ *
4
+ * Everything an extension hands back to Tapcue — items, scenes, scope subjects,
5
+ * settings — must be structurally clonable JSON. Host paths, native pointers and
6
+ * other extensions' handles never cross the boundary.
7
+ *
8
+ * Two TypeScript notes for extension authors:
9
+ *
10
+ * - An `interface` is NOT assignable to `JsonValue` (interfaces get no implicit
11
+ * index signature). Declare payload shapes with `type X = { … }`.
12
+ * - Optional properties are allowed and are dropped on serialization, which is
13
+ * why the object branch admits `undefined` — otherwise `{ region?: string }`
14
+ * would not satisfy `JsonValue`.
15
+ */
16
+ export type JsonValue =
17
+ | string
18
+ | number
19
+ | boolean
20
+ | null
21
+ | JsonValue[]
22
+ | JsonObject;
23
+
24
+ export type JsonObject = { [key: string]: JsonValue | undefined };