@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.
- package/README.md +61 -2
- package/dist/module.d.mts +23 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +37 -0
- package/dist/runtime/chat/index.d.ts +52 -0
- package/dist/runtime/chat/index.js +100 -0
- package/dist/runtime/chat/server.d.ts +36 -0
- package/dist/runtime/chat/server.js +27 -0
- package/dist/runtime/chat/types.d.ts +10 -0
- package/dist/runtime/chat/types.js +0 -0
- package/dist/runtime/client-capabilities.d.ts +29 -0
- package/dist/runtime/client-capabilities.js +25 -0
- package/dist/runtime/components-nuxt-ui/ForgeCapabilityApproval.d.vue.ts +2 -0
- package/dist/runtime/components-nuxt-ui/ForgeCapabilityApproval.vue +125 -0
- package/dist/runtime/components-nuxt-ui/ForgeCapabilityApproval.vue.d.ts +2 -0
- package/dist/runtime/components-nuxt-ui/ForgeCommandPalette.d.vue.ts +2 -0
- package/dist/runtime/components-nuxt-ui/ForgeCommandPalette.vue +218 -0
- package/dist/runtime/components-nuxt-ui/ForgeCommandPalette.vue.d.ts +2 -0
- package/dist/runtime/composables/defineClientCapability.d.ts +64 -0
- package/dist/runtime/composables/defineClientCapability.js +40 -0
- package/dist/runtime/composables/defineForgeAction.d.ts +82 -0
- package/dist/runtime/composables/defineForgeAction.js +147 -0
- package/dist/runtime/composables/useAgentCapabilities.d.ts +78 -0
- package/dist/runtime/composables/useAgentCapabilities.js +94 -0
- package/dist/runtime/composables/useCapabilityApproval.d.ts +109 -0
- package/dist/runtime/composables/useCapabilityApproval.js +74 -0
- package/dist/runtime/composables/useCapabilityInvocations.d.ts +150 -0
- package/dist/runtime/composables/useCapabilityInvocations.js +118 -0
- package/dist/runtime/composables/useCommandPalette.d.ts +28 -0
- package/dist/runtime/composables/useCommandPalette.js +49 -0
- package/dist/runtime/composables/usePaletteArgStep.d.ts +47 -0
- package/dist/runtime/composables/usePaletteArgStep.js +51 -0
- package/dist/runtime/internal/describe-error.d.ts +10 -0
- package/dist/runtime/internal/describe-error.js +12 -0
- package/dist/runtime/server/index.d.ts +7 -0
- package/dist/runtime/server/index.js +2 -0
- package/dist/runtime/server/utils/capability-registry.d.ts +35 -0
- package/dist/runtime/server/utils/capability-registry.js +24 -0
- package/dist/runtime/server/utils/client-capability-bridge.d.ts +647 -0
- package/dist/runtime/server/utils/client-capability-bridge.js +755 -0
- package/dist/runtime/shims-vue.d.ts +8 -0
- package/dist/runtime/types/client-capability.d.ts +159 -0
- package/dist/runtime/types/client-capability.js +0 -0
- package/dist/runtime/types/command-palette.d.ts +75 -0
- package/dist/runtime/types/command-palette.js +0 -0
- package/dist/runtime/types/forge-action.d.ts +152 -0
- package/dist/runtime/types/forge-action.js +0 -0
- package/dist/runtime/types/index.d.ts +3 -0
- package/dist/runtime/types/index.js +0 -0
- package/dist/types.d.mts +9 -0
- package/package.json +94 -4
- package/src/runtime/chat/index.ts +190 -0
- package/src/runtime/chat/server.ts +64 -0
- package/src/runtime/chat/types.ts +13 -0
- package/src/runtime/client-capabilities.ts +82 -0
- package/src/runtime/components-nuxt-ui/ForgeCapabilityApproval.vue +240 -0
- package/src/runtime/components-nuxt-ui/ForgeCommandPalette.vue +363 -0
- package/src/runtime/composables/defineClientCapability.ts +119 -0
- package/src/runtime/composables/defineForgeAction.ts +278 -0
- package/src/runtime/composables/useAgentCapabilities.ts +215 -0
- package/src/runtime/composables/useCapabilityApproval.ts +219 -0
- package/src/runtime/composables/useCapabilityInvocations.ts +325 -0
- package/src/runtime/composables/useCommandPalette.ts +97 -0
- package/src/runtime/composables/usePaletteArgStep.ts +110 -0
- package/src/runtime/internal/describe-error.ts +21 -0
- package/src/runtime/server/index.ts +8 -0
- package/src/runtime/server/utils/capability-registry.ts +66 -0
- package/src/runtime/server/utils/client-capability-bridge.ts +1201 -0
- package/src/runtime/shims-vue.d.ts +8 -0
- package/src/runtime/types/client-capability.ts +178 -0
- package/src/runtime/types/command-palette.ts +78 -0
- package/src/runtime/types/forge-action.ts +166 -0
- 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
|
+
}
|