@k2b/cloud 0.30.0 → 0.32.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 +7 -4
- package/src/_internal/app-presentation.ts +22 -1
- package/src/_internal/capabilities.ts +2 -2
- package/src/_internal/define-app.ts +17 -5
- package/src/_internal/registry.ts +50 -6
- package/src/_internal/widget-declarations.ts +53 -0
- package/src/ai/app-skills.ts +1 -7
- package/src/ai/approval-routes.ts +40 -4
- package/src/ai/approvals.ts +68 -10
- package/src/ai/audio-tool.ts +2 -1
- package/src/ai/browser.ts +109 -0
- package/src/ai/capabilities.ts +6 -2
- package/src/ai/chart-block.ts +431 -81
- package/src/ai/chat/action-sentences.ts +13 -7
- package/src/ai/chat/blocks.tsx +64 -39
- package/src/ai/chat/builtin-tools.tsx +9 -7
- package/src/ai/chat/chart-block.tsx +218 -44
- package/src/ai/chat/explorer-block.tsx +276 -0
- package/src/ai/chat/file-tools.tsx +40 -1
- package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
- package/src/ai/chat/message-actions.tsx +9 -1
- package/src/ai/chat/message-utils.ts +7 -0
- package/src/ai/chat/messages.ts +34 -0
- package/src/ai/chat/presentation.tsx +13 -3
- package/src/ai/chat/reference-text.tsx +34 -0
- package/src/ai/chat/tool-groups.ts +67 -12
- package/src/ai/chat/tool-names.ts +18 -2
- package/src/ai/chat/turn-layout.ts +8 -0
- package/src/ai/chat/turn-view.tsx +111 -32
- package/src/ai/chat/view-block.browser-harness.tsx +35 -0
- package/src/ai/chat/view-block.tsx +417 -0
- package/src/ai/chat/visual-tools.tsx +32 -53
- package/src/ai/chat/web-tools.tsx +95 -1
- package/src/ai/client/controller.ts +6 -3
- package/src/ai/client-tool-results.ts +9 -0
- 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/default-tool-contracts.ts +334 -91
- package/src/ai/default-tools.ts +87 -11
- package/src/ai/executor.ts +343 -45
- package/src/ai/fetch-file-tool.ts +41 -4
- package/src/ai/file-error.ts +2 -0
- package/src/ai/file-tools.ts +27 -7
- package/src/ai/files-store.ts +60 -0
- package/src/ai/firecrawl-tools.ts +87 -4
- package/src/ai/html-pdf-tool.ts +3 -2
- package/src/ai/http.ts +37 -12
- package/src/ai/index.ts +98 -0
- package/src/ai/local-tool-contracts.ts +244 -0
- package/src/ai/malformed-tool-call-repair.ts +144 -0
- package/src/ai/markdown-pdf-tool.ts +2 -1
- package/src/ai/migrate.ts +120 -2
- package/src/ai/openai-api-messages.ts +53 -0
- package/src/ai/openai-api-slots.ts +40 -0
- package/src/ai/openai-api.ts +383 -0
- package/src/ai/protocol.ts +28 -3
- package/src/ai/provider.ts +16 -4
- package/src/ai/request-context.ts +33 -0
- package/src/ai/routes.ts +99 -49
- package/src/ai/runtime.ts +90 -15
- package/src/ai/settings.ts +5 -0
- package/src/ai/skill-catalog.ts +20 -5
- package/src/ai/skill-format.ts +17 -7
- package/src/ai/skill-tool.ts +15 -5
- package/src/ai/skills-routes.ts +20 -6
- package/src/ai/skills.ts +26 -7
- package/src/ai/store.ts +91 -1
- package/src/ai/system-prompt.ts +69 -10
- package/src/ai/timeline.ts +3 -1
- package/src/ai/tool-audit.ts +11 -1
- package/src/ai/tools.ts +94 -3
- package/src/ai/types.ts +48 -0
- package/src/ai/view-block.ts +941 -0
- package/src/ai/vision-tool.ts +2 -1
- package/src/ai/web-provenance.ts +103 -0
- package/src/api/admin-identity.ts +6 -1
- package/src/api/app-approval-actions.ts +36 -0
- package/src/api/app-approval.ts +10 -2
- package/src/api/approvals-loader.ts +15 -0
- package/src/api/approvals.ts +120 -0
- package/src/api/capabilities.ts +3 -2
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +12 -3
- package/src/api/widgets.ts +36 -5
- package/src/approvals/Approvals.browser-harness.tsx +16 -0
- package/src/approvals/Approvals.tsx +621 -0
- package/src/approvals/ApprovalsNavStatus.tsx +20 -0
- package/src/approvals/messages.ts +95 -0
- package/src/approvals/ui.ts +8 -0
- package/src/browser/CloudResourceSearch.tsx +1 -1
- package/src/browser/app-approval.ts +32 -0
- package/src/browser/choose-files.tsx +26 -13
- package/src/browser/command-shortcuts.ts +21 -6
- package/src/browser/commands.ts +13 -4
- package/src/browser/file-provider-error.ts +11 -0
- package/src/browser/file-providers.ts +3 -12
- package/src/browser/files.tsx +1 -2
- package/src/browser/live-connection.ts +2 -3
- package/src/browser/provider-list.ts +2 -1
- package/src/browser/save-files.tsx +13 -2
- 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/account.ts +21 -2
- package/src/cli/commands.ts +24 -2
- package/src/cli/index.ts +16 -0
- package/src/cli/plugin.ts +30 -0
- package/src/contracts/app-approval.ts +120 -0
- package/src/contracts/app.ts +34 -2
- package/src/contracts/approval-paths.ts +4 -0
- package/src/contracts/approvals.ts +81 -0
- package/src/contracts/command-path.ts +17 -0
- package/src/contracts/commands.ts +5 -15
- package/src/contracts/registry.ts +9 -1
- package/src/contracts/shared.ts +5 -0
- package/src/contracts/widgets.ts +47 -2
- package/src/desktop/solid.tsx +6 -7
- package/src/events/live-protocol.ts +2 -1
- package/src/events/live-server-message.ts +40 -0
- package/src/events/testing.ts +88 -0
- package/src/server/index.ts +3 -0
- package/src/server/middleware/auth.ts +11 -1
- package/src/server/request-timeout.ts +22 -0
- package/src/server/services/access.ts +4 -3
- package/src/server/widget-request.ts +27 -0
- package/src/services/app-approval.ts +103 -15
- package/src/services/approvals/cursor.ts +15 -0
- package/src/services/approvals/device.ts +112 -0
- package/src/services/approvals/index.ts +241 -0
- package/src/services/approvals/list.ts +120 -0
- package/src/services/approvals/migrate.ts +41 -0
- package/src/services/identity/models-only-auth.ts +25 -0
- package/src/services/notifications/email-frame.ts +16 -3
- package/src/services/service-account-credentials.ts +22 -10
- package/src/services/settings/core-settings.ts +27 -0
- package/src/shared/ai-platform-prompt.ts +26 -1
- package/src/shared/app-presentation.ts +11 -0
- package/src/shared/email-html.ts +46 -7
- package/src/shared/index.ts +6 -1
- package/src/shared/markdown/extensions/code.ts +14 -0
- package/src/shared/markdown/index.ts +4 -4
- package/src/ssr/AdminLayout.tsx +4 -7
- package/src/ssr/AppBadge.tsx +4 -4
- package/src/ssr/AppLaunchpad.island.tsx +1 -1
- package/src/ssr/AppLaunchpadPanel.tsx +4 -4
- 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/RailApps.island.tsx +2 -2
- package/src/ssr/app-badges.ts +50 -6
- 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 +265 -0
- package/src/ssr/page-trail.ts +59 -0
- package/src/ssr/platform-messages.ts +4 -0
- package/src/ssr/rail-navigation.ts +5 -3
- package/src/ssr/runtime.ts +6 -1
- package/src/styles/approvals.css +390 -0
- package/src/styles/effects.css +21 -4
- package/src/styles/global.css +39 -3
- package/src/styles/utilities-layout.css +24 -1
- package/src/styles/utilities-navigation.css +6 -18
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { i18n } from "@k2b/stdlib";
|
|
2
|
+
|
|
3
|
+
export const approvalMessages = i18n.define({
|
|
4
|
+
baseLocale: "en",
|
|
5
|
+
messages: {
|
|
6
|
+
en: {
|
|
7
|
+
title: "Approvals",
|
|
8
|
+
open: ({ count }: { count: number }) => `${count.toLocaleString("en")} open`,
|
|
9
|
+
rejectAll: "Reject all",
|
|
10
|
+
rejectAllTitle: "Reject all open approvals?",
|
|
11
|
+
rejectAllBody: ({ count }: { count: number }) =>
|
|
12
|
+
`${count === 1 ? "The open Action does not run." : `None of the ${count.toLocaleString("en")} open Actions runs.`} Where Cloud asked, it continues with your rejection.`,
|
|
13
|
+
emptyTitle: "Nothing is waiting for you.",
|
|
14
|
+
emptyBody: "When an Action needs your approval, it appears here and in the chat it comes from.",
|
|
15
|
+
loading: "Loading approvals…",
|
|
16
|
+
loadFailed: "Approvals could not be loaded.",
|
|
17
|
+
retry: "Try again",
|
|
18
|
+
showMore: "Show more",
|
|
19
|
+
loadMoreFailed: "More approvals could not be loaded.",
|
|
20
|
+
elsewhere: ({ count }: { count: number }) => `More approvals in other apps (${count.toLocaleString("en")})`,
|
|
21
|
+
allApprovals: "All approvals",
|
|
22
|
+
fromChat: ({ title }: { title: string }) => `from chat “${title}”`,
|
|
23
|
+
fromUntitledChat: "from a chat",
|
|
24
|
+
asksEveryTime: "Asks every time",
|
|
25
|
+
expiresIn: ({ time }: { time: string }) => `Expires ${time}`,
|
|
26
|
+
details: "Details",
|
|
27
|
+
reject: "Reject",
|
|
28
|
+
approve: "Approve",
|
|
29
|
+
approveForChat: "Approve for this chat",
|
|
30
|
+
allowWebsiteForChat: "Allow this website in this chat",
|
|
31
|
+
alwaysApprove: "Always approve",
|
|
32
|
+
moreOptions: "More approval options",
|
|
33
|
+
decidedHere: "Just now, decided here",
|
|
34
|
+
notApplied: "Your decision was not applied",
|
|
35
|
+
approved: "Approved",
|
|
36
|
+
rejected: "Rejected",
|
|
37
|
+
decidedElsewhere: "Already decided elsewhere",
|
|
38
|
+
gone: "Expired or withdrawn",
|
|
39
|
+
decisionFailed: "The decision was not saved. Try again.",
|
|
40
|
+
openIn: ({ app }: { app: string }) => `Open in ${app}`,
|
|
41
|
+
editIn: ({ app }: { app: string }) => `Edit in ${app}`,
|
|
42
|
+
statusIn: ({ app }: { app: string }) => `Status in ${app}`,
|
|
43
|
+
preview: "Preview",
|
|
44
|
+
download: "Download",
|
|
45
|
+
exactlyThis: "This approval applies to exactly this Action. If it changes before it runs, Cloud asks again.",
|
|
46
|
+
approvalFor: ({ title }: { title: string }) => `Approval: ${title}`,
|
|
47
|
+
close: "Close",
|
|
48
|
+
waiting: ({ count }: { count: number }) => (count === 0 ? "nothing waiting" : `${count.toLocaleString("en")} waiting`),
|
|
49
|
+
},
|
|
50
|
+
de: {
|
|
51
|
+
title: "Freigaben",
|
|
52
|
+
open: ({ count }) => `${count.toLocaleString("de")} offen`,
|
|
53
|
+
rejectAll: "Alle ablehnen",
|
|
54
|
+
rejectAllTitle: "Alle offenen Freigaben ablehnen?",
|
|
55
|
+
rejectAllBody: ({ count }) =>
|
|
56
|
+
`${count === 1 ? "Die offene Aktion läuft nicht." : `Keine der ${count.toLocaleString("de")} offenen Aktionen läuft.`} Wo Cloud gefragt hat, geht es mit deiner Ablehnung weiter.`,
|
|
57
|
+
emptyTitle: "Nichts wartet auf dich.",
|
|
58
|
+
emptyBody: "Wenn eine Aktion deine Freigabe braucht, erscheint sie hier und im Chat, aus dem sie kommt.",
|
|
59
|
+
loading: "Freigaben werden geladen …",
|
|
60
|
+
loadFailed: "Die Freigaben konnten nicht geladen werden.",
|
|
61
|
+
retry: "Erneut versuchen",
|
|
62
|
+
showMore: "Mehr anzeigen",
|
|
63
|
+
loadMoreFailed: "Weitere Freigaben konnten nicht geladen werden.",
|
|
64
|
+
elsewhere: ({ count }) => `Weitere Freigaben in anderen Apps (${count.toLocaleString("de")})`,
|
|
65
|
+
allApprovals: "Alle Freigaben",
|
|
66
|
+
fromChat: ({ title }) => `aus Chat „${title}“`,
|
|
67
|
+
fromUntitledChat: "aus einem Chat",
|
|
68
|
+
asksEveryTime: "Fragt immer",
|
|
69
|
+
expiresIn: ({ time }) => `Läuft ab ${time}`,
|
|
70
|
+
details: "Details",
|
|
71
|
+
reject: "Ablehnen",
|
|
72
|
+
approve: "Freigeben",
|
|
73
|
+
approveForChat: "Für diesen Chat freigeben",
|
|
74
|
+
allowWebsiteForChat: "Diese Website in diesem Chat erlauben",
|
|
75
|
+
alwaysApprove: "Immer freigeben",
|
|
76
|
+
moreOptions: "Weitere Freigabeoptionen",
|
|
77
|
+
decidedHere: "Gerade eben, hier entschieden",
|
|
78
|
+
notApplied: "Deine Entscheidung wurde nicht übernommen",
|
|
79
|
+
approved: "Freigegeben",
|
|
80
|
+
rejected: "Abgelehnt",
|
|
81
|
+
decidedElsewhere: "Schon anderswo entschieden",
|
|
82
|
+
gone: "Abgelaufen oder zurückgezogen",
|
|
83
|
+
decisionFailed: "Die Entscheidung wurde nicht gespeichert. Versuche es erneut.",
|
|
84
|
+
openIn: ({ app }) => `In ${app} öffnen`,
|
|
85
|
+
editIn: ({ app }) => `In ${app} bearbeiten`,
|
|
86
|
+
statusIn: ({ app }) => `Status in ${app}`,
|
|
87
|
+
preview: "Vorschau",
|
|
88
|
+
download: "Herunterladen",
|
|
89
|
+
exactlyThis: "Diese Freigabe gilt genau für diese Aktion. Ändert sie sich, bevor sie läuft, fragt Cloud neu.",
|
|
90
|
+
approvalFor: ({ title }) => `Freigabe: ${title}`,
|
|
91
|
+
close: "Schließen",
|
|
92
|
+
waiting: ({ count }) => (count === 0 ? "nichts offen" : `${count.toLocaleString("de")} offen`),
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared list of pending Action approvals. Every app mounts the same component for its own scope; Core serves the
|
|
3
|
+
* route behind it and decides through the platform's approval requests.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type { ApprovalsProps } from "./Approvals";
|
|
7
|
+
export { default as Approvals } from "./Approvals";
|
|
8
|
+
export { approvalNavCount, default as ApprovalsNavStatus } from "./ApprovalsNavStatus";
|
|
@@ -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" />
|
|
@@ -4,9 +4,12 @@ export { type AppVaultBlob, type AppVaultConfig, AppVaultError, type AppVaultMet
|
|
|
4
4
|
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import {
|
|
7
|
+
APP_ACTION_LIMITS,
|
|
7
8
|
APP_APPROVAL_LIMITS,
|
|
8
9
|
APP_APPROVAL_PATH,
|
|
9
10
|
APP_APPROVAL_PROTOCOL,
|
|
11
|
+
type AppActionView,
|
|
12
|
+
AppActionViewSchema,
|
|
10
13
|
AppDeviceCommandSchema,
|
|
11
14
|
AppDeviceProofSchema,
|
|
12
15
|
type AppDevicePublicKey,
|
|
@@ -19,6 +22,7 @@ import {
|
|
|
19
22
|
AppPairingResultSchema,
|
|
20
23
|
type AppPendingLogin,
|
|
21
24
|
AppPendingLoginSchema,
|
|
25
|
+
appActionDigest,
|
|
22
26
|
appDeviceProofMessage,
|
|
23
27
|
appPairingLink,
|
|
24
28
|
appPairingProofMessage,
|
|
@@ -84,6 +88,8 @@ const InfoSchema = z.object({
|
|
|
84
88
|
appOrigin: z.string(),
|
|
85
89
|
algorithm: z.literal("ES256"),
|
|
86
90
|
limits: z.object(Object.fromEntries(Object.entries(APP_APPROVAL_LIMITS).map(([key, value]) => [key, z.literal(value)]))),
|
|
91
|
+
/** Absent on Clouds that serve only the first version's commands. */
|
|
92
|
+
features: z.array(z.string().max(40)).max(16).optional(),
|
|
87
93
|
});
|
|
88
94
|
|
|
89
95
|
/** Call only after the user explicitly trusts the issuer from a pairing link.
|
|
@@ -215,6 +221,31 @@ const connect = async (options: {
|
|
|
215
221
|
throw new AppApprovalClientError("INVALID_RESPONSE");
|
|
216
222
|
return result;
|
|
217
223
|
},
|
|
224
|
+
/** Whether this Cloud serves `inbox`, `action` and `act`. Older Clouds answer those commands with HTTP 400. */
|
|
225
|
+
actions: info.features?.includes("actions") === true,
|
|
226
|
+
/** Pending sign-ins and the newest open Action approvals in one call; use it instead of `pending` where `actions` is true. */
|
|
227
|
+
inbox: async (device: AppApprovalDevice, signal?: AbortSignal) => {
|
|
228
|
+
const result = await command(device, { operation: "inbox" }, signal);
|
|
229
|
+
if (!("actions" in result)) throw new AppApprovalClientError("INVALID_RESPONSE");
|
|
230
|
+
return result;
|
|
231
|
+
},
|
|
232
|
+
/** One open Action approval. The digest is recomputed here, so a view that does not match it is never returned. */
|
|
233
|
+
action: async (device: AppApprovalDevice, actionId: string, signal?: AbortSignal): Promise<AppActionView> => {
|
|
234
|
+
const result = await command(device, { operation: "action", actionId }, signal);
|
|
235
|
+
if (!("digest" in result) || result.actionId !== actionId || (await appActionDigest(issuer, result)) !== result.digest)
|
|
236
|
+
throw new AppApprovalClientError("INVALID_RESPONSE");
|
|
237
|
+
return result;
|
|
238
|
+
},
|
|
239
|
+
/** Signs the decision over the view's digest. Only call it on an explicit choice of the person. */
|
|
240
|
+
act: async (device: AppApprovalDevice, view: AppActionView, decision: "approve" | "deny", signal?: AbortSignal) => {
|
|
241
|
+
view = AppActionViewSchema.parse(view);
|
|
242
|
+
if (Date.parse(view.expiresAt) <= Date.now() || (await appActionDigest(issuer, view)) !== view.digest)
|
|
243
|
+
throw new AppApprovalClientError("INVALID_INPUT");
|
|
244
|
+
const result = await command(device, { operation: "act", actionId: view.actionId, digest: view.digest, decision }, signal);
|
|
245
|
+
if (!("state" in result) || result.state !== (decision === "approve" ? "approved" : "denied"))
|
|
246
|
+
throw new AppApprovalClientError("INVALID_RESPONSE");
|
|
247
|
+
return result;
|
|
248
|
+
},
|
|
218
249
|
/** Reads the account this device signs in and the device's own record. Older Clouds answer HTTP 400. */
|
|
219
250
|
account: async (device: AppApprovalDevice, signal?: AbortSignal) => {
|
|
220
251
|
const result = await command(device, { operation: "account" }, signal);
|
|
@@ -243,4 +274,5 @@ export const appApproval = {
|
|
|
243
274
|
createKey,
|
|
244
275
|
connect,
|
|
245
276
|
limits: APP_APPROVAL_LIMITS,
|
|
277
|
+
actionLimits: APP_ACTION_LIMITS,
|
|
246
278
|
};
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { showFileDialog } from "@k2b/stdlib/browser";
|
|
2
2
|
import { dialogCore, panelDialogWorkspaceOptions } from "@k2b/ui";
|
|
3
3
|
import { createSignal } from "solid-js";
|
|
4
|
-
import {
|
|
4
|
+
import type { ChooseFilesOptions, FileProviderList } from "./FileChooser";
|
|
5
5
|
import type { FileProviderCaller } from "./file-providers";
|
|
6
6
|
import type { ProviderList } from "./provider-list";
|
|
7
|
+
import { importOnDemand } from "./reload";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* One page's file choosing over its provider list. `caller` carries the locale and, in tests, the transport.
|
|
@@ -39,18 +40,30 @@ export const createFileChoosing = (
|
|
|
39
40
|
);
|
|
40
41
|
};
|
|
41
42
|
if (!known) load();
|
|
42
|
-
return
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
signal
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
)
|
|
53
|
-
|
|
43
|
+
return (async () => {
|
|
44
|
+
let abort = () => {};
|
|
45
|
+
const chooser = await Promise.race([
|
|
46
|
+
importOnDemand(() => import("./FileChooser")),
|
|
47
|
+
new Promise<undefined>((resolve) => {
|
|
48
|
+
abort = () => resolve(undefined);
|
|
49
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
50
|
+
if (signal?.aborted) abort();
|
|
51
|
+
}),
|
|
52
|
+
]).finally(() => signal?.removeEventListener("abort", abort));
|
|
53
|
+
if (!chooser) return [];
|
|
54
|
+
return dialogCore
|
|
55
|
+
.open<File[]>(
|
|
56
|
+
(close) => <chooser.FileChooser options={options} providers={providers} retryProviders={load} caller={caller} close={close} />,
|
|
57
|
+
{
|
|
58
|
+
...panelDialogWorkspaceOptions,
|
|
59
|
+
panelClassName: `${panelDialogWorkspaceOptions.panelClassName} cloud-file-chooser-dialog`,
|
|
60
|
+
initialFocus: (dialog) => dialog.querySelector<HTMLElement>('[role="gridcell"][tabindex="0"]'),
|
|
61
|
+
signal: options.signal,
|
|
62
|
+
history: true,
|
|
63
|
+
},
|
|
64
|
+
)
|
|
65
|
+
.then((files) => files ?? []);
|
|
66
|
+
})();
|
|
54
67
|
};
|
|
55
68
|
|
|
56
69
|
return chooseFiles;
|
|
@@ -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 };
|
package/src/browser/commands.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { toast } from "@k2b/ui";
|
|
2
|
-
import {
|
|
3
|
-
import { type CommandOptions, CommandOptionsSchema, clearCommand, readCommand } from "../contracts/commands";
|
|
2
|
+
import type { CommandOptions } from "../contracts/commands";
|
|
4
3
|
import {
|
|
5
4
|
type ContextAwareCommand,
|
|
6
5
|
collectContextAwareCommands,
|
|
7
6
|
requestCommandHandling,
|
|
8
7
|
requestContextCommandExecution,
|
|
9
8
|
} from "./command-bridge";
|
|
9
|
+
import { importOnDemand } from "./reload";
|
|
10
10
|
import { resourceSearchMessages } from "./resource-search-messages";
|
|
11
11
|
import { requestGlobalSearch } from "./search-bridge";
|
|
12
12
|
|
|
@@ -16,17 +16,26 @@ export { registerCommandHandler, registerContextAwareCommand } from "./command-b
|
|
|
16
16
|
|
|
17
17
|
/** Opens locally only when a mounted owner explicitly accepts; otherwise follows the app's URL. */
|
|
18
18
|
export const openCommand = async (command: string, input: unknown = {}, options: CommandOptions = {}): Promise<void> => {
|
|
19
|
-
const
|
|
19
|
+
const modules = await importOnDemand(() => Promise.all([import("../contracts/commands"), import("../capabilities/client")]));
|
|
20
|
+
if (!modules) return;
|
|
21
|
+
const [contracts, capabilities] = modules;
|
|
22
|
+
const parsedOptions = contracts.CommandOptionsSchema.parse(options);
|
|
20
23
|
const handled = requestCommandHandling({ command, input, options: parsedOptions });
|
|
21
24
|
if (handled) return handled;
|
|
22
|
-
const result = await resolveCommand(command, input, parsedOptions);
|
|
25
|
+
const result = await capabilities.resolveCommand(command, input, parsedOptions);
|
|
23
26
|
if (!result.ok) throw new Error(result.error.message);
|
|
24
27
|
window.location.assign(result.data.href);
|
|
25
28
|
};
|
|
26
29
|
|
|
27
30
|
/** Call after this page's handlers have mounted. Each entry is consumed, including invalid links. */
|
|
28
31
|
export const consumeCommandLink = async (): Promise<void> => {
|
|
32
|
+
const command = new URL(window.location.href).searchParams.get("command");
|
|
33
|
+
if (!command) return;
|
|
34
|
+
const contracts = await importOnDemand(() => import("../contracts/commands"));
|
|
35
|
+
if (!contracts) return;
|
|
29
36
|
const url = new URL(window.location.href);
|
|
37
|
+
if (url.searchParams.get("command") !== command) return;
|
|
38
|
+
const { clearCommand, readCommand } = contracts;
|
|
30
39
|
const messages = resourceSearchMessages.resolve([document.documentElement.lang]).t;
|
|
31
40
|
let target: ReturnType<typeof readCommand>;
|
|
32
41
|
try {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** A provider failure the chooser explains; `status` 0 means the browser is offline. */
|
|
2
|
+
export class FileProviderError extends Error {
|
|
3
|
+
constructor(
|
|
4
|
+
readonly code: string,
|
|
5
|
+
message: string,
|
|
6
|
+
readonly status: number,
|
|
7
|
+
) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "FileProviderError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -15,6 +15,9 @@ import {
|
|
|
15
15
|
fileProviderIssues,
|
|
16
16
|
} from "../contracts/file-provider";
|
|
17
17
|
import { LOCALE_HEADER } from "../shared/locale";
|
|
18
|
+
import { FileProviderError } from "./file-provider-error";
|
|
19
|
+
|
|
20
|
+
export { FileProviderError } from "./file-provider-error";
|
|
18
21
|
|
|
19
22
|
/** One live application whose catalog manifest offers files, reduced to what choosing a file needs. */
|
|
20
23
|
export type FileProviderSource = {
|
|
@@ -31,18 +34,6 @@ export type FileProviderSource = {
|
|
|
31
34
|
|
|
32
35
|
export type FileProviderCaller = Pick<CapabilityHttpOptions, "fetch" | "baseUrl"> & { locale: string };
|
|
33
36
|
|
|
34
|
-
/** A provider failure the chooser explains; `status` 0 means the browser is offline. */
|
|
35
|
-
export class FileProviderError extends Error {
|
|
36
|
-
constructor(
|
|
37
|
-
readonly code: string,
|
|
38
|
-
message: string,
|
|
39
|
-
readonly status: number,
|
|
40
|
-
) {
|
|
41
|
-
super(message);
|
|
42
|
-
this.name = "FileProviderError";
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
37
|
/** The page size the chooser asks for; the contract allows 1 to 100. */
|
|
47
38
|
export const FILE_PROVIDER_PAGE_SIZE = 50;
|
|
48
39
|
/** At most this many provider reads run at the same time. */
|
package/src/browser/files.tsx
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { createFileChoosing } from "./choose-files";
|
|
2
|
-
import { loadFileProviders } from "./file-providers";
|
|
3
2
|
import { createProviderList } from "./provider-list";
|
|
4
3
|
import { createFileSaving } from "./save-files";
|
|
5
4
|
|
|
@@ -7,7 +6,7 @@ export type { ChooseFilesOptions } from "./FileChooser";
|
|
|
7
6
|
export { SAVE_FILES_ICON, type SavedFile, type SaveFileSource, type SaveFilesOptions } from "./save-files";
|
|
8
7
|
|
|
9
8
|
const pageLocale = () => (typeof document === "undefined" ? "en" : document.documentElement.lang || "en");
|
|
10
|
-
const providers = createProviderList(() => loadFileProviders({ locale: pageLocale() }));
|
|
9
|
+
const providers = createProviderList(async () => (await import("./file-providers")).loadFileProviders({ locale: pageLocale() }));
|
|
11
10
|
const saving = createFileSaving(providers);
|
|
12
11
|
|
|
13
12
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type LiveServerMessage,
|
|
1
|
+
import { type LiveServerMessage, parseLiveServerMessage } from "../events/live-server-message";
|
|
2
2
|
import { createLiveWebSocket, type LiveWebSocket } from "./live-websocket";
|
|
3
3
|
|
|
4
4
|
export type LiveSubscriptionHandlers<T> = {
|
|
@@ -41,8 +41,7 @@ const shared = new Map<string, Shared>();
|
|
|
41
41
|
|
|
42
42
|
const parseMessage = (raw: string): LiveServerMessage | null => {
|
|
43
43
|
try {
|
|
44
|
-
|
|
45
|
-
return parsed.success ? parsed.data : null;
|
|
44
|
+
return parseLiveServerMessage(JSON.parse(raw));
|
|
46
45
|
} catch {
|
|
47
46
|
return null;
|
|
48
47
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createSignal } from "solid-js";
|
|
2
|
-
import { FileProviderError
|
|
2
|
+
import { FileProviderError } from "./file-provider-error";
|
|
3
|
+
import type { FileProviderSource } from "./file-providers";
|
|
3
4
|
|
|
4
5
|
/** The catalog refuses the caller, such as a visitor of a public page or a session that has just expired. */
|
|
5
6
|
const refused = (error: unknown) => error instanceof FileProviderError && (error.status === 401 || error.status === 403);
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { dialogCore, IconButton, type IconButtonProps, panelDialogWorkspaceOptions, toast, useLocale } from "@k2b/ui";
|
|
2
2
|
import { createSignal, type JSX, splitProps } from "solid-js";
|
|
3
3
|
import type { FileProviderList } from "./FileChooser";
|
|
4
|
-
import {
|
|
4
|
+
import type { SavedFile } from "./FileSaver";
|
|
5
5
|
import { fileChooserMessages } from "./file-chooser-messages";
|
|
6
6
|
import type { FileProviderCaller, SaveFileSource } from "./file-providers";
|
|
7
7
|
import type { ProviderList } from "./provider-list";
|
|
8
|
+
import { importOnDemand } from "./reload";
|
|
8
9
|
|
|
9
10
|
export type { SavedFile } from "./FileSaver";
|
|
10
11
|
export type { SaveFileSource } from "./file-providers";
|
|
@@ -40,9 +41,19 @@ export const createFileSaving = (
|
|
|
40
41
|
if (!known) load();
|
|
41
42
|
const saved: SavedFile[] = [];
|
|
42
43
|
const running = new Set<Promise<void>>();
|
|
44
|
+
let abort = () => {};
|
|
45
|
+
const saver = await Promise.race([
|
|
46
|
+
importOnDemand(() => import("./FileSaver")),
|
|
47
|
+
new Promise<undefined>((resolve) => {
|
|
48
|
+
abort = () => resolve(undefined);
|
|
49
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
50
|
+
if (options.signal?.aborted) abort();
|
|
51
|
+
}),
|
|
52
|
+
]).finally(() => options.signal?.removeEventListener("abort", abort));
|
|
53
|
+
if (!saver) return [];
|
|
43
54
|
await dialogCore.open<void>(
|
|
44
55
|
(close) => (
|
|
45
|
-
<FileSaver
|
|
56
|
+
<saver.FileSaver
|
|
46
57
|
files={files}
|
|
47
58
|
providers={providers}
|
|
48
59
|
retryProviders={load}
|
|
@@ -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/account.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { isModelsOnlyApiKey } from "../contracts/shared";
|
|
2
3
|
import { arg, type CliInputFlagValue, type CloudCliContext, command, confirmFlag, defineCliCommands, flag, readCliInput } from "./index";
|
|
3
4
|
|
|
4
5
|
type User = {
|
|
@@ -31,6 +32,7 @@ type User = {
|
|
|
31
32
|
};
|
|
32
33
|
|
|
33
34
|
type ApiKey = {
|
|
35
|
+
scopes: string[];
|
|
34
36
|
id: string;
|
|
35
37
|
name: string;
|
|
36
38
|
status: "active" | "revoked";
|
|
@@ -121,6 +123,7 @@ const apiKeyRows = (keys: ApiKey[]) =>
|
|
|
121
123
|
id: key.id,
|
|
122
124
|
name: key.name,
|
|
123
125
|
prefix: key.tokenPrefix,
|
|
126
|
+
access: isModelsOnlyApiKey(key) ? "models" : "full",
|
|
124
127
|
expires: key.expiresAt ?? "never",
|
|
125
128
|
lastUsed: key.lastUsedAt ?? "never",
|
|
126
129
|
created: key.createdAt,
|
|
@@ -283,6 +286,7 @@ export default defineCliCommands({
|
|
|
283
286
|
printJsonOrTable(ctx, result, apiKeyRows(result.items), [
|
|
284
287
|
{ key: "name" },
|
|
285
288
|
{ key: "prefix" },
|
|
289
|
+
{ key: "access" },
|
|
286
290
|
{ key: "expires" },
|
|
287
291
|
{ key: "lastUsed" },
|
|
288
292
|
{ key: "id" },
|
|
@@ -294,15 +298,30 @@ export default defineCliCommands({
|
|
|
294
298
|
args: { name: arg.required({ valueLabel: "name", description: "API key name" }) },
|
|
295
299
|
flags: {
|
|
296
300
|
expires: flag.string({ description: "ISO timestamp or duration like 90d" }),
|
|
301
|
+
access: flag.enum(["full", "models"], { default: "full", description: "Full access or models-only inference access" }),
|
|
297
302
|
},
|
|
298
303
|
run: async ({ ctx, args, flags }) => {
|
|
299
304
|
const result = await readApi<{ credential: ApiKey; token: string }>(
|
|
300
305
|
ctx,
|
|
301
306
|
"/api-keys",
|
|
302
|
-
jsonRequest("POST", { name: args.name, expiresAt: parseExpiry(flags.expires) }),
|
|
307
|
+
jsonRequest("POST", { name: args.name, expiresAt: parseExpiry(flags.expires), access: flags.access }),
|
|
303
308
|
);
|
|
309
|
+
const modelsOnly = isModelsOnlyApiKey(result.credential);
|
|
310
|
+
if (flags.access === "models" && !modelsOnly) {
|
|
311
|
+
try {
|
|
312
|
+
await readApi<{ message: string }>(ctx, `/api-keys/${encodeURIComponent(result.credential.id)}`, jsonRequest("DELETE"));
|
|
313
|
+
} catch {
|
|
314
|
+
throw new Error(
|
|
315
|
+
`The key was created with full access and could not be revoked automatically. Run: cld account api-keys revoke ${result.credential.id} --yes`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
throw new Error("This Cloud does not support models-only API keys. The full-access key it created was revoked.");
|
|
319
|
+
}
|
|
304
320
|
if (ctx.options.output === "json") ctx.json(result);
|
|
305
|
-
else
|
|
321
|
+
else {
|
|
322
|
+
ctx.print(`Token: ${result.token}\nPrefix: ${result.credential.tokenPrefix}\nStore this token now. It cannot be shown again.`);
|
|
323
|
+
if (modelsOnly) ctx.print("This key works only with the OpenAI-compatible API at /api/ai/openai/v1 on this Cloud.");
|
|
324
|
+
}
|
|
306
325
|
},
|
|
307
326
|
}),
|
|
308
327
|
command("api-keys revoke", {
|
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, {});
|
|
@@ -597,8 +617,10 @@ const renderFlagUsage = (key: string, spec: CliFlagSpec): string => {
|
|
|
597
617
|
|
|
598
618
|
const renderFlagDescription = (spec: CliFlagSpec): string => {
|
|
599
619
|
const parts = [spec.description];
|
|
600
|
-
|
|
601
|
-
|
|
620
|
+
const hasDefault = "default" in spec && spec.default !== undefined;
|
|
621
|
+
if ("required" in spec && spec.required && !hasDefault) parts.push("Required.");
|
|
622
|
+
if (hasDefault) parts.push(`Default: ${Array.isArray(spec.default) ? spec.default.join(",") : spec.default}.`);
|
|
602
623
|
if (spec.kind === "enum") parts.push(`Values: ${spec.values.join(", ")}.`);
|
|
624
|
+
if (parts.length > 1 && parts[0] && !/[.!?:]$/.test(parts[0])) parts[0] += ".";
|
|
603
625
|
return parts.filter(Boolean).join(" ");
|
|
604
626
|
};
|