@k2b/cloud 0.30.0 → 0.31.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/package.json +3 -2
- package/src/_internal/app-presentation.ts +22 -1
- package/src/_internal/capabilities.ts +2 -2
- package/src/_internal/define-app.ts +14 -4
- package/src/_internal/registry.ts +50 -6
- package/src/_internal/widget-declarations.ts +53 -0
- package/src/ai/approval-routes.ts +40 -4
- package/src/ai/approvals.ts +68 -10
- package/src/ai/chat/blocks.tsx +53 -32
- package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
- package/src/ai/chat/message-actions.tsx +3 -1
- package/src/ai/chat/messages.ts +18 -0
- package/src/ai/chat/tool-groups.ts +66 -12
- package/src/ai/chat/tool-names.ts +4 -2
- package/src/ai/chat/turn-layout.ts +8 -0
- package/src/ai/chat/turn-view.tsx +94 -25
- package/src/ai/chat/web-tools.tsx +95 -1
- package/src/ai/client/controller.ts +6 -3
- package/src/ai/code-runtime-tools.ts +50 -17
- package/src/ai/code-source-contracts.ts +25 -21
- package/src/ai/code-source-tools.ts +3 -1
- package/src/ai/executor.ts +170 -14
- package/src/ai/fetch-file-tool.ts +41 -4
- package/src/ai/firecrawl-tools.ts +87 -4
- package/src/ai/index.ts +8 -0
- package/src/ai/migrate.ts +113 -2
- package/src/ai/protocol.ts +22 -1
- package/src/ai/routes.ts +11 -0
- package/src/ai/runtime.ts +46 -7
- package/src/ai/store.ts +66 -1
- package/src/ai/tool-audit.ts +11 -1
- package/src/ai/tools.ts +91 -3
- package/src/ai/types.ts +39 -0
- package/src/ai/web-provenance.ts +103 -0
- package/src/api/capabilities.ts +3 -2
- package/src/api/widgets.ts +36 -5
- package/src/browser/CloudResourceSearch.tsx +1 -1
- package/src/browser/command-shortcuts.ts +21 -6
- package/src/browser/spotlight-position.ts +4 -1
- package/src/browser/widgets.ts +7 -5
- package/src/capabilities/executions.ts +3 -0
- package/src/cli/commands.ts +20 -0
- package/src/cli/index.ts +16 -0
- package/src/cli/plugin.ts +30 -0
- package/src/contracts/app.ts +34 -2
- package/src/contracts/registry.ts +9 -1
- package/src/contracts/widgets.ts +47 -2
- package/src/desktop/solid.tsx +6 -7
- package/src/server/index.ts +1 -0
- package/src/server/widget-request.ts +27 -0
- package/src/services/approvals/index.ts +225 -0
- package/src/services/approvals/migrate.ts +33 -0
- package/src/shared/app-presentation.ts +11 -0
- package/src/shared/email-html.ts +43 -8
- package/src/ssr/AdminLayout.tsx +4 -7
- package/src/ssr/AppLaunchpad.island.tsx +1 -1
- package/src/ssr/Layout.tsx +38 -14
- package/src/ssr/LayoutFooter.tsx +1 -1
- package/src/ssr/LayoutHeader.tsx +17 -20
- package/src/ssr/LayoutHelp.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +1 -2
- package/src/ssr/PageTrail.tsx +23 -0
- package/src/ssr/PageTrailRow.island.tsx +20 -0
- package/src/ssr/PageTrailRuntime.island.tsx +26 -0
- package/src/ssr/ProfilePreferences.island.tsx +7 -3
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/layout-runtime.ts +60 -0
- package/src/ssr/mobile-menu-history.ts +1 -1
- package/src/ssr/page-trail-runtime.ts +313 -0
- package/src/ssr/page-trail.browser-harness.tsx +216 -0
- package/src/ssr/page-trail.ts +59 -0
- package/src/ssr/platform-messages.ts +4 -0
- package/src/styles/effects.css +21 -4
- package/src/styles/global.css +35 -2
- package/src/styles/utilities-layout.css +24 -1
- package/src/styles/utilities-navigation.css +6 -18
package/src/ai/tools.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { z } from "zod";
|
|
|
4
4
|
import type { RequestActor } from "../server";
|
|
5
5
|
import { aiToolNeedsApproval } from "./approvals";
|
|
6
6
|
import type {
|
|
7
|
+
AiApprovalTarget,
|
|
7
8
|
AiDataBoundary,
|
|
8
9
|
AiFrontendToolMode,
|
|
9
10
|
AiProjectFileToolSource,
|
|
@@ -13,6 +14,7 @@ import type {
|
|
|
13
14
|
AiToolApprovalPolicy,
|
|
14
15
|
AiToolDefinition,
|
|
15
16
|
AiToolRuntime,
|
|
17
|
+
AiWebsiteReceipt,
|
|
16
18
|
} from "./types";
|
|
17
19
|
|
|
18
20
|
export const defineAiTool = <TInput extends z.ZodType, TOutput extends z.ZodType>(config: {
|
|
@@ -60,6 +62,12 @@ export const defineAiTool = <TInput extends z.ZodType, TOutput extends z.ZodType
|
|
|
60
62
|
timeZone?: string;
|
|
61
63
|
/** Localized short status; never include payloads or secrets. */
|
|
62
64
|
reportProgress?: (message: string) => Promise<void>;
|
|
65
|
+
/** Asks like `requestApproval`, and lets the person remember the approval of `target`. */
|
|
66
|
+
requestApprovalFor?: (message: string, target: AiApprovalTarget) => Promise<boolean>;
|
|
67
|
+
/** Relays a decision for an effect another platform component consumes and executes. */
|
|
68
|
+
requestRelayedApproval?: (message: string, options: { approvalId?: string; target?: AiApprovalTarget }) => Promise<boolean>;
|
|
69
|
+
/** Shows requests a website approval let through as they go out, so the chat keeps them whatever the outcome. */
|
|
70
|
+
reportWebsiteReceipts?: (receipts: AiWebsiteReceipt[]) => Promise<void>;
|
|
63
71
|
},
|
|
64
72
|
) => Promise<z.infer<TOutput>>,
|
|
65
73
|
): AiToolRuntime<TInput, TOutput> {
|
|
@@ -106,15 +114,93 @@ export type AiToolPreparationContext = {
|
|
|
106
114
|
locale?: string;
|
|
107
115
|
timeZone?: string;
|
|
108
116
|
reportToolProgress?: (callId: string, message: string) => Promise<void>;
|
|
117
|
+
/** Uses the approved snapshot immediately before the tool executes its effect. */
|
|
118
|
+
consumeApproval?: (approvalCallId: string, input: unknown) => Promise<AiApprovalUse>;
|
|
119
|
+
/** Links a relayed decision to the record the effect owner will consume. */
|
|
120
|
+
linkApproval?: (approvalCallId: string, approvalId: string) => void;
|
|
121
|
+
/** Hears what a nested approval asks for before the approval is requested. */
|
|
122
|
+
describeApproval?: (approvalCallId: string, target: AiApprovalTarget) => void;
|
|
123
|
+
reportWebsiteReceipts?: (callId: string, receipts: AiWebsiteReceipt[]) => Promise<void>;
|
|
109
124
|
};
|
|
110
125
|
|
|
126
|
+
/**
|
|
127
|
+
* What using an approval found: `approved` lets the effect run, `changed` means the approved snapshot no longer
|
|
128
|
+
* matches and asks again, and `used` means an earlier run of the call already used it, so the call must not run again.
|
|
129
|
+
*/
|
|
130
|
+
export type AiApprovalUse = "approved" | "changed" | "used";
|
|
131
|
+
|
|
132
|
+
const approvalCallIdPattern = /^(.*)-approval-(\d+)$/;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Whether `askedCallIds` holds a question of the same tool call that Nessi asked after `approvalCallId`, the call's
|
|
136
|
+
* own approval or one of its numbered questions. A turn that continues after such a question runs the tool from its
|
|
137
|
+
* start again, so the approvals before it are used again by the same call.
|
|
138
|
+
*/
|
|
139
|
+
export const aiApprovalAskedLater = (approvalCallId: string, askedCallIds: Iterable<string>): boolean => {
|
|
140
|
+
const own = approvalCallIdPattern.exec(approvalCallId);
|
|
141
|
+
const callId = own ? own[1] : approvalCallId,
|
|
142
|
+
index = own ? Number(own[2]) : -1;
|
|
143
|
+
for (const asked of askedCallIds) {
|
|
144
|
+
const later = approvalCallIdPattern.exec(asked);
|
|
145
|
+
if (later && later[1] === callId && Number(later[2]) > index) return true;
|
|
146
|
+
}
|
|
147
|
+
return false;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const approvalUsedError = () =>
|
|
151
|
+
new Error("This approval was already used, so Cloud does not run the call again. Check its outcome before you call again.");
|
|
152
|
+
|
|
111
153
|
export const prepareAiTools = (input: AiToolPreparationContext & { tools?: AiRuntimeTool[] }): PreparedAiTools => {
|
|
112
154
|
const canonicalNames = new Map<string, string>();
|
|
113
155
|
const approvalPolicies = new Map<string, AiToolApprovalPolicy>();
|
|
114
156
|
const frontendModes = new Map<string, AiFrontendToolMode>();
|
|
115
157
|
|
|
158
|
+
const approvalContext = async (toolInput: unknown, ctx: ToolContext, needsApproval: boolean) => {
|
|
159
|
+
const callId = ctx.callId;
|
|
160
|
+
const use = (approvalCallId: string): Promise<AiApprovalUse> =>
|
|
161
|
+
input.consumeApproval ? input.consumeApproval(approvalCallId, toolInput) : Promise.resolve("approved");
|
|
162
|
+
if (needsApproval && input.consumeApproval) {
|
|
163
|
+
const used = callId ? await use(callId) : "changed";
|
|
164
|
+
if (used === "used") throw approvalUsedError();
|
|
165
|
+
if (used === "changed") throw new Error("This approval no longer matches the call. Ask again.");
|
|
166
|
+
}
|
|
167
|
+
// Nessi numbers the approvals of one execution in request order; count them to name each one's call ID.
|
|
168
|
+
let approvals = 0;
|
|
169
|
+
const requestApproval = async (message: string, target?: AiApprovalTarget): Promise<boolean> => {
|
|
170
|
+
while (true) {
|
|
171
|
+
const approvalCallId = `${callId}-approval-${approvals++}`;
|
|
172
|
+
if (target) input.describeApproval?.(approvalCallId, target);
|
|
173
|
+
if (!(await ctx.requestApproval(message))) return false;
|
|
174
|
+
const used = await use(approvalCallId);
|
|
175
|
+
if (used === "approved") return true;
|
|
176
|
+
if (used === "used") throw approvalUsedError();
|
|
177
|
+
// A changed snapshot requires a new human decision under the next Nessi action ID.
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
return {
|
|
181
|
+
...ctx,
|
|
182
|
+
requestApproval,
|
|
183
|
+
requestApprovalFor: (message: string, target: AiApprovalTarget) => requestApproval(message, target),
|
|
184
|
+
requestRelayedApproval: async (message: string, options: { approvalId?: string; target?: AiApprovalTarget }) => {
|
|
185
|
+
const approvalCallId = `${callId}-approval-${approvals++}`;
|
|
186
|
+
if (options.approvalId) input.linkApproval?.(approvalCallId, options.approvalId);
|
|
187
|
+
if (options.target) input.describeApproval?.(approvalCallId, options.target);
|
|
188
|
+
if (!(await ctx.requestApproval(message))) return false;
|
|
189
|
+
// The effect's own record is used where it runs. Without one, as for a website request from code, the relay
|
|
190
|
+
// that hands over the decision uses the chat's record; a decision it cannot use is not handed over as approval.
|
|
191
|
+
return options.approvalId !== undefined || (await use(approvalCallId)) === "approved";
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
};
|
|
195
|
+
|
|
116
196
|
const tools = (input.tools ?? []).map((tool): Tool => {
|
|
117
|
-
if (!isCloudAiTool(tool))
|
|
197
|
+
if (!isCloudAiTool(tool)) {
|
|
198
|
+
if (tool.kind !== "server") return tool;
|
|
199
|
+
return {
|
|
200
|
+
...tool,
|
|
201
|
+
execute: async (toolInput, ctx) => tool.execute(toolInput, await approvalContext(toolInput, ctx, tool.def.needsApproval === true)),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
118
204
|
|
|
119
205
|
canonicalNames.set(tool.def.name, tool.def.canonicalName ?? tool.def.name);
|
|
120
206
|
approvalPolicies.set(tool.def.name, tool.def.approval);
|
|
@@ -130,12 +216,12 @@ export const prepareAiTools = (input: AiToolPreparationContext & { tools?: AiRun
|
|
|
130
216
|
});
|
|
131
217
|
|
|
132
218
|
if (tool.location === "server") {
|
|
133
|
-
return nessiTool.server((toolInput, ctx) => {
|
|
219
|
+
return nessiTool.server(async (toolInput, ctx) => {
|
|
134
220
|
if (!input.actor) throw new Error(`AI server tool "${tool.def.name}" requires a request actor.`);
|
|
135
221
|
const report = input.reportToolProgress;
|
|
136
222
|
const callId = ctx.callId;
|
|
137
223
|
return tool.run(toolInput, {
|
|
138
|
-
...ctx,
|
|
224
|
+
...(await approvalContext(toolInput, ctx, aiToolNeedsApproval(tool.def.approval))),
|
|
139
225
|
actor: input.actor,
|
|
140
226
|
conversationId: input.conversationId,
|
|
141
227
|
turnId: input.turnId,
|
|
@@ -147,6 +233,8 @@ export const prepareAiTools = (input: AiToolPreparationContext & { tools?: AiRun
|
|
|
147
233
|
locale: input.locale,
|
|
148
234
|
timeZone: input.timeZone,
|
|
149
235
|
reportProgress: callId && report ? (message) => report(callId, message) : undefined,
|
|
236
|
+
reportWebsiteReceipts:
|
|
237
|
+
callId && input.reportWebsiteReceipts ? (receipts) => input.reportWebsiteReceipts!(callId, receipts) : undefined,
|
|
150
238
|
});
|
|
151
239
|
});
|
|
152
240
|
}
|
package/src/ai/types.ts
CHANGED
|
@@ -303,6 +303,8 @@ export type AiStoredMessage = {
|
|
|
303
303
|
toolPresentations?: Record<string, AiToolPresentation>;
|
|
304
304
|
/** How the person decided an approval; `expired` when the turn ended while it still waited. */
|
|
305
305
|
toolOutcomes?: Record<string, "rejected" | "approved" | "expired">;
|
|
306
|
+
/** Requests a website approval let through, by tool call and then by `AiWebsiteReceipt.index`. */
|
|
307
|
+
websiteReceipts?: Record<string, Record<string, { method: string; url: string }>>;
|
|
306
308
|
/** Why the turn failed, on the last message of its loop. The chat words it in the reader's language. */
|
|
307
309
|
turnError?: AiTurnError;
|
|
308
310
|
} | null;
|
|
@@ -420,6 +422,10 @@ export type AiPendingTurnAction =
|
|
|
420
422
|
message?: string;
|
|
421
423
|
review?: CapabilityActionReview;
|
|
422
424
|
allowAlways: boolean;
|
|
425
|
+
/** Whether the approval can be remembered for this chat. */
|
|
426
|
+
allowChat: boolean;
|
|
427
|
+
/** The website origin a chat approval would allow, for an HTTP request a code run makes. */
|
|
428
|
+
website?: string;
|
|
423
429
|
}
|
|
424
430
|
| {
|
|
425
431
|
type: "frontend_tool";
|
|
@@ -441,6 +447,7 @@ export type AiTurnAbortRequest =
|
|
|
441
447
|
};
|
|
442
448
|
|
|
443
449
|
export type AiPendingTurnActionRecord = {
|
|
450
|
+
approvalId?: string;
|
|
444
451
|
turnId: string;
|
|
445
452
|
conversationId: string;
|
|
446
453
|
callId: string;
|
|
@@ -452,6 +459,10 @@ export type AiPendingTurnActionRecord = {
|
|
|
452
459
|
review?: CapabilityActionReview;
|
|
453
460
|
approvalScope: string;
|
|
454
461
|
allowAlways: boolean;
|
|
462
|
+
/** Whether the approval can be remembered for this chat; defaults to `allowAlways`. */
|
|
463
|
+
allowChat?: boolean;
|
|
464
|
+
/** Remembered-approval name when it differs from `name`: the website or Action a code run asks for. */
|
|
465
|
+
rememberToolName?: string;
|
|
455
466
|
frontendMode?: AiFrontendToolMode;
|
|
456
467
|
resolvedEvent: InboundEvent | null;
|
|
457
468
|
};
|
|
@@ -607,6 +618,11 @@ export type AiChatTurnRunConfig = {
|
|
|
607
618
|
selectedSkillIds?: string[];
|
|
608
619
|
/** Server-owned marker: model grants apply only to interactive Assistant chat. */
|
|
609
620
|
assistantChat?: true;
|
|
621
|
+
/**
|
|
622
|
+
* Server-owned marker: a person started this turn in a signed-in browser session, not through `cld`, an API key,
|
|
623
|
+
* a delegated account, or a schedule. Only such a turn uses website approvals.
|
|
624
|
+
*/
|
|
625
|
+
signedInSession?: true;
|
|
610
626
|
kind?: "chat";
|
|
611
627
|
input: Input;
|
|
612
628
|
/** Stable public ID exposed as runtime context, not instructions. */
|
|
@@ -1016,6 +1032,23 @@ export type AiAccessResult<TAccess = unknown> = {
|
|
|
1016
1032
|
|
|
1017
1033
|
export type AiToolApprovalPolicy = "never" | "once" | "always" | { kind: "user-configurable"; default: "once" | "always"; scope?: string };
|
|
1018
1034
|
|
|
1035
|
+
/**
|
|
1036
|
+
* What a server tool asks a person to approve when the approval can be remembered: a website or an Action, not the
|
|
1037
|
+
* tool itself. A target can always be remembered for its chat.
|
|
1038
|
+
*/
|
|
1039
|
+
export type AiApprovalTarget = {
|
|
1040
|
+
toolName: string;
|
|
1041
|
+
approvalScope: string;
|
|
1042
|
+
/** Whether the person may also remember it everywhere. */
|
|
1043
|
+
always: boolean;
|
|
1044
|
+
};
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* A request a remembered website approval let through without asking. The chat shows it with its full URL and a revoke
|
|
1048
|
+
* action; `index` orders the receipts of one tool call, and a receipt reported again with the same index replaces it.
|
|
1049
|
+
*/
|
|
1050
|
+
export type AiWebsiteReceipt = { index: number; method: string; url: string };
|
|
1051
|
+
|
|
1019
1052
|
export type AiFrontendToolMode = "client" | "client_view" | "client_interaction";
|
|
1020
1053
|
|
|
1021
1054
|
export type AiCapabilityToolPresentation = {
|
|
@@ -1071,6 +1104,12 @@ export type AiToolRuntime<TInput extends z.ZodType = z.ZodType, TOutput extends
|
|
|
1071
1104
|
timeZone?: string;
|
|
1072
1105
|
/** Localized short status; never include payloads or secrets. */
|
|
1073
1106
|
reportProgress?: (message: string) => Promise<void>;
|
|
1107
|
+
/** Asks like `requestApproval`, and lets the person remember the approval of `target`. */
|
|
1108
|
+
requestApprovalFor?: (message: string, target: AiApprovalTarget) => Promise<boolean>;
|
|
1109
|
+
/** Relays a decision for an effect another platform component consumes and executes. */
|
|
1110
|
+
requestRelayedApproval?: (message: string, options: { approvalId?: string; target?: AiApprovalTarget }) => Promise<boolean>;
|
|
1111
|
+
/** Shows requests a website approval let through as they go out, so the chat keeps them whatever the outcome. */
|
|
1112
|
+
reportWebsiteReceipts?: (receipts: AiWebsiteReceipt[]) => Promise<void>;
|
|
1074
1113
|
},
|
|
1075
1114
|
): Promise<z.infer<TOutput>>;
|
|
1076
1115
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { domainToUnicode } from "node:url";
|
|
2
|
+
import { sql } from "bun";
|
|
3
|
+
import { toPgTextArray } from "../services/postgres";
|
|
4
|
+
|
|
5
|
+
/** The longest address web_extract and fetch_file accept; a longer one can never be read, so it is never recorded. */
|
|
6
|
+
const WEB_ADDRESS_MAX_CHARS = 2_000;
|
|
7
|
+
|
|
8
|
+
const URL_TOKEN = /https?:\/\/[^\s<>"'`\\^{}|[\]]+/gi;
|
|
9
|
+
/**
|
|
10
|
+
* An address typed without its scheme, such as `example.org/news`. It must start the word: a host inside another
|
|
11
|
+
* address, an email address, or a longer host name is not a typed address of its own.
|
|
12
|
+
*/
|
|
13
|
+
const BARE_TOKEN =
|
|
14
|
+
/(?<![\w@.\-/:%+~#=?&])(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z][a-z0-9-]*[a-z0-9](?::\d{1,5})?(?:\/[^\s<>"'`\\^{}|[\]]*)?/gi;
|
|
15
|
+
|
|
16
|
+
/** Drops punctuation that ends the sentence around an address, and a closing bracket the address did not open. */
|
|
17
|
+
const trimToken = (token: string): string => {
|
|
18
|
+
let end = token.length;
|
|
19
|
+
while (end > 0) {
|
|
20
|
+
const last = token[end - 1]!;
|
|
21
|
+
if (".,;:!?*'\"".includes(last)) end--;
|
|
22
|
+
else if (last === ")" && token.slice(0, end).split("(").length <= token.slice(0, end).split(")").length - 1) end--;
|
|
23
|
+
else break;
|
|
24
|
+
}
|
|
25
|
+
return token.slice(0, end);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** The forms of one address that count as the same read: as written, without its fragment, and over HTTPS. */
|
|
29
|
+
const addressForms = (raw: string, add: (address: string) => void) => {
|
|
30
|
+
let url: URL;
|
|
31
|
+
try {
|
|
32
|
+
url = new URL(raw);
|
|
33
|
+
} catch {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return;
|
|
37
|
+
const forms = [url.href];
|
|
38
|
+
if (url.hash) forms.push(url.href.slice(0, -url.hash.length));
|
|
39
|
+
// Upgrading to HTTPS carries no data the address did not already carry.
|
|
40
|
+
if (url.protocol === "http:") forms.push(...forms.map((form) => `https:${form.slice("http:".length)}`));
|
|
41
|
+
for (const form of forms) if (form.length <= WEB_ADDRESS_MAX_CHARS) add(form);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Every whole address in a text, normalized like a URL the tools read. Only complete tokens count, never a part of a
|
|
46
|
+
* longer address; with `typed`, an address written without its scheme counts too, as a person often types it.
|
|
47
|
+
*/
|
|
48
|
+
export const webAddressesIn = (text: string, options: { typed?: boolean } = {}): Set<string> => {
|
|
49
|
+
const addresses = new Set<string>();
|
|
50
|
+
const add = (address: string) => addresses.add(address);
|
|
51
|
+
const rest = text.replace(URL_TOKEN, (token) => {
|
|
52
|
+
addressForms(trimToken(token), add);
|
|
53
|
+
return " ";
|
|
54
|
+
});
|
|
55
|
+
if (options.typed)
|
|
56
|
+
for (const [token] of rest.matchAll(BARE_TOKEN)) {
|
|
57
|
+
const bare = trimToken(token);
|
|
58
|
+
addressForms(`https://${bare}`, add);
|
|
59
|
+
addressForms(`http://${bare}`, add);
|
|
60
|
+
}
|
|
61
|
+
return addresses;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Records addresses a web search returned or a page read in this chat links to; later reads of them do not ask. */
|
|
65
|
+
export const recordWebAddresses = async (conversationId: string, addresses: Iterable<string>): Promise<void> => {
|
|
66
|
+
const list = [...addresses];
|
|
67
|
+
if (!list.length) return;
|
|
68
|
+
await sql`
|
|
69
|
+
INSERT INTO ai.web_addresses (conversation_id, address)
|
|
70
|
+
SELECT ${conversationId}::uuid, address FROM unnest(${toPgTextArray(list)}::text[]) address
|
|
71
|
+
ON CONFLICT DO NOTHING
|
|
72
|
+
`;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whether the chat itself supplied this address: the person wrote it, a web search returned it, or a page read
|
|
77
|
+
* earlier links to it. A message another chat sent counts as neither. Anything else, such as an address the model
|
|
78
|
+
* assembled from other data, has no provenance. `url` is the normalized address the tool reads.
|
|
79
|
+
*/
|
|
80
|
+
export const hasWebAddressProvenance = async (conversationId: string, url: string): Promise<boolean> => {
|
|
81
|
+
const [recorded] = await sql<{ found: boolean }[]>`
|
|
82
|
+
SELECT EXISTS (SELECT 1 FROM ai.web_addresses WHERE conversation_id = ${conversationId}::uuid AND address = ${url}) AS found
|
|
83
|
+
`;
|
|
84
|
+
if (recorded?.found) return true;
|
|
85
|
+
// Only the person's own messages that name the host are read, never tool results.
|
|
86
|
+
const { hostname } = new URL(url);
|
|
87
|
+
const hosts = [...new Set([hostname, domainToUnicode(hostname) || hostname])];
|
|
88
|
+
const rows = await sql<{ text: string }[]>`
|
|
89
|
+
SELECT text FROM (
|
|
90
|
+
SELECT message, meta FROM ai.messages WHERE conversation_id = ${conversationId}::uuid AND kind = 'message' AND role = 'user'
|
|
91
|
+
UNION ALL
|
|
92
|
+
SELECT message, meta FROM ai.task_messages WHERE conversation_id = ${conversationId}::uuid AND kind = 'message' AND role = 'user'
|
|
93
|
+
) entry
|
|
94
|
+
CROSS JOIN LATERAL (
|
|
95
|
+
SELECT string_agg(CASE WHEN jsonb_typeof(part) = 'string' THEN part #>> '{}' ELSE part->>'text' END, chr(10)) AS text
|
|
96
|
+
FROM jsonb_array_elements(CASE WHEN jsonb_typeof(entry.message->'content') = 'array' THEN entry.message->'content' ELSE '[]'::jsonb END) part
|
|
97
|
+
WHERE jsonb_typeof(part) = 'string' OR part->>'type' = 'text'
|
|
98
|
+
) content
|
|
99
|
+
WHERE NOT COALESCE(entry.meta ? 'agentMessage', false)
|
|
100
|
+
AND EXISTS (SELECT 1 FROM unnest(${toPgTextArray(hosts)}::text[]) host WHERE strpos(lower(content.text), host) > 0)
|
|
101
|
+
`;
|
|
102
|
+
return rows.some((row) => webAddressesIn(row.text, { typed: true }).has(url));
|
|
103
|
+
};
|
package/src/api/capabilities.ts
CHANGED
|
@@ -613,11 +613,12 @@ const runCapabilityDispatch = async (
|
|
|
613
613
|
: schemaValidator(`${operation.schemaHash}:result`, capabilityResultJsonSchema(operation.dataSchema));
|
|
614
614
|
const parsedResult = resultValidator?.safeParse(upstreamBody.data);
|
|
615
615
|
const parsedReview = params.review ? CapabilityActionReviewSchema.safeParse(upstreamBody.data) : null;
|
|
616
|
+
// A rememberable Action may leave out its scope for arguments that should ask every time; any other Action has none.
|
|
616
617
|
const reviewApprovalScopeIsValid =
|
|
617
618
|
!params.review ||
|
|
618
|
-
!("approval" in operation) ||
|
|
619
619
|
!parsedReview?.success ||
|
|
620
|
-
(
|
|
620
|
+
("approval" in operation && operation.approval === "rememberable") ||
|
|
621
|
+
parsedReview.data.approvalScope === undefined;
|
|
621
622
|
if (!resultValidator || !parsedResult?.success || !reviewApprovalScopeIsValid) {
|
|
622
623
|
if (actionWithoutRetrySafety) return settle(outcomeUnknown(), "uncertain");
|
|
623
624
|
const invalid = errorResponse("INVALID_APP_RESPONSE", messages.outsideResultSchema({ appId: params.appId }), 502);
|
package/src/api/widgets.ts
CHANGED
|
@@ -2,6 +2,9 @@ import { type Context, Hono, type MiddlewareHandler } from "hono";
|
|
|
2
2
|
import { readBoundedJson } from "../_internal/bounded-json";
|
|
3
3
|
import { type DashboardWidget, listWidgets } from "../_internal/registry";
|
|
4
4
|
import {
|
|
5
|
+
type DashboardWidgetSize,
|
|
6
|
+
fitWidgetSize,
|
|
7
|
+
isDashboardWidgetSize,
|
|
5
8
|
WIDGET_MAX_RESPONSE_BYTES,
|
|
6
9
|
type WidgetResponse,
|
|
7
10
|
WidgetResponseSchema,
|
|
@@ -9,6 +12,7 @@ import {
|
|
|
9
12
|
widgetKey,
|
|
10
13
|
} from "../contracts/widgets";
|
|
11
14
|
import { type AuthContext, auth, preferredLocale, rejectReservedWorkloadCredential } from "../server";
|
|
15
|
+
import { WIDGET_SIZE_PARAM } from "../server/widget-request";
|
|
12
16
|
import { CLOUD_INVOCATION_TOKEN_TTL_SECONDS } from "../services/identity/constants";
|
|
13
17
|
import { invocationAuthorityFromRequest } from "../services/identity/invocation-authority";
|
|
14
18
|
import { widgetInvocationOperation } from "../services/identity/invocation-operations";
|
|
@@ -78,6 +82,8 @@ type ProviderOutcome =
|
|
|
78
82
|
const callProvider = async (options: {
|
|
79
83
|
c: Context<AuthContext>;
|
|
80
84
|
widget: DashboardWidget;
|
|
85
|
+
/** What the browser asked for, fitted to the widget's declared sizes before it reaches the app. */
|
|
86
|
+
size: unknown;
|
|
81
87
|
token: string;
|
|
82
88
|
requestId: string | undefined;
|
|
83
89
|
signal: AbortSignal;
|
|
@@ -98,6 +104,7 @@ const callProvider = async (options: {
|
|
|
98
104
|
headers.set("x-cloud-invocation-operation", widgetInvocationOperation(widget.widgetId));
|
|
99
105
|
|
|
100
106
|
const targetUrl = new URL(`/api/_internal/widgets/v1/${encodeURIComponent(widget.widgetId)}`, widget.url);
|
|
107
|
+
targetUrl.searchParams.set(WIDGET_SIZE_PARAM, fitWidgetSize(options.size, widget));
|
|
101
108
|
signal.throwIfAborted();
|
|
102
109
|
const response = await options.fetch(targetUrl, { headers, signal });
|
|
103
110
|
if (response.status === 204 || response.status === 403) {
|
|
@@ -125,6 +132,17 @@ const callProvider = async (options: {
|
|
|
125
132
|
}
|
|
126
133
|
};
|
|
127
134
|
|
|
135
|
+
/**
|
|
136
|
+
* `<appId>/<widgetId>` with an optional `@small`, `@medium`, or `@large`, as the key and the size it asks for. The
|
|
137
|
+
* suffix is a size whenever the key before it is declared; `defineApp()` rejects `@` in widget IDs, so only an app
|
|
138
|
+
* built before sizes existed can declare an ID that ends in such a suffix, and it is still asked by that whole ID.
|
|
139
|
+
*/
|
|
140
|
+
const parseRequestedWidget = (value: string, declared: ReadonlySet<string>): [string, DashboardWidgetSize | undefined] => {
|
|
141
|
+
const at = value.lastIndexOf("@");
|
|
142
|
+
const size = at < 0 ? undefined : value.slice(at + 1);
|
|
143
|
+
return isDashboardWidgetSize(size) && declared.has(value.slice(0, at)) ? [value.slice(0, at), size] : [value, undefined];
|
|
144
|
+
};
|
|
145
|
+
|
|
128
146
|
export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) => {
|
|
129
147
|
const registry = dependencies.listWidgets ?? listWidgets;
|
|
130
148
|
const fetchWidget = dependencies.fetch ?? globalThis.fetch;
|
|
@@ -155,17 +173,20 @@ export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) =
|
|
|
155
173
|
.use(rejectReservedWorkloadCredential)
|
|
156
174
|
.use(auth.requireOAuthScope("read", "admin"))
|
|
157
175
|
/**
|
|
158
|
-
* Every requested widget (`?widget=<appId>/<widgetId>`, repeatable
|
|
159
|
-
* NDJSON stream: one line per widget as soon as it answers, each within its own budget.
|
|
176
|
+
* Every requested widget (`?widget=<appId>/<widgetId>`, repeatable, optionally with `@<size>`; all declared
|
|
177
|
+
* widgets without one) as an NDJSON stream: one line per widget as soon as it answers, each within its own budget.
|
|
160
178
|
*/
|
|
161
179
|
.get("/widgets/v1", async (c) => {
|
|
162
180
|
const requestId = normalizeInvocationRequestId(c.req.header("x-request-id"));
|
|
163
|
-
const requested = new Set(c.req.queries("widget") ?? []);
|
|
164
181
|
const setup = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(timeoutMs)]);
|
|
165
182
|
let widgets: DashboardWidget[];
|
|
183
|
+
let requested = new Map<string, DashboardWidgetSize | undefined>();
|
|
166
184
|
try {
|
|
167
185
|
const keys = new Set<string>();
|
|
168
|
-
|
|
186
|
+
const declared = await waitWithin(registry(), setup);
|
|
187
|
+
const declaredKeys = new Set(declared.map((widget) => widgetKey(widget.appId, widget.widgetId)));
|
|
188
|
+
requested = new Map((c.req.queries("widget") ?? []).map((value) => parseRequestedWidget(value, declaredKeys)));
|
|
189
|
+
widgets = declared.filter((widget) => {
|
|
169
190
|
const key = widgetKey(widget.appId, widget.widgetId);
|
|
170
191
|
if (keys.has(key) || (requested.size > 0 && !requested.has(key))) return false;
|
|
171
192
|
keys.add(key);
|
|
@@ -222,6 +243,7 @@ export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) =
|
|
|
222
243
|
signal: AbortSignal.any([fanoutSignal, timeout]),
|
|
223
244
|
timeout,
|
|
224
245
|
fetch: fetchWidget,
|
|
246
|
+
size: requested.get(key),
|
|
225
247
|
});
|
|
226
248
|
if (outcome.status === "ok") return { type: "widget", key, status: "ok", widget: outcome.widget, ms: elapsed() };
|
|
227
249
|
// A widget the stream itself stopped has already been settled, and is logged once, below.
|
|
@@ -276,7 +298,16 @@ export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) =
|
|
|
276
298
|
);
|
|
277
299
|
|
|
278
300
|
phase = "provider";
|
|
279
|
-
const outcome = await callProvider({
|
|
301
|
+
const outcome = await callProvider({
|
|
302
|
+
c,
|
|
303
|
+
widget,
|
|
304
|
+
size: c.req.query(WIDGET_SIZE_PARAM),
|
|
305
|
+
token: signed.token,
|
|
306
|
+
requestId,
|
|
307
|
+
signal,
|
|
308
|
+
timeout,
|
|
309
|
+
fetch: fetchWidget,
|
|
310
|
+
});
|
|
280
311
|
if (outcome.status === "ok") return Response.json(outcome.widget, { headers: { "content-type": "application/json" } });
|
|
281
312
|
if (!("reason" in outcome))
|
|
282
313
|
return new Response(null, { status: outcome.status === "empty" ? 204 : 403, headers: { "content-type": "application/json" } });
|
|
@@ -824,7 +824,7 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
|
|
|
824
824
|
focusInput();
|
|
825
825
|
}}
|
|
826
826
|
>
|
|
827
|
-
|
|
827
|
+
<i class="ti ti-arrow-left" aria-hidden="true" /> {t().back}
|
|
828
828
|
</Button>
|
|
829
829
|
<div class="cloud-resource-search__preview-app">
|
|
830
830
|
<i class={item().icon ?? item().appIcon} aria-hidden="true" />
|
|
@@ -31,12 +31,21 @@ export const shortcutLabel = (shortcut: string): string => {
|
|
|
31
31
|
if (!combination) return "";
|
|
32
32
|
const mac = isMac();
|
|
33
33
|
const names = { ctrl: mac ? "⌃" : "Ctrl", alt: mac ? "⌥" : "Alt", shift: mac ? "⇧" : "Shift", meta: mac ? "⌘" : "Meta" };
|
|
34
|
-
|
|
34
|
+
const arrows: Record<string, string> = { arrowup: "↑", arrowdown: "↓", arrowleft: "←", arrowright: "→" };
|
|
35
|
+
const key = arrows[combination.key] ?? combination.key.toUpperCase();
|
|
36
|
+
return [...modifiers.filter((name) => combination[name]).map((name) => names[name]), key].join(mac ? "" : "+");
|
|
35
37
|
};
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
const ARROWS = new Set(["arrowup", "arrowdown", "arrowleft", "arrowright"]);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One owner in the Cloud layout; app islands only publish commands. A command
|
|
43
|
+
* on an arrow key comes last: the focused element sees the key first, and a
|
|
44
|
+
* widget that moves with arrow keys, such as a tree that reorders on Alt+↑ or
|
|
45
|
+
* an open list, keeps it by handling it.
|
|
46
|
+
*/
|
|
38
47
|
export const attachCommandShortcuts = (run: (command: ContextAwareCommand) => void): (() => void) => {
|
|
39
|
-
const listener = (event: KeyboardEvent) => {
|
|
48
|
+
const listener = (event: KeyboardEvent, arrows: boolean) => {
|
|
40
49
|
if (event.defaultPrevented || event.repeat || event.isComposing || (!isMac() && event.getModifierState("AltGraph"))) return;
|
|
41
50
|
const target = event.target;
|
|
42
51
|
const editing =
|
|
@@ -47,7 +56,7 @@ export const attachCommandShortcuts = (run: (command: ContextAwareCommand) => vo
|
|
|
47
56
|
if (!command.shortcut) return false;
|
|
48
57
|
if (command.id !== "cloud.search" && dialogOpen) return false;
|
|
49
58
|
const combo = parse(command.shortcut);
|
|
50
|
-
if (!combo || (editing && !combo.ctrl && !combo.meta)) return false;
|
|
59
|
+
if (!combo || ARROWS.has(combo.key) !== arrows || (editing && !combo.ctrl && !combo.meta)) return false;
|
|
51
60
|
// Shift+/ reports '?' on common keyboard layouts.
|
|
52
61
|
const key = event.key === "?" && combo.key === "/" ? "/" : event.key.toLowerCase();
|
|
53
62
|
const physicalLetter =
|
|
@@ -69,8 +78,14 @@ export const attachCommandShortcuts = (run: (command: ContextAwareCommand) => vo
|
|
|
69
78
|
run(matches[0]!);
|
|
70
79
|
};
|
|
71
80
|
// Registered app combinations must win over editor bindings such as Mod+Shift+K.
|
|
72
|
-
|
|
73
|
-
|
|
81
|
+
const first = (event: KeyboardEvent) => listener(event, false);
|
|
82
|
+
const last = (event: KeyboardEvent) => listener(event, true);
|
|
83
|
+
window.addEventListener("keydown", first, true);
|
|
84
|
+
window.addEventListener("keydown", last);
|
|
85
|
+
return () => {
|
|
86
|
+
window.removeEventListener("keydown", first, true);
|
|
87
|
+
window.removeEventListener("keydown", last);
|
|
88
|
+
};
|
|
74
89
|
};
|
|
75
90
|
|
|
76
91
|
export { COMMANDS_CHANGED };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type DialogRender, suppressTextSelection } from "@k2b/ui";
|
|
2
2
|
|
|
3
3
|
export type SpotlightPosition = { x: number; y: number };
|
|
4
4
|
const STORAGE_KEY = "cloud.spotlight.position.v1";
|
|
@@ -42,6 +42,7 @@ export const attachSpotlightPosition = (
|
|
|
42
42
|
origin: SpotlightPosition;
|
|
43
43
|
previous: SpotlightPosition | null;
|
|
44
44
|
handle: HTMLElement;
|
|
45
|
+
releaseTextSelection: () => void;
|
|
45
46
|
} | null = null;
|
|
46
47
|
|
|
47
48
|
const home = document.createElement("div");
|
|
@@ -108,6 +109,7 @@ export const attachSpotlightPosition = (
|
|
|
108
109
|
if (!drag) return;
|
|
109
110
|
const handle = drag.handle;
|
|
110
111
|
const id = drag.id;
|
|
112
|
+
drag.releaseTextSelection();
|
|
111
113
|
drag = null;
|
|
112
114
|
if (handle.hasPointerCapture(id)) handle.releasePointerCapture(id);
|
|
113
115
|
delete host.dataset.dragging;
|
|
@@ -132,6 +134,7 @@ export const attachSpotlightPosition = (
|
|
|
132
134
|
origin: { x: rect.x, y: rect.y },
|
|
133
135
|
previous: position,
|
|
134
136
|
handle,
|
|
137
|
+
releaseTextSelection: suppressTextSelection(event.pointerId),
|
|
135
138
|
};
|
|
136
139
|
handle.setPointerCapture(event.pointerId);
|
|
137
140
|
};
|
package/src/browser/widgets.ts
CHANGED
|
@@ -1,23 +1,25 @@
|
|
|
1
|
-
import type { WidgetStreamLine } from "../contracts/widgets";
|
|
1
|
+
import type { DashboardWidgetSize, WidgetStreamLine } from "../contracts/widgets";
|
|
2
2
|
import { LOCALE_HEADER } from "../shared/locale";
|
|
3
3
|
import { NDJSON_CONTENT_TYPE, readNdjsonLines } from "./ndjson";
|
|
4
4
|
|
|
5
|
-
export type { WidgetResponse, WidgetStreamLine, WidgetStreamStatus } from "../contracts/widgets";
|
|
5
|
+
export type { DashboardWidgetSize, WidgetResponse, WidgetStreamLine, WidgetStreamStatus } from "../contracts/widgets";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Asks Core for dashboard widgets and hands over each one as soon as it answers: `start` names the widgets, one
|
|
9
9
|
* `widget` line follows per widget in the order they finish, then `done`. `keys` (`<appId>/<widgetId>`) limits the
|
|
10
|
-
* request, for example to retry one widget; without it Core asks every declared widget.
|
|
11
|
-
* widget
|
|
10
|
+
* request, for example to retry one widget; without it Core asks every declared widget. `sizes` names the size to ask
|
|
11
|
+
* a widget for; a widget without one, or with a size it does not offer, answers in its default size. Aborting `signal`
|
|
12
|
+
* stops every widget Core still waits for. Rejects when the request fails or the stream breaks off.
|
|
12
13
|
*/
|
|
13
14
|
export const streamWidgets = async (options: {
|
|
14
15
|
keys?: readonly string[];
|
|
16
|
+
sizes?: Readonly<Record<string, DashboardWidgetSize>>;
|
|
15
17
|
signal: AbortSignal;
|
|
16
18
|
locale: string;
|
|
17
19
|
onLine: (line: WidgetStreamLine) => void;
|
|
18
20
|
}): Promise<void> => {
|
|
19
21
|
const query = new URLSearchParams();
|
|
20
|
-
for (const key of options.keys ?? []) query.append("widget", key);
|
|
22
|
+
for (const key of options.keys ?? []) query.append("widget", options.sizes?.[key] ? `${key}@${options.sizes[key]}` : key);
|
|
21
23
|
const search = query.toString();
|
|
22
24
|
const response = await fetch(`/api/widgets/v1${search ? `?${search}` : ""}`, {
|
|
23
25
|
signal: options.signal,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { sql } from "bun";
|
|
2
2
|
import type { CapabilityOrigin } from "../contracts/capabilities";
|
|
3
|
+
import { pruneApprovalRequests } from "../services/approvals";
|
|
3
4
|
import { logger } from "../services/logging";
|
|
4
5
|
import { CAPABILITY_CLAIM_RETENTION_HOURS, pruneCapabilityIdempotencyClaims } from "./claims";
|
|
5
6
|
|
|
@@ -280,6 +281,8 @@ export const startCapabilityExecutionMaintenance = (): (() => void) => {
|
|
|
280
281
|
try {
|
|
281
282
|
const removed = await pruneCapabilityExecutions(new Date(Date.now() - CAPABILITY_EXECUTION_RETENTION_DAYS * 86_400_000));
|
|
282
283
|
if (removed > 0) log.info("Pruned expired capability executions", { removed });
|
|
284
|
+
const requests = await pruneApprovalRequests(new Date(Date.now() - CAPABILITY_EXECUTION_RETENTION_DAYS * 86_400_000));
|
|
285
|
+
if (requests > 0) log.info("Pruned expired approval requests", { removed: requests });
|
|
283
286
|
const claims = await pruneCapabilityIdempotencyClaims(new Date(Date.now() - CAPABILITY_CLAIM_RETENTION_HOURS * 3_600_000));
|
|
284
287
|
if (claims > 0) log.info("Pruned expired capability idempotency claims", { removed: claims });
|
|
285
288
|
} catch (error) {
|
package/src/cli/commands.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import type { CloudCliContext, CloudCliFlagValue, CloudCliModule } from "./index";
|
|
3
|
+
import { isCloudCliAlias, isCloudCliShortcuts } from "./plugin";
|
|
3
4
|
|
|
4
5
|
type FlagKind = "string" | "boolean" | "int" | "enum" | "stringList" | "input";
|
|
5
6
|
type ArgKind = "required" | "optional" | "rest";
|
|
@@ -155,6 +156,8 @@ type CommandNode = {
|
|
|
155
156
|
type CliCommandsConfig = {
|
|
156
157
|
name: string;
|
|
157
158
|
summary: string;
|
|
159
|
+
alias?: string;
|
|
160
|
+
shortcuts?: Readonly<Record<string, string>>;
|
|
158
161
|
requiresCloud?: boolean;
|
|
159
162
|
groupSummaries?: Readonly<Record<string, string>>;
|
|
160
163
|
commands: readonly CliCommandDefinition[];
|
|
@@ -241,10 +244,27 @@ export const readCliInput = async (input: CliInputFlagValue, options: ReadCliInp
|
|
|
241
244
|
|
|
242
245
|
export const defineCliCommands = (config: CliCommandsConfig): CloudCliModule => {
|
|
243
246
|
const root = buildCommandTree(config.commands, config.groupSummaries);
|
|
247
|
+
if (config.alias !== undefined && !isCloudCliAlias(config.alias, config.name)) {
|
|
248
|
+
throw new Error("CLI alias must be a lowercase kebab-case name different from the module name");
|
|
249
|
+
}
|
|
250
|
+
let shortcuts: Record<string, string> | undefined;
|
|
251
|
+
if (config.shortcuts !== undefined) {
|
|
252
|
+
if (!isCloudCliShortcuts(config.shortcuts, config.name, config.alias)) {
|
|
253
|
+
throw new Error("CLI shortcuts require unique kebab-case names and non-empty command paths without flags");
|
|
254
|
+
}
|
|
255
|
+
shortcuts = {};
|
|
256
|
+
for (const [name, target] of Object.entries(config.shortcuts)) {
|
|
257
|
+
const segments = target.trim().split(/\s+/);
|
|
258
|
+
if (!findNode(root, segments)?.command) throw new Error(`Unknown CLI shortcut command path: ${target}`);
|
|
259
|
+
shortcuts[name] = segments.join(" ");
|
|
260
|
+
}
|
|
261
|
+
}
|
|
244
262
|
|
|
245
263
|
return {
|
|
246
264
|
name: config.name,
|
|
247
265
|
summary: config.summary,
|
|
266
|
+
...(config.alias !== undefined ? { alias: config.alias } : {}),
|
|
267
|
+
...(shortcuts !== undefined ? { shortcuts } : {}),
|
|
248
268
|
requiresCloud: config.requiresCloud,
|
|
249
269
|
requiresCloudFor: (args) => {
|
|
250
270
|
const helpRequest = normalizeHelpRequest(args, {});
|