@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,218 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
|
3
|
+
import { useCommandPalette } from "../composables/useCommandPalette";
|
|
4
|
+
import { usePaletteArgStep } from "../composables/usePaletteArgStep";
|
|
5
|
+
const { queryActions, isOpen, open, close } = useCommandPalette();
|
|
6
|
+
const search = ref("");
|
|
7
|
+
const selectedIndex = ref(0);
|
|
8
|
+
const searchInputRef = ref(null);
|
|
9
|
+
const argStep = usePaletteArgStep();
|
|
10
|
+
const filteredActions = computed(() => argStep.active.value ? [] : queryActions(search.value));
|
|
11
|
+
const rowCount = computed(
|
|
12
|
+
() => argStep.active.value ? argStep.options.value.length : filteredActions.value.length
|
|
13
|
+
);
|
|
14
|
+
watch(filteredActions, () => {
|
|
15
|
+
selectedIndex.value = 0;
|
|
16
|
+
});
|
|
17
|
+
watch(search, (value) => {
|
|
18
|
+
if (!argStep.active.value) return;
|
|
19
|
+
selectedIndex.value = 0;
|
|
20
|
+
void argStep.query(value);
|
|
21
|
+
});
|
|
22
|
+
watch(isOpen, (open2) => {
|
|
23
|
+
if (!open2) {
|
|
24
|
+
search.value = "";
|
|
25
|
+
selectedIndex.value = 0;
|
|
26
|
+
argStep.cancel();
|
|
27
|
+
} else {
|
|
28
|
+
nextTick(() => focusSearch());
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
function focusSearch() {
|
|
32
|
+
searchInputRef.value?.inputRef?.focus();
|
|
33
|
+
}
|
|
34
|
+
function getGroupHeader(index) {
|
|
35
|
+
const action = filteredActions.value[index];
|
|
36
|
+
if (!action?.group) return null;
|
|
37
|
+
if (index === 0) return action.group;
|
|
38
|
+
const prev = filteredActions.value[index - 1];
|
|
39
|
+
if (prev?.group !== action.group) return action.group;
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
function handleDialogKeydown(e) {
|
|
43
|
+
if (e.key === "Escape") {
|
|
44
|
+
e.preventDefault();
|
|
45
|
+
if (argStep.active.value) {
|
|
46
|
+
argStep.cancel();
|
|
47
|
+
search.value = "";
|
|
48
|
+
selectedIndex.value = 0;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
close();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const count = rowCount.value;
|
|
55
|
+
if (e.key === "ArrowDown") {
|
|
56
|
+
if (count === 0) return;
|
|
57
|
+
e.preventDefault();
|
|
58
|
+
selectedIndex.value = (selectedIndex.value + 1) % count;
|
|
59
|
+
} else if (e.key === "ArrowUp") {
|
|
60
|
+
if (count === 0) return;
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
selectedIndex.value = (selectedIndex.value - 1 + count) % count;
|
|
63
|
+
} else if (e.key === "Enter") {
|
|
64
|
+
e.preventDefault();
|
|
65
|
+
if (argStep.active.value) {
|
|
66
|
+
void submitArg();
|
|
67
|
+
} else {
|
|
68
|
+
void selectAction(filteredActions.value[selectedIndex.value]);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function runAction(action, value) {
|
|
73
|
+
try {
|
|
74
|
+
await action.handler(value);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.error(`[ForgeCommandPalette] action "${action.id}" failed:`, err);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async function selectAction(action) {
|
|
80
|
+
if (!action) return;
|
|
81
|
+
if (argStep.begin(action)) {
|
|
82
|
+
search.value = "";
|
|
83
|
+
selectedIndex.value = 0;
|
|
84
|
+
nextTick(() => focusSearch());
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
close();
|
|
88
|
+
await runAction(action);
|
|
89
|
+
}
|
|
90
|
+
async function submitArg() {
|
|
91
|
+
const action = argStep.action.value;
|
|
92
|
+
if (!action) return;
|
|
93
|
+
const value = argStep.valueFor(search.value, selectedIndex.value);
|
|
94
|
+
if (value === null) return;
|
|
95
|
+
argStep.cancel();
|
|
96
|
+
close();
|
|
97
|
+
await runAction(action, value);
|
|
98
|
+
}
|
|
99
|
+
async function selectArgOption(index) {
|
|
100
|
+
selectedIndex.value = index;
|
|
101
|
+
await submitArg();
|
|
102
|
+
}
|
|
103
|
+
function handleGlobalKeydown(e) {
|
|
104
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
|
105
|
+
e.preventDefault();
|
|
106
|
+
open();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
onMounted(() => {
|
|
110
|
+
document.addEventListener("keydown", handleGlobalKeydown);
|
|
111
|
+
});
|
|
112
|
+
onUnmounted(() => {
|
|
113
|
+
document.removeEventListener("keydown", handleGlobalKeydown);
|
|
114
|
+
});
|
|
115
|
+
</script>
|
|
116
|
+
|
|
117
|
+
<template>
|
|
118
|
+
<!-- Nuxt UI Modal as the palette backdrop. #content gives full control of the
|
|
119
|
+
panel (no default header/footer). -->
|
|
120
|
+
<UModal
|
|
121
|
+
:open="isOpen"
|
|
122
|
+
:ui="{ content: 'max-w-[560px] w-[95vw] p-0' }"
|
|
123
|
+
@update:open="(v) => !v && close()"
|
|
124
|
+
>
|
|
125
|
+
<template #content>
|
|
126
|
+
<div class="forge-command-palette" @keydown="handleDialogKeydown">
|
|
127
|
+
<div v-if="argStep.active.value" class="forge-command-palette__breadcrumb">
|
|
128
|
+
{{ argStep.prompt.value?.label ?? argStep.action.value?.label }}
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<!-- Search input -->
|
|
132
|
+
<div class="forge-command-palette__search">
|
|
133
|
+
<UInput
|
|
134
|
+
ref="searchInputRef"
|
|
135
|
+
v-model="search"
|
|
136
|
+
:placeholder="argStep.active.value ? argStep.prompt.value?.placeholder ?? 'Type a value\u2026' : 'Search commands\u2026'"
|
|
137
|
+
variant="none"
|
|
138
|
+
autofocus
|
|
139
|
+
:ui="{ root: 'w-full' }"
|
|
140
|
+
class="forge-command-palette__input"
|
|
141
|
+
/>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<!-- Results list -->
|
|
145
|
+
<div class="forge-command-palette__list" role="listbox">
|
|
146
|
+
<!-- Argument step: the pending action's candidates -->
|
|
147
|
+
<template v-if="argStep.active.value">
|
|
148
|
+
<div v-if="argStep.options.value.length === 0" class="forge-command-palette__empty">
|
|
149
|
+
{{ argStep.loading.value ? "Searching\u2026" : "Type a value and press Enter" }}
|
|
150
|
+
</div>
|
|
151
|
+
<button
|
|
152
|
+
v-for="(option, index) in argStep.options.value"
|
|
153
|
+
:key="option.value"
|
|
154
|
+
type="button"
|
|
155
|
+
class="forge-command-palette__item"
|
|
156
|
+
:class="{ 'forge-command-palette__item--selected': index === selectedIndex }"
|
|
157
|
+
role="option"
|
|
158
|
+
:aria-selected="index === selectedIndex"
|
|
159
|
+
@click="selectArgOption(index)"
|
|
160
|
+
@mouseenter="selectedIndex = index"
|
|
161
|
+
>
|
|
162
|
+
<span v-if="option.icon" class="forge-command-palette__icon">
|
|
163
|
+
<Icon :name="option.icon" />
|
|
164
|
+
</span>
|
|
165
|
+
<span class="forge-command-palette__text">
|
|
166
|
+
<span class="forge-command-palette__label">{{ option.label }}</span>
|
|
167
|
+
<span
|
|
168
|
+
v-if="option.description"
|
|
169
|
+
class="forge-command-palette__description"
|
|
170
|
+
>{{ option.description }}</span>
|
|
171
|
+
</span>
|
|
172
|
+
</button>
|
|
173
|
+
</template>
|
|
174
|
+
|
|
175
|
+
<template v-if="!argStep.active.value && filteredActions.length === 0">
|
|
176
|
+
<div class="forge-command-palette__empty">No commands found</div>
|
|
177
|
+
</template>
|
|
178
|
+
|
|
179
|
+
<template v-for="(action, index) in filteredActions" :key="action.id">
|
|
180
|
+
<!-- Group header -->
|
|
181
|
+
<div
|
|
182
|
+
v-if="getGroupHeader(index)"
|
|
183
|
+
class="forge-command-palette__group"
|
|
184
|
+
>
|
|
185
|
+
{{ getGroupHeader(index) }}
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
<!-- Action row -->
|
|
189
|
+
<button
|
|
190
|
+
type="button"
|
|
191
|
+
class="forge-command-palette__item"
|
|
192
|
+
:class="{ 'forge-command-palette__item--selected': index === selectedIndex }"
|
|
193
|
+
role="option"
|
|
194
|
+
:aria-selected="index === selectedIndex"
|
|
195
|
+
@click="selectAction(action)"
|
|
196
|
+
@mouseenter="selectedIndex = index"
|
|
197
|
+
>
|
|
198
|
+
<span v-if="action.icon" class="forge-command-palette__icon">
|
|
199
|
+
<Icon :name="action.icon" />
|
|
200
|
+
</span>
|
|
201
|
+
<span class="forge-command-palette__text">
|
|
202
|
+
<span class="forge-command-palette__label">{{ action.label }}</span>
|
|
203
|
+
<span
|
|
204
|
+
v-if="action.description"
|
|
205
|
+
class="forge-command-palette__description"
|
|
206
|
+
>{{ action.description }}</span>
|
|
207
|
+
</span>
|
|
208
|
+
</button>
|
|
209
|
+
</template>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
</template>
|
|
213
|
+
</UModal>
|
|
214
|
+
</template>
|
|
215
|
+
|
|
216
|
+
<style scoped>
|
|
217
|
+
.forge-command-palette__breadcrumb{color:#64748b;font-size:.75rem;font-weight:600;padding:10px 12px 0}.forge-command-palette{border-radius:8px;display:flex;flex-direction:column;overflow:hidden}.forge-command-palette__search{border-bottom:1px solid #e2e8f0;padding:12px}.forge-command-palette__input{font-size:.95rem;width:100%}.forge-command-palette__list{max-height:360px;overflow-y:auto;padding:6px}.forge-command-palette__empty{color:#94a3b8;font-size:.875rem;padding:16px;text-align:center}.forge-command-palette__group{color:#94a3b8;font-size:.7rem;font-weight:700;letter-spacing:.08em;margin-top:4px;padding:4px 8px 2px;text-transform:uppercase}.forge-command-palette__item{align-items:center;background:transparent;border:none;border-radius:6px;color:inherit;cursor:pointer;display:flex;font:inherit;gap:10px;padding:8px 10px;text-align:left;transition:background .1s ease;width:100%}.forge-command-palette__item--selected{background:#f1f5f9}.forge-command-palette__icon{color:#64748b;flex-shrink:0;width:20px}.forge-command-palette__text{display:flex;flex-direction:column;gap:1px;min-width:0}.forge-command-palette__label{font-size:.875rem;font-weight:500}.forge-command-palette__description{color:#94a3b8;font-size:.775rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
218
|
+
</style>
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
export default _default;
|
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
import * as z from "zod";
|
|
12
|
+
import type { DefineClientCapabilityArgs, DefinedClientCapability } from "../types/client-capability.js";
|
|
13
|
+
/**
|
|
14
|
+
* Build a {@link DefinedClientCapability} from author-friendly Zod-typed args.
|
|
15
|
+
*
|
|
16
|
+
* The wire `inputSchema` / `outputSchema` are **derived** from the Zod schemas
|
|
17
|
+
* via `z.toJSONSchema()` (zod v4 only) — they are never hand-written, which is
|
|
18
|
+
* what buys the validation the raw-JSON-Schema capabilities never had.
|
|
19
|
+
*
|
|
20
|
+
* `side` is fixed to `"client"`. `origin` is stamped by `ClientCapabilityBridge`
|
|
21
|
+
* when the announcement reaches Nitro.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* export const openFile = defineClientCapability({
|
|
26
|
+
* name: "app.workspace.open_file",
|
|
27
|
+
* displayName: "Open file in viewer",
|
|
28
|
+
* description: "Open a file in the user's file viewer.",
|
|
29
|
+
* kind: "effect",
|
|
30
|
+
* audience: ["llm", "user"],
|
|
31
|
+
* input: z.object({ path: z.string().min(1), line: z.number().optional() }),
|
|
32
|
+
* output: z.object({ opened: z.boolean(), path: z.string() }),
|
|
33
|
+
* handler: async ({ path, line }) => ({ opened: await openInViewer(path, line), path }),
|
|
34
|
+
* })
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export declare function defineClientCapability<T extends z.ZodType, R extends z.ZodType | undefined = undefined>(args: DefineClientCapabilityArgs<T, R>): DefinedClientCapability;
|
|
38
|
+
/**
|
|
39
|
+
* Register an old capability name as a **deprecated alias** of a defined one.
|
|
40
|
+
*
|
|
41
|
+
* Renaming a capability is not free: `promptFragment` text names the old name
|
|
42
|
+
* literally, and agent definitions in the wild may too (spec §2.2). The alias is
|
|
43
|
+
* a second registry entry — same `inputZod`, same `outputZod`, same handler
|
|
44
|
+
* *instance* — so an invocation of either name runs exactly the same code, and
|
|
45
|
+
* the alias needs no separate maintenance.
|
|
46
|
+
*
|
|
47
|
+
* Two deliberate differences from the capability it aliases:
|
|
48
|
+
* - `audience` is narrowed to `["llm"]`. The alias exists only for callers that
|
|
49
|
+
* already know the old name; a deprecated duplicate has no business appearing
|
|
50
|
+
* in a user-facing surface like Cmd+K.
|
|
51
|
+
* - `description` and `promptFragment` are prefixed with a deprecation notice
|
|
52
|
+
* naming the replacement, because the wire `Capability` type has no
|
|
53
|
+
* `deprecated` flag — the model reads prose or it reads nothing.
|
|
54
|
+
*
|
|
55
|
+
* @param capability the current definition, which owns the handler
|
|
56
|
+
* @param name the old name to keep answering
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```ts
|
|
60
|
+
* export const openFile = defineClientCapability({ name: "app.workspace.open_file", … })
|
|
61
|
+
* export const openFileAlias = deprecatedClientCapabilityAlias(openFile, "forge.open_file")
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export declare function deprecatedClientCapabilityAlias(capability: DefinedClientCapability, name: string): DefinedClientCapability;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
export function defineClientCapability(args) {
|
|
3
|
+
const inputSchema = z.toJSONSchema(args.input, {
|
|
4
|
+
target: "draft-07",
|
|
5
|
+
io: "input"
|
|
6
|
+
});
|
|
7
|
+
const outputSchema = args.output ? z.toJSONSchema(args.output, {
|
|
8
|
+
target: "draft-07",
|
|
9
|
+
io: "output"
|
|
10
|
+
}) : void 0;
|
|
11
|
+
return {
|
|
12
|
+
name: args.name,
|
|
13
|
+
displayName: args.displayName,
|
|
14
|
+
description: args.description,
|
|
15
|
+
inputSchema,
|
|
16
|
+
outputSchema,
|
|
17
|
+
side: "client",
|
|
18
|
+
scope: args.scope,
|
|
19
|
+
requiresApproval: args.requiresApproval,
|
|
20
|
+
kind: args.kind,
|
|
21
|
+
audience: args.audience,
|
|
22
|
+
promptFragment: args.promptFragment,
|
|
23
|
+
callTimeoutMs: args.callTimeoutMs,
|
|
24
|
+
render: args.render,
|
|
25
|
+
inputZod: args.input,
|
|
26
|
+
outputZod: args.output,
|
|
27
|
+
handler: args.handler
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export function deprecatedClientCapabilityAlias(capability, name) {
|
|
31
|
+
const notice = `DEPRECATED: use \`${capability.name}\` instead. This name still works but will be removed.`;
|
|
32
|
+
return {
|
|
33
|
+
...capability,
|
|
34
|
+
name,
|
|
35
|
+
description: `${notice} ${capability.description}`,
|
|
36
|
+
audience: ["llm"],
|
|
37
|
+
promptFragment: capability.promptFragment ? `${notice}
|
|
38
|
+
${capability.promptFragment}` : void 0
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `defineForgeAction` — one declaration for Cmd+K and the agent.
|
|
3
|
+
*
|
|
4
|
+
* Spec: `docs/superpowers/specs/2026-08-22-forge-app-channel-design.md` §2.3,
|
|
5
|
+
* which adopts the platform's rule verbatim — *"All user-facing functionality is
|
|
6
|
+
* a command-palette action; register it so it is discoverable via Cmd+K **and**
|
|
7
|
+
* usable by the AI agent"*.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* export const openFile = defineForgeAction({
|
|
11
|
+
* id: "workspace.open_file", // → capability `app.workspace.open_file`
|
|
12
|
+
* label: "Open file…",
|
|
13
|
+
* description: "Open a file in the user's file viewer.",
|
|
14
|
+
* input: z.object({ path: z.string().min(1) }),
|
|
15
|
+
* arg: { placeholder: "path/to/file", toInput: (path) => ({ path }) },
|
|
16
|
+
* run: async ({ path }) => { await useFileViewer().openFile(path); return { opened: true, path } },
|
|
17
|
+
* })
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* `audience` defaults to `['user', 'llm']`. That default is the whole point:
|
|
21
|
+
* a new UI feature is agent-controllable unless its author opts out, instead of
|
|
22
|
+
* agent-controllability being a bespoke per-feature integration.
|
|
23
|
+
*
|
|
24
|
+
* ## No third registry
|
|
25
|
+
*
|
|
26
|
+
* This does not store anything. It *projects* one declaration into the two
|
|
27
|
+
* registries forge already had — `useCommandPalette()` (idempotent by id) and
|
|
28
|
+
* `useAgentCapabilities()` (idempotent by name) — and `useForgeActions()`
|
|
29
|
+
* writes through to both. Those remain the single sources of truth for "what is
|
|
30
|
+
* in the palette" and "what may the agent call", so nothing can drift.
|
|
31
|
+
*
|
|
32
|
+
* ## The id/name seam
|
|
33
|
+
*
|
|
34
|
+
* The palette has always used bare ids (`workspace.open_file`); the platform
|
|
35
|
+
* **rejects** capability registrations outside the `app.` namespace (spec §2.2).
|
|
36
|
+
* The rule is therefore: **author the palette id, and the capability name is
|
|
37
|
+
* `app.<id>`.** An id that already carries the prefix is a mistake and throws,
|
|
38
|
+
* because otherwise `app.app.workspace.open_file` would ship silently.
|
|
39
|
+
*/
|
|
40
|
+
import * as z from "zod";
|
|
41
|
+
import type { DefineForgeActionArgs, ForgeAction, ForgeActionAudience } from "../types/forge-action.js";
|
|
42
|
+
/** The default when `audience` is omitted: reachable from both surfaces. */
|
|
43
|
+
export declare const DEFAULT_FORGE_ACTION_AUDIENCE: ForgeActionAudience[];
|
|
44
|
+
/**
|
|
45
|
+
* Build a {@link ForgeAction} from one declaration.
|
|
46
|
+
*
|
|
47
|
+
* Throws — at define time, i.e. on the first import in dev — for the four
|
|
48
|
+
* declarations that could only fail later and further away:
|
|
49
|
+
* - an `id` that already starts with `app.` (see the id/name seam above),
|
|
50
|
+
* - an llm-audience action with no `description` (the model reads nothing),
|
|
51
|
+
* - an llm-audience `id` that is not `<sector>.<verb>` (spec §2.2's namespace),
|
|
52
|
+
* - a user-audience action whose input needs arguments but declares no `arg`
|
|
53
|
+
* (a palette row that exists only to fail on Enter).
|
|
54
|
+
*/
|
|
55
|
+
export declare function defineForgeAction<T extends z.ZodType = z.ZodObject<Record<string, never>>>(args: DefineForgeActionArgs<T>): ForgeAction;
|
|
56
|
+
export interface UseForgeActionsReturn {
|
|
57
|
+
/**
|
|
58
|
+
* Write one action through to the registries its audience names. Idempotent:
|
|
59
|
+
* the palette replaces by id and the capability registry replaces by name,
|
|
60
|
+
* exactly as they already did, so re-registering on HMR or a remount is a
|
|
61
|
+
* no-op rather than a duplicate.
|
|
62
|
+
*/
|
|
63
|
+
register(action: ForgeAction): ForgeAction;
|
|
64
|
+
/** Register several, in order. */
|
|
65
|
+
registerAll(actions: ForgeAction[]): void;
|
|
66
|
+
/**
|
|
67
|
+
* Remove an action from both registries, aliases included. Takes the action
|
|
68
|
+
* itself rather than an id — the aliases it owns cannot be derived from one.
|
|
69
|
+
*/
|
|
70
|
+
unregister(action: ForgeAction): void;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Register forge actions into the palette and the agent capability registry.
|
|
74
|
+
*
|
|
75
|
+
* ```ts
|
|
76
|
+
* export default defineNuxtPlugin(() => {
|
|
77
|
+
* useForgeActions().registerAll([openFile, toggleSidebar])
|
|
78
|
+
* // then announce upward once — see the app-channel wiring
|
|
79
|
+
* })
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
export declare function useForgeActions(): UseForgeActionsReturn;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { defineClientCapability, deprecatedClientCapabilityAlias } from "./defineClientCapability.js";
|
|
3
|
+
import { useAgentCapabilities } from "./useAgentCapabilities.js";
|
|
4
|
+
import { useCommandPalette } from "./useCommandPalette.js";
|
|
5
|
+
export const DEFAULT_FORGE_ACTION_AUDIENCE = ["user", "llm"];
|
|
6
|
+
const ID_PATTERN = /^[a-z0-9][a-z0-9_-]*(?:\.[a-z0-9][a-z0-9_-]*)+$/;
|
|
7
|
+
const defaultLog = {
|
|
8
|
+
info: (message, ...args) => console.log(message, ...args),
|
|
9
|
+
warn: (message, ...args) => console.warn(message, ...args),
|
|
10
|
+
error: (message, ...args) => console.error(message, ...args)
|
|
11
|
+
};
|
|
12
|
+
function fail(id, message) {
|
|
13
|
+
throw new Error(`[defineForgeAction] ${id}: ${message}`);
|
|
14
|
+
}
|
|
15
|
+
export function defineForgeAction(args) {
|
|
16
|
+
const { id } = args;
|
|
17
|
+
if (!id || id.trim().length === 0) fail("<empty>", "an action needs a non-empty id.");
|
|
18
|
+
const audience = args.audience ?? DEFAULT_FORGE_ACTION_AUDIENCE;
|
|
19
|
+
if (audience.length === 0) {
|
|
20
|
+
fail(id, "audience is empty \u2014 an action nobody can trigger is not an action.");
|
|
21
|
+
}
|
|
22
|
+
const forUser = audience.includes("user");
|
|
23
|
+
const forLlm = audience.includes("llm");
|
|
24
|
+
if (id.startsWith("app.")) {
|
|
25
|
+
fail(
|
|
26
|
+
id,
|
|
27
|
+
`the id is the PALETTE id and must not carry the \`app.\` prefix \u2014 it is added automatically to derive the capability name. Use "${id.slice(4)}".`
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
const name = args.name ?? `app.${id}`;
|
|
31
|
+
if (forLlm) {
|
|
32
|
+
if (!args.description) {
|
|
33
|
+
fail(id, "an action with audience 'llm' must declare a description \u2014 the model reads it.");
|
|
34
|
+
}
|
|
35
|
+
if (!args.name && !ID_PATTERN.test(id)) {
|
|
36
|
+
fail(
|
|
37
|
+
id,
|
|
38
|
+
`an agent-visible id must be lowercase \`<sector>.<verb>\` (spec \xA72.2), so the derived capability name lands in the platform's \`app.\` namespace as \`app.${id}\`.`
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
if (!name.startsWith("app.")) {
|
|
42
|
+
fail(
|
|
43
|
+
id,
|
|
44
|
+
`capability name "${name}" is outside the \`app.\` namespace, which the platform rejects.`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const inputZod = args.input ?? z.object({});
|
|
49
|
+
const acceptsNoArgs = inputZod.safeParse({}).success;
|
|
50
|
+
if (forUser && !acceptsNoArgs && !args.arg) {
|
|
51
|
+
fail(
|
|
52
|
+
id,
|
|
53
|
+
"the input schema requires arguments but no `arg` was declared, so the Cmd+K entry could never run. Declare `arg: { placeholder, toInput }` (and optionally `options` for a picker), or narrow the audience to ['llm']."
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
async function run(rawInput, ctxIn) {
|
|
57
|
+
const parsed = inputZod.parse(rawInput ?? {});
|
|
58
|
+
const ctx = {
|
|
59
|
+
source: ctxIn?.source ?? "user",
|
|
60
|
+
name: ctxIn?.name ?? name,
|
|
61
|
+
sessionKey: ctxIn?.sessionKey ?? "",
|
|
62
|
+
callId: ctxIn?.callId,
|
|
63
|
+
log: ctxIn?.log ?? defaultLog
|
|
64
|
+
};
|
|
65
|
+
return await args.run(parsed, ctx);
|
|
66
|
+
}
|
|
67
|
+
let paletteAction;
|
|
68
|
+
if (forUser) {
|
|
69
|
+
paletteAction = {
|
|
70
|
+
id,
|
|
71
|
+
label: args.label,
|
|
72
|
+
// The model's description is written to be exhaustive; a palette row is
|
|
73
|
+
// one line. `hint` is where an action visible on both surfaces says the
|
|
74
|
+
// short version.
|
|
75
|
+
description: args.hint ?? args.description,
|
|
76
|
+
icon: args.icon,
|
|
77
|
+
keywords: args.keywords,
|
|
78
|
+
group: args.group,
|
|
79
|
+
// `arg` carries `toInput`, which is this module's business, not the
|
|
80
|
+
// palette's — project down to the prompt half the component consumes.
|
|
81
|
+
arg: args.arg ? {
|
|
82
|
+
label: args.arg.label,
|
|
83
|
+
placeholder: args.arg.placeholder,
|
|
84
|
+
options: args.arg.options,
|
|
85
|
+
allowCustom: args.arg.allowCustom
|
|
86
|
+
} : void 0,
|
|
87
|
+
handler: async (value) => {
|
|
88
|
+
const input = args.arg ? args.arg.toInput(value ?? "") : {};
|
|
89
|
+
await run(input, { source: "user" });
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const capability = forLlm ? defineClientCapability({
|
|
94
|
+
name,
|
|
95
|
+
displayName: args.label,
|
|
96
|
+
description: args.description,
|
|
97
|
+
input: inputZod,
|
|
98
|
+
output: args.output,
|
|
99
|
+
kind: args.kind,
|
|
100
|
+
audience,
|
|
101
|
+
scope: args.scope,
|
|
102
|
+
requiresApproval: args.requiresApproval,
|
|
103
|
+
promptFragment: args.promptFragment,
|
|
104
|
+
callTimeoutMs: args.callTimeoutMs,
|
|
105
|
+
render: args.render,
|
|
106
|
+
// Input arrives already parsed by `useCapabilityInvocations`; re-parsing
|
|
107
|
+
// here would only pay the cost twice.
|
|
108
|
+
handler: async (input, capCtx) => await args.run(input, {
|
|
109
|
+
source: "llm",
|
|
110
|
+
name: capCtx.name,
|
|
111
|
+
sessionKey: capCtx.sessionKey,
|
|
112
|
+
callId: capCtx.callId,
|
|
113
|
+
log: capCtx.log
|
|
114
|
+
})
|
|
115
|
+
}) : void 0;
|
|
116
|
+
const aliases = capability && args.deprecatedNames ? args.deprecatedNames.map((old) => deprecatedClientCapabilityAlias(capability, old)) : [];
|
|
117
|
+
return {
|
|
118
|
+
id,
|
|
119
|
+
name,
|
|
120
|
+
label: args.label,
|
|
121
|
+
audience,
|
|
122
|
+
inputZod,
|
|
123
|
+
paletteAction,
|
|
124
|
+
capability,
|
|
125
|
+
aliases,
|
|
126
|
+
run
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
export function useForgeActions() {
|
|
130
|
+
const palette = useCommandPalette();
|
|
131
|
+
const capabilities = useAgentCapabilities();
|
|
132
|
+
function register(action) {
|
|
133
|
+
if (action.paletteAction) palette.registerAction(action.paletteAction);
|
|
134
|
+
if (action.capability) capabilities.register(action.capability);
|
|
135
|
+
for (const alias of action.aliases) capabilities.register(alias);
|
|
136
|
+
return action;
|
|
137
|
+
}
|
|
138
|
+
function registerAll(actions) {
|
|
139
|
+
for (const action of actions) register(action);
|
|
140
|
+
}
|
|
141
|
+
function unregister(action) {
|
|
142
|
+
palette.unregisterAction(action.id);
|
|
143
|
+
capabilities.unregister(action.name);
|
|
144
|
+
for (const alias of action.aliases) capabilities.unregister(alias.name);
|
|
145
|
+
}
|
|
146
|
+
return { register, registerAll, unregister };
|
|
147
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type ShallowRef } from "vue";
|
|
2
|
+
import type { ClientCapabilityDescriptor, DefinedClientCapability } from "../types/client-capability.js";
|
|
3
|
+
/** Minimal `fetch` shape the announce transport needs. Injectable for tests. */
|
|
4
|
+
export type CapabilityAnnounceFetcher = (url: string, init: {
|
|
5
|
+
method: string;
|
|
6
|
+
headers: Record<string, string>;
|
|
7
|
+
body: string;
|
|
8
|
+
}) => Promise<{
|
|
9
|
+
ok: boolean;
|
|
10
|
+
status: number;
|
|
11
|
+
}>;
|
|
12
|
+
export interface AnnounceOptions {
|
|
13
|
+
/**
|
|
14
|
+
* The upward POST endpoint, e.g. `/api/agent/capabilities`. The consuming app
|
|
15
|
+
* owns this route (spec §2) — forge-app-actions never assumes a path.
|
|
16
|
+
*/
|
|
17
|
+
endpoint: string;
|
|
18
|
+
/**
|
|
19
|
+
* Extra fields merged into the JSON body. This is where the app supplies
|
|
20
|
+
* whatever identifies the session to its own route — a single-session app
|
|
21
|
+
* sends nothing; a multi-user app would send the parts of its
|
|
22
|
+
* `userId:slug:mode` key. Never send the derived key from the browser if the
|
|
23
|
+
* server can derive it itself.
|
|
24
|
+
*/
|
|
25
|
+
body?: Record<string, unknown>;
|
|
26
|
+
/** Override the fetcher. Defaults to `globalThis.fetch`. */
|
|
27
|
+
fetcher?: CapabilityAnnounceFetcher;
|
|
28
|
+
}
|
|
29
|
+
export interface AnnounceResult {
|
|
30
|
+
ok: boolean;
|
|
31
|
+
/** How many capability descriptors were sent. `0` for a withdrawal. */
|
|
32
|
+
sent: number;
|
|
33
|
+
/** Present when `ok` is false. */
|
|
34
|
+
error?: string;
|
|
35
|
+
}
|
|
36
|
+
export interface UseAgentCapabilitiesReturn {
|
|
37
|
+
/** Register a capability. Idempotent by name — replaces an existing entry. */
|
|
38
|
+
register(capability: DefinedClientCapability): void;
|
|
39
|
+
/** Register several at once. Idempotent by name, in order. */
|
|
40
|
+
registerAll(capabilities: DefinedClientCapability[]): void;
|
|
41
|
+
/** Remove a previously registered capability. No-op if the name is unknown. */
|
|
42
|
+
unregister(name: string): void;
|
|
43
|
+
/** Look up one registered capability by name. */
|
|
44
|
+
get(name: string): DefinedClientCapability | undefined;
|
|
45
|
+
/** Snapshot of every registered capability (schemas and handlers included). */
|
|
46
|
+
list(): DefinedClientCapability[];
|
|
47
|
+
/** The JSON-serialisable projection announced upward. */
|
|
48
|
+
toWire(): ClientCapabilityDescriptor[];
|
|
49
|
+
/** Reactive list of registered capabilities. */
|
|
50
|
+
capabilities: Readonly<ShallowRef<DefinedClientCapability[]>>;
|
|
51
|
+
/** Stable per-tab token sent with every announcement. */
|
|
52
|
+
subscriberId: string;
|
|
53
|
+
/**
|
|
54
|
+
* POST the whole registry upward. Announcements are never deltas — the bridge
|
|
55
|
+
* replaces its stored list, so a reconnect re-announce is naturally idempotent
|
|
56
|
+
* (spec §L5).
|
|
57
|
+
*
|
|
58
|
+
* Never throws: a failed announce logs a warning and returns `ok: false`, so a
|
|
59
|
+
* dead route cannot break app mount.
|
|
60
|
+
*/
|
|
61
|
+
announce(options: AnnounceOptions): Promise<AnnounceResult>;
|
|
62
|
+
/**
|
|
63
|
+
* POST an empty registry upward — the explicit withdrawal seam for spec §L1's
|
|
64
|
+
* "no browser, no capability". Ticket 05 adds the SSE-disconnect grace period
|
|
65
|
+
* that calls this from the server side; this is the voluntary client-side path.
|
|
66
|
+
*/
|
|
67
|
+
withdraw(options: AnnounceOptions): Promise<AnnounceResult>;
|
|
68
|
+
/** Test-only: empty the singleton. Not for application code. */
|
|
69
|
+
reset(): void;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Browser-side agent capability registry.
|
|
73
|
+
*
|
|
74
|
+
* Components and plugins call `register()` to declare what the agent may invoke
|
|
75
|
+
* in this tab, then `announce()` once on mount so Nitro — and through it the
|
|
76
|
+
* agent's `capability_register` — reflects that a UI actually exists.
|
|
77
|
+
*/
|
|
78
|
+
export declare function useAgentCapabilities(): UseAgentCapabilitiesReturn;
|