@tai42/studio-sdk 20.0.0 → 21.0.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.
Files changed (89) hide show
  1. package/dist/components/components.css +7 -0
  2. package/dist/components/execution-key-picker.js.map +1 -1
  3. package/dist/components/extension-grouping.d.ts +8 -12
  4. package/dist/components/extension-grouping.d.ts.map +1 -1
  5. package/dist/components/extension-grouping.js +12 -15
  6. package/dist/components/extension-grouping.js.map +1 -1
  7. package/dist/components/extension-picker.d.ts +3 -4
  8. package/dist/components/extension-picker.d.ts.map +1 -1
  9. package/dist/components/extension-picker.js +10 -10
  10. package/dist/components/extension-picker.js.map +1 -1
  11. package/dist/components/fleet-report.d.ts +10 -5
  12. package/dist/components/fleet-report.d.ts.map +1 -1
  13. package/dist/components/fleet-report.js +20 -3
  14. package/dist/components/fleet-report.js.map +1 -1
  15. package/dist/components/json-tree-model.d.ts +19 -7
  16. package/dist/components/json-tree-model.d.ts.map +1 -1
  17. package/dist/components/json-tree-model.js +32 -8
  18. package/dist/components/json-tree-model.js.map +1 -1
  19. package/dist/components/json-tree.d.ts +10 -1
  20. package/dist/components/json-tree.d.ts.map +1 -1
  21. package/dist/components/json-tree.js +7 -4
  22. package/dist/components/json-tree.js.map +1 -1
  23. package/dist/components/select.d.ts +6 -0
  24. package/dist/components/select.d.ts.map +1 -1
  25. package/dist/components/select.js +3 -2
  26. package/dist/components/select.js.map +1 -1
  27. package/dist/components/served-media.d.ts +50 -4
  28. package/dist/components/served-media.d.ts.map +1 -1
  29. package/dist/components/served-media.js +69 -19
  30. package/dist/components/served-media.js.map +1 -1
  31. package/dist/env-markers.d.ts +25 -0
  32. package/dist/env-markers.d.ts.map +1 -0
  33. package/dist/env-markers.js +63 -0
  34. package/dist/env-markers.js.map +1 -0
  35. package/dist/hooks/interactions-stream.d.ts.map +1 -1
  36. package/dist/hooks/interactions-stream.js +9 -7
  37. package/dist/hooks/interactions-stream.js.map +1 -1
  38. package/dist/hooks/useApi.d.ts +2264 -912
  39. package/dist/hooks/useApi.d.ts.map +1 -1
  40. package/dist/hooks/useCapabilities.d.ts +5 -9
  41. package/dist/hooks/useCapabilities.d.ts.map +1 -1
  42. package/dist/hooks/useCapabilities.js +8 -33
  43. package/dist/hooks/useCapabilities.js.map +1 -1
  44. package/dist/host.d.ts +2 -0
  45. package/dist/host.d.ts.map +1 -1
  46. package/dist/host.js +8 -0
  47. package/dist/host.js.map +1 -1
  48. package/dist/index.d.ts +8 -4
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +3 -2
  51. package/dist/index.js.map +1 -1
  52. package/dist/plugin/coverage.d.ts +14 -0
  53. package/dist/plugin/coverage.d.ts.map +1 -0
  54. package/dist/plugin/coverage.js +14 -0
  55. package/dist/plugin/coverage.js.map +1 -0
  56. package/dist/plugin/registry.d.ts +11 -1
  57. package/dist/plugin/registry.d.ts.map +1 -1
  58. package/dist/plugin/registry.js +33 -4
  59. package/dist/plugin/registry.js.map +1 -1
  60. package/dist/plugin/types.d.ts +43 -1
  61. package/dist/plugin/types.d.ts.map +1 -1
  62. package/dist/state-binding/UpdateRow.d.ts +2 -2
  63. package/dist/state-binding/UpdateRow.js +1 -1
  64. package/dist/state-binding/UpdateRow.js.map +1 -1
  65. package/dist/state-binding/adapter.d.ts +12 -8
  66. package/dist/state-binding/adapter.d.ts.map +1 -1
  67. package/dist/state-binding/adapter.js +30 -13
  68. package/dist/state-binding/adapter.js.map +1 -1
  69. package/package.json +5 -4
  70. package/src/components/components.css +7 -0
  71. package/src/components/execution-key-picker.tsx +1 -1
  72. package/src/components/extension-grouping.ts +22 -22
  73. package/src/components/extension-picker.tsx +13 -14
  74. package/src/components/fleet-report.tsx +66 -7
  75. package/src/components/json-tree-model.ts +36 -8
  76. package/src/components/json-tree.tsx +24 -4
  77. package/src/components/select.tsx +11 -1
  78. package/src/components/served-media.tsx +150 -16
  79. package/src/env-markers.ts +70 -0
  80. package/src/hooks/interactions-stream.ts +9 -7
  81. package/src/hooks/useCapabilities.ts +8 -31
  82. package/src/host.ts +2 -0
  83. package/src/index.ts +16 -2
  84. package/src/plugin/coverage.ts +23 -0
  85. package/src/plugin/registry.ts +58 -3
  86. package/src/plugin/types.ts +45 -1
  87. package/src/state-binding/README.md +10 -7
  88. package/src/state-binding/UpdateRow.tsx +3 -3
  89. package/src/state-binding/adapter.ts +31 -16
@@ -131,8 +131,8 @@ export function isFullProjection(projection: MeProjection): boolean {
131
131
 
132
132
  /**
133
133
  * Whether a concrete path is `prefix` itself or nested beneath it at a PATH-SEGMENT
134
- * boundary — the client mirror of the server's segment-aware coverage rule
135
- * (access_control/settings.py: `a == b or a.startswith(b + '/')`). Matching on a
134
+ * boundary — the client mirror of the server's segment-aware coverage rule, held to
135
+ * the platform's published path-coverage vectors by the shell's tests. Matching on a
136
136
  * bare `startsWith` would let a sibling route like `/api/tools-schema` satisfy the
137
137
  * `/api/tools` token; the boundary keeps them distinct.
138
138
  */
@@ -144,22 +144,18 @@ function pathUnderPrefix(path: string, prefix: string): boolean {
144
144
  * Whether the projection reaches ANY route under one of `prefixes` — the
145
145
  * declarative test behind nav filtering and plugin `requiredCapabilities`. A
146
146
  * concrete `routes[].path` covers a prefix when it is that prefix or a
147
- * segment-nested descendant of it; a dynamic `route_patterns[].pattern` covers a
148
- * prefix only when it is a simple anchored open-wildcard pattern (`^<literal>.*$`)
149
- * whose open literal overlaps the prefix at a segment boundary.
150
- *
151
- * Under-showing is safe (projection is UX; the server is the authority);
152
- * over-showing is the bug. So any non-anchored or otherwise complex pattern
153
- * (alternation, char class, bounded quantifier) is conservatively treated as NOT
154
- * covering — its matched set is not a clean prefix, so we never infer reach from
155
- * it.
147
+ * segment-nested descendant of it; a dynamic `route_patterns[]` entry covers a
148
+ * prefix through its served `open_prefix` — the literal the server rules every
149
+ * path starting with is reachable — when it overlaps the prefix at a segment
150
+ * boundary. A `null` open prefix (a pattern whose matched set is not a clean
151
+ * prefix) covers nothing by prefix: under-showing is safe, over-showing is the bug.
156
152
  */
157
153
  export function coversAnyRoute(projection: MeProjection, prefixes: readonly string[]): boolean {
158
154
  return prefixes.some(
159
155
  (prefix) =>
160
156
  projection.routes.some((route) => pathUnderPrefix(route.path, prefix)) ||
161
157
  projection.route_patterns.some((entry) => {
162
- const open = patternOpenPrefix(entry.pattern);
158
+ const open = entry.open_prefix;
163
159
  // The open literal L means "every path starting with L is reachable". That
164
160
  // set reaches something under `prefix` when the prefix itself lies in it
165
161
  // (`prefix.startsWith(open)`) or when the literal already dives under the
@@ -221,22 +217,3 @@ export function useCanWrite(path: string, method = 'POST'): boolean {
221
217
  const { state } = useCapabilities();
222
218
  return coversWrite(state, path, method);
223
219
  }
224
-
225
- const REGEX_METACHARACTERS = /[.*+?()[\]{}|\\^$]/;
226
-
227
- /**
228
- * The literal prefix of a simple anchored open-wildcard pattern (`^<literal>.*$`,
229
- * the `$` optional), or `null` when the pattern is not exactly that shape. A
230
- * returned literal `L` means "every path starting with `L` is reachable"; only
231
- * that open-wildcard form yields a sound prefix, so a bare literal, an
232
- * alternation, a char class, or any other construct declines (returns `null`).
233
- */
234
- function patternOpenPrefix(pattern: string): string | null {
235
- if (!pattern.startsWith('^')) return null;
236
- let body = pattern.slice(1);
237
- if (body.endsWith('$')) body = body.slice(0, -1);
238
- if (!body.endsWith('.*')) return null;
239
- body = body.slice(0, -2);
240
- if (body.length === 0 || REGEX_METACHARACTERS.test(body)) return null;
241
- return body;
242
- }
package/src/host.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * carries no way to forge, wipe, or enumerate the registry. Only the host bundle
6
6
  * imports this. See SECURITY.md for the trust boundary.
7
7
  */
8
+ export { contributionCovered } from './plugin/coverage';
8
9
  export type {
9
10
  LoadedPlugin,
10
11
  PluginContributionsSnapshot,
@@ -16,4 +17,5 @@ export {
16
17
  subscribePluginHost,
17
18
  usePluginContributions,
18
19
  } from './plugin/host-state';
20
+ export type { PluginLoadInputs } from './plugin/registry';
19
21
  export { getContributions, loadPlugin } from './plugin/registry';
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ import './components/components.css';
41
41
 
42
42
  // -- Plugin API --------------------------------------------------------------
43
43
  export type {
44
+ ChannelConversationRoute,
44
45
  NavEntryContribution,
45
46
  NavEntrySection,
46
47
  PageContribution,
@@ -51,8 +52,11 @@ export type {
51
52
  PluginPageProps,
52
53
  RegisteredNavEntry,
53
54
  RegisteredPage,
55
+ RegisteredRoutePanel,
54
56
  RegisteredSettingsTab,
55
57
  RequiredCapabilities,
58
+ RoutePanelContribution,
59
+ RoutePanelProps,
56
60
  SettingsTabContribution,
57
61
  SettingsTabProps,
58
62
  ToolPanelContribution,
@@ -60,6 +64,8 @@ export type {
60
64
  } from './plugin/types';
61
65
  export type { VersionGateResult } from './plugin/version';
62
66
  export { checkPluginApiVersion, STUDIO_PLUGIN_API_VERSION } from './plugin/version';
67
+ /** What `useApi().runTool` resolves with: the run-tool door's answer, read by its `kind`. */
68
+ export type { RunAnswer } from '@tai42/api-client';
63
69
 
64
70
  // -- Hooks -------------------------------------------------------------------
65
71
  export type { ApiClient } from './hooks/useApi';
@@ -133,6 +139,8 @@ export {
133
139
 
134
140
  // -- Utilities ---------------------------------------------------------------
135
141
  export { downloadBlob } from './download';
142
+ export type { EnvRef } from './env-markers';
143
+ export { collectEnvRefs, ENV_MARKER_PREFIX, formatEnvMarker, parseEnvMarker } from './env-markers';
136
144
  export { errorMessage } from './errors';
137
145
 
138
146
  // -- Disabled-feature idiom (one helper + component, every consumer) ----------
@@ -292,11 +300,18 @@ export type { HeadingLevel, MarkdownBlock, MarkdownProps } from './components/ma
292
300
  export { Markdown, parseMarkdown } from './components/markdown';
293
301
  export type { OpenTargetOptions, OpenTargetProps } from './components/open-target';
294
302
  export { openTargetProps } from './components/open-target';
295
- export type { MediaImageProps } from './components/served-media';
303
+ export type {
304
+ MediaDocumentProps,
305
+ MediaImageProps,
306
+ MediaPlayerProps,
307
+ ServedMediaGrammar,
308
+ } from './components/served-media';
296
309
  export {
297
310
  isRenderableMediaSrc,
298
311
  isServedMediaUrl,
312
+ MediaDocument,
299
313
  MediaImage,
314
+ MediaPlayer,
300
315
  resolveMediaSrc,
301
316
  } from './components/served-media';
302
317
  // The URL half of the link-safety pair: `isSafeHttpUrl` answers yes/no, this one
@@ -313,7 +328,6 @@ export {
313
328
  groupByKind,
314
329
  groupIntoFamilies,
315
330
  kindVariant,
316
- NON_STACKABLE_KIND,
317
331
  } from './components/extension-grouping';
318
332
  export type { ExtensionPickerProps } from './components/extension-picker';
319
333
  export { ExtensionPicker } from './components/extension-picker';
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Whether a plugin contribution is visible under a capability projection — the one
3
+ * evaluator every host surface applies to a contribution's optional
4
+ * `requiredCapabilities`. Host-only (`@tai42/studio-sdk/host`).
5
+ */
6
+ import type { MeProjection } from '@tai42/api-client';
7
+
8
+ import { coversAnyRoute, isFullProjection } from '../hooks/useCapabilities';
9
+ import type { RequiredCapabilities } from './types';
10
+
11
+ /**
12
+ * A full projection shows every contribution; an ABSENT requirement renders only for a
13
+ * full projection (safe-by-default); a declared requirement renders when the projection
14
+ * reaches at least one of its routes.
15
+ */
16
+ export function contributionCovered(
17
+ projection: MeProjection,
18
+ required: RequiredCapabilities | undefined,
19
+ ): boolean {
20
+ if (isFullProjection(projection)) return true;
21
+ if (required === undefined) return false;
22
+ return coversAnyRoute(projection, required.routes);
23
+ }
@@ -14,7 +14,9 @@ import type {
14
14
  PluginEntry,
15
15
  RegisteredNavEntry,
16
16
  RegisteredPage,
17
+ RegisteredRoutePanel,
17
18
  RegisteredSettingsTab,
19
+ RoutePanelContribution,
18
20
  SettingsTabContribution,
19
21
  ToolPanelContribution,
20
22
  } from './types';
@@ -23,6 +25,18 @@ const toolPanels = new Map<string, ToolPanelContribution>();
23
25
  const pages: RegisteredPage[] = [];
24
26
  const settingsTabs: RegisteredSettingsTab[] = [];
25
27
  const navEntries: RegisteredNavEntry[] = [];
28
+ const routePanels = new Map<string, RegisteredRoutePanel>();
29
+
30
+ /**
31
+ * What the host knows about a plugin from its registry listing entry: `mounts`, the
32
+ * plugin's own route items and their resolved absolute bases (`{item: "/api/<base>"}`),
33
+ * and `declaredRoutePanels`, the channels its manifest lists under
34
+ * `contributions.route_panels`.
35
+ */
36
+ export interface PluginLoadInputs {
37
+ readonly mounts: Readonly<Record<string, string>>;
38
+ readonly declaredRoutePanels: readonly string[];
39
+ }
26
40
 
27
41
  /** The contributions one plugin stages before they commit as a batch. */
28
42
  interface StagedContributions {
@@ -31,6 +45,7 @@ interface StagedContributions {
31
45
  readonly settingsTabs: RegisteredSettingsTab[];
32
46
  readonly tabIds: Set<string>;
33
47
  readonly navEntries: RegisteredNavEntry[];
48
+ readonly routePanels: Map<string, RegisteredRoutePanel>;
34
49
  }
35
50
 
36
51
  /**
@@ -48,6 +63,7 @@ function emptyStaged(): StagedContributions {
48
63
  settingsTabs: [],
49
64
  tabIds: new Set(),
50
65
  navEntries: [],
66
+ routePanels: new Map(),
51
67
  };
52
68
  }
53
69
 
@@ -63,6 +79,7 @@ function makeStagingContext(
63
79
  pluginId: string,
64
80
  staged: StagedContributions,
65
81
  seal: Seal,
82
+ host: PluginLoadInputs,
66
83
  ): PluginContext {
67
84
  const assertOpen = (): void => {
68
85
  if (seal.closed) {
@@ -120,6 +137,16 @@ function makeStagingContext(
120
137
  }
121
138
  staged.navEntries.push({ ...contribution, pluginId });
122
139
  },
140
+ registerRoutePanel(contribution: RoutePanelContribution): void {
141
+ assertOpen();
142
+ if (staged.routePanels.has(contribution.channel) || routePanels.has(contribution.channel)) {
143
+ throw new Error(`a route panel is already registered for channel ${contribution.channel}`);
144
+ }
145
+ staged.routePanels.set(contribution.channel, { ...contribution, pluginId });
146
+ },
147
+ routeBase(itemName: string): string | null {
148
+ return Object.hasOwn(host.mounts, itemName) ? (host.mounts[itemName] ?? null) : null;
149
+ },
123
150
  };
124
151
  }
125
152
 
@@ -142,6 +169,27 @@ function assertNavEntriesHavePages(pluginId: string, staged: StagedContributions
142
169
  }
143
170
  }
144
171
 
172
+ /**
173
+ * The channels a plugin registered route panels for must be exactly the channels its
174
+ * manifest declares: a bundle and its manifest are built together, so a mismatch is a
175
+ * build fault. Throwing BEFORE any commit keeps atomicity.
176
+ */
177
+ function assertRoutePanelsDeclared(
178
+ pluginId: string,
179
+ staged: StagedContributions,
180
+ declared: readonly string[],
181
+ ): void {
182
+ const registered = [...staged.routePanels.keys()].sort();
183
+ const manifest = [...new Set(declared)].sort();
184
+ const same =
185
+ registered.length === manifest.length && registered.every((c, i) => c === manifest[i]);
186
+ if (!same) {
187
+ throw new Error(
188
+ `plugin “${pluginId}” registered route panels [${registered.join(', ')}] but its manifest declares [${manifest.join(', ')}]`,
189
+ );
190
+ }
191
+ }
192
+
145
193
  /**
146
194
  * Commit the staged contributions as one batch, so the registry only ever holds a
147
195
  * plugin's complete set — never a partial set left behind by a mid-registration failure.
@@ -151,6 +199,7 @@ function commitStaged(staged: StagedContributions): void {
151
199
  for (const [toolName, contribution] of staged.toolPanels) toolPanels.set(toolName, contribution);
152
200
  for (const tab of staged.settingsTabs) settingsTabs.push(tab);
153
201
  for (const navEntry of staged.navEntries) navEntries.push(navEntry);
202
+ for (const [channel, panel] of staged.routePanels) routePanels.set(channel, panel);
154
203
  }
155
204
 
156
205
  /**
@@ -164,10 +213,14 @@ function commitStaged(staged: StagedContributions): void {
164
213
  * during `entry`'s synchronous body — including a microtask it enqueues then — are
165
214
  * still captured and committed. A `register` that throws commits nothing.
166
215
  */
167
- export async function loadPlugin(pluginId: string, entry: PluginEntry): Promise<void> {
216
+ export async function loadPlugin(
217
+ pluginId: string,
218
+ entry: PluginEntry,
219
+ host: PluginLoadInputs,
220
+ ): Promise<void> {
168
221
  const staged = emptyStaged();
169
222
  const seal: Seal = { closed: false };
170
- const context = makeStagingContext(pluginId, staged, seal);
223
+ const context = makeStagingContext(pluginId, staged, seal, host);
171
224
 
172
225
  try {
173
226
  await entry(context);
@@ -178,12 +231,13 @@ export async function loadPlugin(pluginId: string, entry: PluginEntry): Promise<
178
231
  }
179
232
 
180
233
  assertNavEntriesHavePages(pluginId, staged);
234
+ assertRoutePanelsDeclared(pluginId, staged, host.declaredRoutePanels);
181
235
  commitStaged(staged);
182
236
  }
183
237
 
184
238
  /** The shell reads this after loading every installed plugin bundle. */
185
239
  export function getContributions(): PluginContributions {
186
- return { toolPanels, pages, settingsTabs, navEntries };
240
+ return { toolPanels, pages, settingsTabs, navEntries, routePanels };
187
241
  }
188
242
 
189
243
  /** Tests reset the module-level registry between cases. */
@@ -192,4 +246,5 @@ export function __resetContributions(): void {
192
246
  pages.length = 0;
193
247
  settingsTabs.length = 0;
194
248
  navEntries.length = 0;
249
+ routePanels.clear();
195
250
  }
@@ -2,7 +2,7 @@
2
2
  * The Studio-plugin authoring contract. A platform plugin ships an ESM bundle
3
3
  * that exports a `register(context)` entry ({@link PluginEntry}); the host calls
4
4
  * it with a {@link PluginContext} through which the plugin contributes tool
5
- * panels, pages, settings tabs, and sidebar nav entries.
5
+ * panels, pages, settings tabs, sidebar nav entries and conversation route panels.
6
6
  *
7
7
  * Two styling paths are sanctioned, and only these two:
8
8
  * 1. SDK components plus inline styles that read the design-system tokens
@@ -20,6 +20,7 @@
20
20
  * its own. Bundled third-party base CSS is exempt from rule (c) but rules
21
21
  * (a) and (b) still bind it.
22
22
  */
23
+ import type { ConversationRouteView } from '@tai42/api-client';
23
24
  import type { ComponentType } from 'react';
24
25
 
25
26
  /**
@@ -219,12 +220,43 @@ export interface RegisteredSettingsTab extends SettingsTabContribution {
219
220
  readonly pluginId: string;
220
221
  }
221
222
 
223
+ /** A conversation route row as `GET /api/conversations` lists it, on the `channel` door. */
224
+ export type ChannelConversationRoute = Extract<ConversationRouteView, { readonly door: 'channel' }>;
225
+
226
+ /** Props a contributed route panel receives. */
227
+ export interface RoutePanelProps {
228
+ readonly pluginId: string;
229
+ /** The route row the panel decorates; its `channel` is the contribution's channel. */
230
+ readonly route: ChannelConversationRoute;
231
+ }
232
+
233
+ /**
234
+ * A panel the conversations drill-in renders above the thread list of every route on
235
+ * one channel. One panel per channel deployment-wide.
236
+ */
237
+ export interface RoutePanelContribution {
238
+ /** The channel name whose routes this panel decorates (`ConversationRouteView.channel`). */
239
+ readonly channel: string;
240
+ /** Names the panel region (its aria-label and the error card label). */
241
+ readonly title: string;
242
+ readonly component: ComponentType<RoutePanelProps>;
243
+ /** Capability gate (see {@link RequiredCapabilities}); absent ⇒ full-only. */
244
+ readonly requiredCapabilities?: RequiredCapabilities;
245
+ }
246
+
247
+ /** A route panel as stored in the registry, stamped with the plugin that registered it. */
248
+ export interface RegisteredRoutePanel extends RoutePanelContribution {
249
+ readonly pluginId: string;
250
+ }
251
+
222
252
  /** Everything a single plugin bundle has registered at import time. */
223
253
  export interface PluginContributions {
224
254
  readonly toolPanels: ReadonlyMap<string, ToolPanelContribution>;
225
255
  readonly pages: readonly RegisteredPage[];
226
256
  readonly settingsTabs: readonly RegisteredSettingsTab[];
227
257
  readonly navEntries: readonly RegisteredNavEntry[];
258
+ /** Route panels keyed by the channel they decorate. */
259
+ readonly routePanels: ReadonlyMap<string, RegisteredRoutePanel>;
228
260
  }
229
261
 
230
262
  /**
@@ -250,6 +282,18 @@ export interface PluginContext {
250
282
  * registers (a nav entry with no page is a dead link and is rejected loudly).
251
283
  */
252
284
  registerNavEntry(contribution: NavEntryContribution): void;
285
+ /**
286
+ * Register a panel for the routes of one channel. The plugin's manifest must list the
287
+ * same channels under `contributions.route_panels`, or the plugin fails to load.
288
+ */
289
+ registerRoutePanel(contribution: RoutePanelContribution): void;
290
+ /**
291
+ * The resolved absolute base of one of this plugin's route items, e.g. `/api/channels/web`,
292
+ * or `null` when the item has no mount on this deployment (an optional router not listed, a
293
+ * channel not loaded). Never throws; a feature that needs the base shows its own unavailable
294
+ * state for `null`. Call it during `register` and keep the value.
295
+ */
296
+ routeBase(itemName: string): string | null;
253
297
  }
254
298
 
255
299
  /**
@@ -9,12 +9,14 @@ it; holds no edge to jq (the jq fields render through the host's ambient
9
9
 
10
10
  - **template `input` jq** — input is the record (`.`), declared params bound as
11
11
  `$params`; returns the injected value.
12
- - **custom injection jq** — over `{ record, input }`; returns the value placed at `into`.
13
- - **adapter** — over `{ output, input }`; CONSTRUCTS the update jq's declared input
14
- object.
15
- - **template `update` jq** — over `{ record, input }` where `input` is the adapter's
16
- output; returns a template-relative op batch.
17
- - **custom `update` jq** — over `{ record, output, input }`; returns `[{op, path, value}]`.
12
+ - **custom injection jq** — over the record (`.`), the run input bound as `$input`; returns
13
+ the value placed at `into`.
14
+ - **adapter** — over the tool output (`.`), the run input bound as `$input`; CONSTRUCTS the
15
+ update jq's declared input object.
16
+ - **template `update` jq** — over the record subtree (`.`), the adapter's output bound as
17
+ `$input`; returns a template-relative op batch.
18
+ - **custom `update` jq** — over the tool output (`.`), the run input bound as `$input` and the
19
+ record as `$record`; returns `[{op, path, value}]`.
18
20
  - **`subject_expr`** — a jq yielding the record KEY: a bare key string, or a full subject object.
19
21
  - **`scope_expr`** — optional; a boolean jq predicate. When it is false, the state is skipped for the run.
20
22
 
@@ -28,7 +30,8 @@ canonical, parseable object so a stored adapter reopens as the form it was autho
28
30
  ```
29
31
 
30
32
  - `<key>` — a bare identifier, or a JSON string when it is not one.
31
- - `<value>` — one of: a field path (`.output…` / `.input…`), a JSON literal, or a raw
33
+ - `<value>` — one of: a field path (an output field `.…`, the whole output `.`, an input
34
+ field `$input…`), a JSON literal, or a raw
32
35
  jq expression, each wrapped in `(…)`.
33
36
 
34
37
  `parseAdapter` inverts it: `parseAdapter(compileAdapter(rows).jq) === rows` for every
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * The update list of one attached state. Each update applies AFTER the run: either
3
3
  * a template `update` jq (picked by name, whose declared input is authored through
4
- * the adapter mapping) or a custom jq over `{ record, output, input }` that authors
5
- * the whole op batch itself. An optional op-id expression carries idempotency.
4
+ * the adapter mapping) or a custom jq over the tool output (its `.`, with the run input
5
+ * bound as `$input` and the record as `$record`) that authors the whole op batch itself. An optional op-id expression carries idempotency.
6
6
  */
7
7
  import type { ReactNode } from 'react';
8
8
 
@@ -114,7 +114,7 @@ function UpdateRowEditor({
114
114
  <BindingTemplatedJqField
115
115
  label={`Custom update jq ${String(index + 1)}`}
116
116
  required
117
- description="A jq over `{ record, output, input }` returning a template-relative op batch."
117
+ description="A jq over the tool output (`.`), with `$input` and `$record` bound, returning a template-relative op batch."
118
118
  value={update.jq}
119
119
  onChange={(next) => {
120
120
  onChange({
@@ -3,9 +3,9 @@
3
3
  *
4
4
  * A named template `update` jq declares an input object (its `params` / declared
5
5
  * input keys). A binding must map the run's OUTPUT (and the run's INPUT) into that
6
- * object; that mapping is the `adapter` — a single jq program over `{ output, input }`
7
- * that CONSTRUCTS the update jq's declared input (which the update jq then reads as
8
- * `.input` alongside the record).
6
+ * object; that mapping is the `adapter` — a single jq program over the tool output
7
+ * (its `.`) with the run input bound as `$input`, that CONSTRUCTS the update jq's
8
+ * declared input (which the update jq then reads as `$input`, over the record).
9
9
  *
10
10
  * The editor authors the adapter as a ROW form: one row per declared input key,
11
11
  * each row picking one of three value sources — a picked field (from the run's
@@ -17,10 +17,10 @@
17
17
  * object.
18
18
  */
19
19
 
20
- /** Which root of `{ output, input }` a picked field reads. */
20
+ /** Which value a picked field reads: the tool output (the program's `.`) or the run input (`$input`). */
21
21
  export type FieldRoot = 'output' | 'input';
22
22
 
23
- /** A picked field: a path into `output` or `input`. */
23
+ /** A picked field: a path into the tool output or the run input. */
24
24
  export interface FieldSource {
25
25
  readonly kind: 'field';
26
26
  readonly root: FieldRoot;
@@ -33,7 +33,7 @@ export interface LiteralSource {
33
33
  readonly json: string;
34
34
  }
35
35
 
36
- /** A raw jq expression over `{ output, input }`. */
36
+ /** A raw jq expression over the tool output (its `.`), with the run input bound as `$input`. */
37
37
  export interface JqSource {
38
38
  readonly kind: 'jq';
39
39
  readonly expr: string;
@@ -59,13 +59,19 @@ export function jqKey(key: string): string {
59
59
  return IDENTIFIER.test(key) ? key : JSON.stringify(key);
60
60
  }
61
61
 
62
- /** A picked field rendered as a jq path, e.g. `.output.user.name` / `.input["odd key"]`. */
62
+ /**
63
+ * A picked field rendered as a jq path: an output field reads off the program root
64
+ * (`.user.name`, `.["odd key"]`, the whole output `.`), an input field off the bound run
65
+ * input (`$input.user.name`, the whole input `$input`).
66
+ */
63
67
  export function fieldPathToJq(root: FieldRoot, path: readonly string[]): string {
64
- let out = `.${root}`;
68
+ let out = root === 'input' ? '$input' : '';
65
69
  for (const segment of path) {
66
- out += IDENTIFIER.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`;
70
+ out += IDENTIFIER.test(segment)
71
+ ? `.${segment}`
72
+ : `${out === '' ? '.' : ''}[${JSON.stringify(segment)}]`;
67
73
  }
68
- return out;
74
+ return out === '' ? '.' : out;
69
75
  }
70
76
 
71
77
  /** The jq fragment ONE row's value compiles to — the row's "show jq". */
@@ -95,7 +101,7 @@ export function rowValueJq(source: MappingSource): AdapterCompileResult {
95
101
 
96
102
  /**
97
103
  * Compile a mapping form into ONE adapter jq that constructs the update jq's
98
- * declared input over `{ output, input }`. An empty form compiles to the
104
+ * declared input over the tool output and `$input`. An empty form compiles to the
99
105
  * empty object `{}`; a blank target, a duplicate target, or an invalid source is a
100
106
  * loud error.
101
107
  */
@@ -128,7 +134,7 @@ export function defaultRowsForInput(params: readonly string[]): MappingRow[] {
128
134
  // `compileAdapter` emits a canonical, PARSEABLE shape so a stored adapter reopens as
129
135
  // the form it was authored in (never UI-only state): the object
130
136
  // `{ <key>: (<value>), … }` where `<key>` is a bare identifier or a JSON string and
131
- // `<value>` is one of — a field path `.output…` / `.input…`, a JSON literal, or a raw
137
+ // `<value>` is one of — a field path `.…` (output) / `$input…` (input), a JSON literal, or a raw
132
138
  // jq expression. `parseAdapter` inverts it; `parse(compileAdapter(rows).jq) === rows`
133
139
  // for every row kind whose literal is already canonical JSON. A shape it does not
134
140
  // recognise returns `null` — the editor falls back to the raw-jq escape hatch.
@@ -139,10 +145,19 @@ const ADAPTER_KEY = /^("(?:[^"\\]|\\.)*"|[A-Za-z_][A-Za-z0-9_]*)\s*:\s*\(/;
139
145
 
140
146
  /** Recover a field source from a pure path expression, or `null` if it is not one. */
141
147
  export function parseFieldPath(value: string): FieldSource | null {
142
- const head = /^\.(output|input)/.exec(value);
143
- if (head === null) return null;
144
- const root = head[1] as FieldRoot;
145
- let rest = value.slice(head[0].length);
148
+ let root: FieldRoot;
149
+ let rest: string;
150
+ if (value === '.') return { kind: 'field', root: 'output', path: [] };
151
+ if (value.startsWith('$input')) {
152
+ root = 'input';
153
+ rest = value.slice('$input'.length);
154
+ } else if (value.startsWith('.')) {
155
+ root = 'output';
156
+ // A root bracket (`.["odd key"]`) drops its leading dot; a root key keeps it (`.a`).
157
+ rest = value.startsWith('.[') ? value.slice(1) : value;
158
+ } else {
159
+ return null;
160
+ }
146
161
  const path: string[] = [];
147
162
  while (rest.length > 0) {
148
163
  if (rest.startsWith('.')) {