@skaile/forge-app-actions 0.0.0-stage → 0.2.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 (73) hide show
  1. package/README.md +61 -2
  2. package/dist/module.d.mts +23 -0
  3. package/dist/module.json +9 -0
  4. package/dist/module.mjs +37 -0
  5. package/dist/runtime/chat/index.d.ts +52 -0
  6. package/dist/runtime/chat/index.js +100 -0
  7. package/dist/runtime/chat/server.d.ts +36 -0
  8. package/dist/runtime/chat/server.js +27 -0
  9. package/dist/runtime/chat/types.d.ts +10 -0
  10. package/dist/runtime/chat/types.js +0 -0
  11. package/dist/runtime/client-capabilities.d.ts +29 -0
  12. package/dist/runtime/client-capabilities.js +25 -0
  13. package/dist/runtime/components-nuxt-ui/ForgeCapabilityApproval.d.vue.ts +2 -0
  14. package/dist/runtime/components-nuxt-ui/ForgeCapabilityApproval.vue +125 -0
  15. package/dist/runtime/components-nuxt-ui/ForgeCapabilityApproval.vue.d.ts +2 -0
  16. package/dist/runtime/components-nuxt-ui/ForgeCommandPalette.d.vue.ts +2 -0
  17. package/dist/runtime/components-nuxt-ui/ForgeCommandPalette.vue +218 -0
  18. package/dist/runtime/components-nuxt-ui/ForgeCommandPalette.vue.d.ts +2 -0
  19. package/dist/runtime/composables/defineClientCapability.d.ts +64 -0
  20. package/dist/runtime/composables/defineClientCapability.js +40 -0
  21. package/dist/runtime/composables/defineForgeAction.d.ts +82 -0
  22. package/dist/runtime/composables/defineForgeAction.js +147 -0
  23. package/dist/runtime/composables/useAgentCapabilities.d.ts +78 -0
  24. package/dist/runtime/composables/useAgentCapabilities.js +94 -0
  25. package/dist/runtime/composables/useCapabilityApproval.d.ts +109 -0
  26. package/dist/runtime/composables/useCapabilityApproval.js +74 -0
  27. package/dist/runtime/composables/useCapabilityInvocations.d.ts +150 -0
  28. package/dist/runtime/composables/useCapabilityInvocations.js +118 -0
  29. package/dist/runtime/composables/useCommandPalette.d.ts +28 -0
  30. package/dist/runtime/composables/useCommandPalette.js +49 -0
  31. package/dist/runtime/composables/usePaletteArgStep.d.ts +47 -0
  32. package/dist/runtime/composables/usePaletteArgStep.js +51 -0
  33. package/dist/runtime/internal/describe-error.d.ts +10 -0
  34. package/dist/runtime/internal/describe-error.js +12 -0
  35. package/dist/runtime/server/index.d.ts +7 -0
  36. package/dist/runtime/server/index.js +2 -0
  37. package/dist/runtime/server/utils/capability-registry.d.ts +35 -0
  38. package/dist/runtime/server/utils/capability-registry.js +24 -0
  39. package/dist/runtime/server/utils/client-capability-bridge.d.ts +647 -0
  40. package/dist/runtime/server/utils/client-capability-bridge.js +755 -0
  41. package/dist/runtime/shims-vue.d.ts +8 -0
  42. package/dist/runtime/types/client-capability.d.ts +159 -0
  43. package/dist/runtime/types/client-capability.js +0 -0
  44. package/dist/runtime/types/command-palette.d.ts +75 -0
  45. package/dist/runtime/types/command-palette.js +0 -0
  46. package/dist/runtime/types/forge-action.d.ts +152 -0
  47. package/dist/runtime/types/forge-action.js +0 -0
  48. package/dist/runtime/types/index.d.ts +3 -0
  49. package/dist/runtime/types/index.js +0 -0
  50. package/dist/types.d.mts +9 -0
  51. package/package.json +94 -4
  52. package/src/runtime/chat/index.ts +190 -0
  53. package/src/runtime/chat/server.ts +64 -0
  54. package/src/runtime/chat/types.ts +13 -0
  55. package/src/runtime/client-capabilities.ts +82 -0
  56. package/src/runtime/components-nuxt-ui/ForgeCapabilityApproval.vue +240 -0
  57. package/src/runtime/components-nuxt-ui/ForgeCommandPalette.vue +363 -0
  58. package/src/runtime/composables/defineClientCapability.ts +119 -0
  59. package/src/runtime/composables/defineForgeAction.ts +278 -0
  60. package/src/runtime/composables/useAgentCapabilities.ts +215 -0
  61. package/src/runtime/composables/useCapabilityApproval.ts +219 -0
  62. package/src/runtime/composables/useCapabilityInvocations.ts +325 -0
  63. package/src/runtime/composables/useCommandPalette.ts +97 -0
  64. package/src/runtime/composables/usePaletteArgStep.ts +110 -0
  65. package/src/runtime/internal/describe-error.ts +21 -0
  66. package/src/runtime/server/index.ts +8 -0
  67. package/src/runtime/server/utils/capability-registry.ts +66 -0
  68. package/src/runtime/server/utils/client-capability-bridge.ts +1201 -0
  69. package/src/runtime/shims-vue.d.ts +8 -0
  70. package/src/runtime/types/client-capability.ts +178 -0
  71. package/src/runtime/types/command-palette.ts +78 -0
  72. package/src/runtime/types/forge-action.ts +166 -0
  73. package/src/runtime/types/index.ts +24 -0
@@ -0,0 +1,363 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * ForgeCommandPalette — global Cmd+K / Ctrl+K command palette, built on
4
+ * UModal (backdrop) + UInput (search).
5
+ *
6
+ * No props, no emits. All state lives in the useCommandPalette singleton.
7
+ * Mount once in app.vue (outside any auth gate).
8
+ */
9
+ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
10
+ import { useCommandPalette } from "../composables/useCommandPalette";
11
+ import { usePaletteArgStep } from "../composables/usePaletteArgStep";
12
+ import type { PaletteAction } from "../types/command-palette";
13
+
14
+ const { queryActions, isOpen, open, close } = useCommandPalette();
15
+
16
+ // ── Search input ───────────────────────────────────────────────────────────
17
+ const search = ref("");
18
+ const selectedIndex = ref(0);
19
+ const searchInputRef = ref<{ inputRef?: HTMLInputElement } | null>(null);
20
+
21
+ // ── Argument step ──────────────────────────────────────────────────────────
22
+ // An action that declares `arg` needs one string before it can run. Selecting
23
+ // it swaps the list for its candidates and waits for a second Enter; Escape
24
+ // steps back to the action list rather than closing the palette.
25
+ const argStep = usePaletteArgStep();
26
+
27
+ // ── Filtered results ───────────────────────────────────────────────────────
28
+ const filteredActions = computed(() => (argStep.active.value ? [] : queryActions(search.value)));
29
+
30
+ /** Rows currently rendered — actions, or the pending action's candidates. */
31
+ const rowCount = computed(() =>
32
+ argStep.active.value ? argStep.options.value.length : filteredActions.value.length,
33
+ );
34
+
35
+ // Reset selection when results change
36
+ watch(filteredActions, () => {
37
+ selectedIndex.value = 0;
38
+ });
39
+
40
+ // Typing during the argument step re-queries the action's candidates.
41
+ watch(search, (value) => {
42
+ if (!argStep.active.value) return;
43
+ selectedIndex.value = 0;
44
+ void argStep.query(value);
45
+ });
46
+
47
+ // Reset search when dialog closes
48
+ watch(isOpen, (open) => {
49
+ if (!open) {
50
+ search.value = "";
51
+ selectedIndex.value = 0;
52
+ argStep.cancel();
53
+ } else {
54
+ // Focus input after dialog opens (guard: stub / no-op in tests)
55
+ nextTick(() => focusSearch());
56
+ }
57
+ });
58
+
59
+ /** Focus the palette input. Guarded: a stubbed ref in tests is a no-op. */
60
+ function focusSearch() {
61
+ searchInputRef.value?.inputRef?.focus();
62
+ }
63
+
64
+ // ── Group headers ──────────────────────────────────────────────────────────
65
+ function getGroupHeader(index: number): string | null {
66
+ const action = filteredActions.value[index];
67
+ if (!action?.group) return null;
68
+ if (index === 0) return action.group;
69
+ const prev = filteredActions.value[index - 1];
70
+ if (prev?.group !== action.group) return action.group;
71
+ return null;
72
+ }
73
+
74
+ // ── Keyboard navigation ────────────────────────────────────────────────────
75
+ function handleDialogKeydown(e: KeyboardEvent) {
76
+ // Escape and Enter must work even with an empty list: Escape backs out of the
77
+ // argument step, and a free-text argument has no rows to highlight.
78
+ if (e.key === "Escape") {
79
+ e.preventDefault();
80
+ if (argStep.active.value) {
81
+ argStep.cancel();
82
+ search.value = "";
83
+ selectedIndex.value = 0;
84
+ return;
85
+ }
86
+ close();
87
+ return;
88
+ }
89
+
90
+ const count = rowCount.value;
91
+
92
+ if (e.key === "ArrowDown") {
93
+ if (count === 0) return;
94
+ e.preventDefault();
95
+ selectedIndex.value = (selectedIndex.value + 1) % count;
96
+ } else if (e.key === "ArrowUp") {
97
+ if (count === 0) return;
98
+ e.preventDefault();
99
+ selectedIndex.value = (selectedIndex.value - 1 + count) % count;
100
+ } else if (e.key === "Enter") {
101
+ e.preventDefault();
102
+ if (argStep.active.value) {
103
+ void submitArg();
104
+ } else {
105
+ void selectAction(filteredActions.value[selectedIndex.value]);
106
+ }
107
+ }
108
+ }
109
+
110
+ /**
111
+ * A thrown action must not become an unhandled rejection — the palette has
112
+ * already closed by then and there is nothing left to show it on.
113
+ */
114
+ async function runAction(action: PaletteAction, value?: string) {
115
+ try {
116
+ await action.handler(value);
117
+ } catch (err) {
118
+ console.error(`[ForgeCommandPalette] action "${action.id}" failed:`, err);
119
+ }
120
+ }
121
+
122
+ async function selectAction(action: PaletteAction | undefined) {
123
+ if (!action) return;
124
+ // An action that needs an argument keeps the palette open for the second step.
125
+ if (argStep.begin(action)) {
126
+ search.value = "";
127
+ selectedIndex.value = 0;
128
+ nextTick(() => focusSearch());
129
+ return;
130
+ }
131
+ close();
132
+ await runAction(action);
133
+ }
134
+
135
+ /** Complete the argument step with the highlighted row or the typed value. */
136
+ async function submitArg() {
137
+ const action = argStep.action.value;
138
+ if (!action) return;
139
+ const value = argStep.valueFor(search.value, selectedIndex.value);
140
+ if (value === null) return; // nothing to run with yet
141
+ argStep.cancel();
142
+ close();
143
+ await runAction(action, value);
144
+ }
145
+
146
+ /** Click on a candidate row during the argument step. */
147
+ async function selectArgOption(index: number) {
148
+ selectedIndex.value = index;
149
+ await submitArg();
150
+ }
151
+
152
+ // ── Global Cmd+K shortcut ──────────────────────────────────────────────────
153
+ function handleGlobalKeydown(e: KeyboardEvent) {
154
+ if ((e.metaKey || e.ctrlKey) && e.key === "k") {
155
+ e.preventDefault();
156
+ open();
157
+ }
158
+ }
159
+
160
+ onMounted(() => {
161
+ document.addEventListener("keydown", handleGlobalKeydown);
162
+ });
163
+
164
+ onUnmounted(() => {
165
+ document.removeEventListener("keydown", handleGlobalKeydown);
166
+ });
167
+ </script>
168
+
169
+ <template>
170
+ <!-- Nuxt UI Modal as the palette backdrop. #content gives full control of the
171
+ panel (no default header/footer). -->
172
+ <UModal
173
+ :open="isOpen"
174
+ :ui="{ content: 'max-w-[560px] w-[95vw] p-0' }"
175
+ @update:open="(v: boolean) => !v && close()"
176
+ >
177
+ <template #content>
178
+ <div class="forge-command-palette" @keydown="handleDialogKeydown">
179
+ <div v-if="argStep.active.value" class="forge-command-palette__breadcrumb">
180
+ {{ argStep.prompt.value?.label ?? argStep.action.value?.label }}
181
+ </div>
182
+
183
+ <!-- Search input -->
184
+ <div class="forge-command-palette__search">
185
+ <UInput
186
+ ref="searchInputRef"
187
+ v-model="search"
188
+ :placeholder="argStep.active.value
189
+ ? (argStep.prompt.value?.placeholder ?? 'Type a value…')
190
+ : 'Search commands…'"
191
+ variant="none"
192
+ autofocus
193
+ :ui="{ root: 'w-full' }"
194
+ class="forge-command-palette__input"
195
+ />
196
+ </div>
197
+
198
+ <!-- Results list -->
199
+ <div class="forge-command-palette__list" role="listbox">
200
+ <!-- Argument step: the pending action's candidates -->
201
+ <template v-if="argStep.active.value">
202
+ <div v-if="argStep.options.value.length === 0" class="forge-command-palette__empty">
203
+ {{ argStep.loading.value ? "Searching…" : "Type a value and press Enter" }}
204
+ </div>
205
+ <button
206
+ v-for="(option, index) in argStep.options.value"
207
+ :key="option.value"
208
+ type="button"
209
+ class="forge-command-palette__item"
210
+ :class="{ 'forge-command-palette__item--selected': index === selectedIndex }"
211
+ role="option"
212
+ :aria-selected="index === selectedIndex"
213
+ @click="selectArgOption(index)"
214
+ @mouseenter="selectedIndex = index"
215
+ >
216
+ <span v-if="option.icon" class="forge-command-palette__icon">
217
+ <Icon :name="option.icon" />
218
+ </span>
219
+ <span class="forge-command-palette__text">
220
+ <span class="forge-command-palette__label">{{ option.label }}</span>
221
+ <span
222
+ v-if="option.description"
223
+ class="forge-command-palette__description"
224
+ >{{ option.description }}</span>
225
+ </span>
226
+ </button>
227
+ </template>
228
+
229
+ <template v-if="!argStep.active.value && filteredActions.length === 0">
230
+ <div class="forge-command-palette__empty">No commands found</div>
231
+ </template>
232
+
233
+ <template v-for="(action, index) in filteredActions" :key="action.id">
234
+ <!-- Group header -->
235
+ <div
236
+ v-if="getGroupHeader(index)"
237
+ class="forge-command-palette__group"
238
+ >
239
+ {{ getGroupHeader(index) }}
240
+ </div>
241
+
242
+ <!-- Action row -->
243
+ <button
244
+ type="button"
245
+ class="forge-command-palette__item"
246
+ :class="{ 'forge-command-palette__item--selected': index === selectedIndex }"
247
+ role="option"
248
+ :aria-selected="index === selectedIndex"
249
+ @click="selectAction(action)"
250
+ @mouseenter="selectedIndex = index"
251
+ >
252
+ <span v-if="action.icon" class="forge-command-palette__icon">
253
+ <Icon :name="action.icon" />
254
+ </span>
255
+ <span class="forge-command-palette__text">
256
+ <span class="forge-command-palette__label">{{ action.label }}</span>
257
+ <span
258
+ v-if="action.description"
259
+ class="forge-command-palette__description"
260
+ >{{ action.description }}</span>
261
+ </span>
262
+ </button>
263
+ </template>
264
+ </div>
265
+ </div>
266
+ </template>
267
+ </UModal>
268
+ </template>
269
+
270
+ <style scoped>
271
+ .forge-command-palette__breadcrumb {
272
+ padding: 10px 12px 0;
273
+ font-size: 0.75rem;
274
+ font-weight: 600;
275
+ color: #64748b;
276
+ }
277
+
278
+ .forge-command-palette {
279
+ display: flex;
280
+ flex-direction: column;
281
+ overflow: hidden;
282
+ border-radius: 8px;
283
+ }
284
+
285
+ .forge-command-palette__search {
286
+ padding: 12px;
287
+ border-bottom: 1px solid #e2e8f0;
288
+ }
289
+
290
+ .forge-command-palette__input {
291
+ width: 100%;
292
+ font-size: 0.95rem;
293
+ }
294
+
295
+ .forge-command-palette__list {
296
+ max-height: 360px;
297
+ overflow-y: auto;
298
+ padding: 6px;
299
+ }
300
+
301
+ .forge-command-palette__empty {
302
+ padding: 16px;
303
+ text-align: center;
304
+ color: #94a3b8;
305
+ font-size: 0.875rem;
306
+ }
307
+
308
+ .forge-command-palette__group {
309
+ padding: 4px 8px 2px;
310
+ font-size: 0.7rem;
311
+ font-weight: 700;
312
+ text-transform: uppercase;
313
+ letter-spacing: 0.08em;
314
+ color: #94a3b8;
315
+ margin-top: 4px;
316
+ }
317
+
318
+ .forge-command-palette__item {
319
+ display: flex;
320
+ align-items: center;
321
+ gap: 10px;
322
+ width: 100%;
323
+ padding: 8px 10px;
324
+ border: none;
325
+ border-radius: 6px;
326
+ background: transparent;
327
+ cursor: pointer;
328
+ text-align: left;
329
+ font: inherit;
330
+ color: inherit;
331
+ transition: background 0.1s ease;
332
+ }
333
+
334
+ .forge-command-palette__item--selected {
335
+ background: #f1f5f9;
336
+ }
337
+
338
+ .forge-command-palette__icon {
339
+ width: 20px;
340
+ flex-shrink: 0;
341
+ color: #64748b;
342
+ }
343
+
344
+ .forge-command-palette__text {
345
+ display: flex;
346
+ flex-direction: column;
347
+ gap: 1px;
348
+ min-width: 0;
349
+ }
350
+
351
+ .forge-command-palette__label {
352
+ font-size: 0.875rem;
353
+ font-weight: 500;
354
+ }
355
+
356
+ .forge-command-palette__description {
357
+ font-size: 0.775rem;
358
+ color: #94a3b8;
359
+ white-space: nowrap;
360
+ overflow: hidden;
361
+ text-overflow: ellipsis;
362
+ }
363
+ </style>
@@ -0,0 +1,119 @@
1
+ /**
2
+ * `defineClientCapability` — authoring helper for a browser-side agent
3
+ * capability. Mirrors `@skaile/workspaces/app-sdk`'s `defineAppCapability`
4
+ * field-for-field; see `../types/client-capability` for the two deliberate
5
+ * divergences (`side`, `origin`).
6
+ *
7
+ * Auto-imported in consumer apps via the module's `addImportsDir` on
8
+ * `runtime/composables`. Also reachable explicitly through the
9
+ * `@skaile/forge-app-actions/client-capabilities` subpath.
10
+ */
11
+
12
+ import * as z from "zod";
13
+ import type {
14
+ ClientCapabilityContext,
15
+ DefineClientCapabilityArgs,
16
+ DefinedClientCapability,
17
+ } from "../types/client-capability";
18
+
19
+ /**
20
+ * Build a {@link DefinedClientCapability} from author-friendly Zod-typed args.
21
+ *
22
+ * The wire `inputSchema` / `outputSchema` are **derived** from the Zod schemas
23
+ * via `z.toJSONSchema()` (zod v4 only) — they are never hand-written, which is
24
+ * what buys the validation the raw-JSON-Schema capabilities never had.
25
+ *
26
+ * `side` is fixed to `"client"`. `origin` is stamped by `ClientCapabilityBridge`
27
+ * when the announcement reaches Nitro.
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * export const openFile = defineClientCapability({
32
+ * name: "app.workspace.open_file",
33
+ * displayName: "Open file in viewer",
34
+ * description: "Open a file in the user's file viewer.",
35
+ * kind: "effect",
36
+ * audience: ["llm", "user"],
37
+ * input: z.object({ path: z.string().min(1), line: z.number().optional() }),
38
+ * output: z.object({ opened: z.boolean(), path: z.string() }),
39
+ * handler: async ({ path, line }) => ({ opened: await openInViewer(path, line), path }),
40
+ * })
41
+ * ```
42
+ */
43
+ export function defineClientCapability<
44
+ T extends z.ZodType,
45
+ R extends z.ZodType | undefined = undefined,
46
+ >(args: DefineClientCapabilityArgs<T, R>): DefinedClientCapability {
47
+ const inputSchema = z.toJSONSchema(args.input, {
48
+ target: "draft-07",
49
+ io: "input",
50
+ }) as Record<string, unknown>;
51
+
52
+ const outputSchema = args.output
53
+ ? (z.toJSONSchema(args.output, {
54
+ target: "draft-07",
55
+ io: "output",
56
+ }) as Record<string, unknown>)
57
+ : undefined;
58
+
59
+ return {
60
+ name: args.name,
61
+ displayName: args.displayName,
62
+ description: args.description,
63
+ inputSchema,
64
+ outputSchema,
65
+ side: "client",
66
+ scope: args.scope,
67
+ requiresApproval: args.requiresApproval,
68
+ kind: args.kind,
69
+ audience: args.audience,
70
+ promptFragment: args.promptFragment,
71
+ callTimeoutMs: args.callTimeoutMs,
72
+ render: args.render,
73
+ inputZod: args.input,
74
+ outputZod: args.output,
75
+ handler: args.handler as (args: unknown, ctx: ClientCapabilityContext) => Promise<unknown>,
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Register an old capability name as a **deprecated alias** of a defined one.
81
+ *
82
+ * Renaming a capability is not free: `promptFragment` text names the old name
83
+ * literally, and agent definitions in the wild may too (spec §2.2). The alias is
84
+ * a second registry entry — same `inputZod`, same `outputZod`, same handler
85
+ * *instance* — so an invocation of either name runs exactly the same code, and
86
+ * the alias needs no separate maintenance.
87
+ *
88
+ * Two deliberate differences from the capability it aliases:
89
+ * - `audience` is narrowed to `["llm"]`. The alias exists only for callers that
90
+ * already know the old name; a deprecated duplicate has no business appearing
91
+ * in a user-facing surface like Cmd+K.
92
+ * - `description` and `promptFragment` are prefixed with a deprecation notice
93
+ * naming the replacement, because the wire `Capability` type has no
94
+ * `deprecated` flag — the model reads prose or it reads nothing.
95
+ *
96
+ * @param capability the current definition, which owns the handler
97
+ * @param name the old name to keep answering
98
+ *
99
+ * @example
100
+ * ```ts
101
+ * export const openFile = defineClientCapability({ name: "app.workspace.open_file", … })
102
+ * export const openFileAlias = deprecatedClientCapabilityAlias(openFile, "forge.open_file")
103
+ * ```
104
+ */
105
+ export function deprecatedClientCapabilityAlias(
106
+ capability: DefinedClientCapability,
107
+ name: string,
108
+ ): DefinedClientCapability {
109
+ const notice = `DEPRECATED: use \`${capability.name}\` instead. This name still works but will be removed.`;
110
+ return {
111
+ ...capability,
112
+ name,
113
+ description: `${notice} ${capability.description}`,
114
+ audience: ["llm"],
115
+ promptFragment: capability.promptFragment
116
+ ? `${notice}\n${capability.promptFragment}`
117
+ : undefined,
118
+ };
119
+ }