@settlemint/dalp-cli 3.1.33-main.43433008600 → 3.1.33-release.43440124932

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/src/hosted-mcp.ts DELETED
@@ -1,145 +0,0 @@
1
- import { fromJsonSchema, type McpServer } from "@modelcontextprotocol/server";
2
- import type { DalpClient } from "@settlemint/dalp-sdk";
3
- import { Cli, Mcp, z } from "incur";
4
- import { listCliOperations, type OperationDescriptor } from "./lib/operation-inventory.ts";
5
- import { registerGeneratedOperations } from "./lib/register-operations.ts";
6
- import { CLI_VERSION, cliVars } from "./lib/vars.ts";
7
-
8
- export type { OperationDescriptor };
9
-
10
- export const HOSTED_MCP_VERSION: string = CLI_VERSION;
11
-
12
- export const HOSTED_EXECUTION_MODES: readonly ["inbox", "mandate"] = ["inbox", "mandate"];
13
- export type HostedExecutionMode = (typeof HOSTED_EXECUTION_MODES)[number];
14
-
15
- export const PREPARE_GROUP = "prepare";
16
-
17
- export const PREPARE_GROUP_DESCRIPTION =
18
- "Prepare a write for a person to review and sign in DALP. With execution mandate, a tool that runs under a mandate executes the write at once as the organisation's agent principal, and no person signs.";
19
-
20
- export interface PrepareToolField {
21
- readonly description: string;
22
- readonly required: boolean;
23
- readonly options?: ReadonlyArray<string>;
24
- }
25
-
26
- export interface PrepareTool {
27
- readonly name: string;
28
- readonly description: string;
29
- readonly fields: Readonly<Record<string, PrepareToolField>>;
30
- readonly runsUnderMandate: boolean;
31
- }
32
-
33
- export interface PrepareCall {
34
- readonly tool: string;
35
- readonly assetId: string;
36
- readonly fields: Readonly<Record<string, string>>;
37
- readonly execution: HostedExecutionMode;
38
- readonly agentId: string | undefined;
39
- readonly idempotencyKey: string | undefined;
40
- }
41
-
42
- export type PrepareOutcome =
43
- | { readonly prepared: unknown }
44
- | { readonly refused: { readonly code: string; readonly message: string } };
45
-
46
- export type PrepareExecutor = (call: PrepareCall) => Promise<PrepareOutcome>;
47
-
48
- export interface HostedMcpRequest {
49
- readonly client: DalpClient;
50
- readonly prepare: PrepareExecutor;
51
- }
52
-
53
- export interface HostedMcpTools {
54
- readonly reads: ReadonlyArray<OperationDescriptor>;
55
- readonly register: (server: McpServer, request: HostedMcpRequest) => void;
56
- }
57
-
58
- const hostedVars = cliVars.extend({ prepare: z.custom<PrepareExecutor>().optional() });
59
-
60
- const executionOption = z
61
- .enum(HOSTED_EXECUTION_MODES)
62
- .default("inbox")
63
- .describe(
64
- "inbox (default): post the prepared write to the member's DALP inbox, where a person reviews and signs it. mandate: execute the write at once as the organisation's agent principal, only inside its mandate, and no person signs."
65
- );
66
-
67
- const fieldOption = (field: PrepareToolField) => {
68
- const value = field.options === undefined ? z.string() : z.enum(field.options);
69
- const described = value.describe(field.description);
70
- return field.required ? described : described.optional();
71
- };
72
-
73
- type PrepareOptions = Readonly<Record<string, string | undefined>>;
74
-
75
- const prepareCommand = (tool: PrepareTool) => ({
76
- description: tool.description,
77
- mcp: { annotations: { readOnlyHint: false, destructiveHint: tool.runsUnderMandate, idempotentHint: false } },
78
- options: z.object({
79
- assetId: z.string().describe("The asset the write acts on: its contract address."),
80
- ...Object.fromEntries(Object.entries(tool.fields).map(([name, field]) => [name, fieldOption(field)])),
81
- execution: executionOption,
82
- agentId: z
83
- .string()
84
- .optional()
85
- .describe("With execution mandate only: the agent principal that acts, its pp_ id from the Agents page."),
86
- idempotencyKey: z
87
- .string()
88
- .optional()
89
- .describe(
90
- "Required with execution mandate: a key you choose for this write and send again, unchanged, on a retry, so the write never runs twice."
91
- ),
92
- }),
93
- run: async (context: {
94
- readonly options: PrepareOptions;
95
- readonly var: { readonly prepare?: PrepareExecutor | undefined };
96
- readonly error: (error: { readonly code: string; readonly message: string }) => unknown;
97
- }) => {
98
- const { assetId, execution, agentId, idempotencyKey, ...rest } = context.options;
99
- if (context.var.prepare === undefined || typeof assetId !== "string") {
100
- return context.error({ code: "PREPARE_UNAVAILABLE", message: "This server does not prepare writes." });
101
- }
102
- const fields = Object.fromEntries(
103
- Object.entries(rest).flatMap(([name, value]) => (typeof value === "string" ? [[name, value]] : []))
104
- );
105
- const outcome = await context.var.prepare({
106
- tool: tool.name,
107
- assetId,
108
- fields,
109
- execution: execution === "mandate" ? "mandate" : "inbox",
110
- agentId,
111
- idempotencyKey,
112
- });
113
- return "refused" in outcome ? context.error(outcome.refused) : outcome.prepared;
114
- },
115
- });
116
-
117
- export function makeHostedMcpTools(input: { readonly prepareTools: ReadonlyArray<PrepareTool> }): HostedMcpTools {
118
- const reads = listCliOperations().filter((descriptor) => descriptor.method === "GET");
119
- const cli = Cli.create("dalp", { version: CLI_VERSION, vars: hostedVars });
120
- registerGeneratedOperations(cli, reads);
121
- const prepare = Cli.create(PREPARE_GROUP, {
122
- description: PREPARE_GROUP_DESCRIPTION,
123
- vars: hostedVars,
124
- });
125
- for (const tool of input.prepareTools) {
126
- prepare.command(tool.name, prepareCommand(tool));
127
- }
128
- cli.command(prepare);
129
- const registered: WeakMap<object, Map<string, unknown>> = Cli.toCommands;
130
- const commands = registered.get(cli) ?? new Map<string, unknown>();
131
- return {
132
- reads,
133
- register: (server, request) =>
134
- Mcp.registerTools(server, commands, {
135
- fromJsonSchema,
136
- name: "dalp",
137
- version: CLI_VERSION,
138
- vars: hostedVars.extend({
139
- client: z.custom<DalpClient>().default(() => request.client),
140
- prepare: z.custom<PrepareExecutor>().default(() => request.prepare),
141
- }),
142
- sendNotification: (notification) => server.server.notification(notification),
143
- }),
144
- };
145
- }
@@ -1,30 +0,0 @@
1
- import { createDalpClient, type CompressionRequestInit, type DalpClient } from "@settlemint/dalp-sdk";
2
- import type { StoredCredentials } from "./credential-store.ts";
3
- import { CLI_USER_AGENT } from "./vars.ts";
4
-
5
- export interface CliGeneratedClientOptions {
6
- readonly fetch?: typeof globalThis.fetch;
7
- }
8
-
9
- const withCliUserAgent = (fetchImpl: typeof globalThis.fetch): typeof globalThis.fetch =>
10
- Object.assign(async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
11
- const request = new Request(input, init);
12
- const headers = new Headers(request.headers);
13
- headers.set("User-Agent", CLI_USER_AGENT);
14
- const compress = (init as CompressionRequestInit | undefined)?.compress;
15
- const transportInit: CompressionRequestInit | undefined = compress === undefined ? undefined : { compress };
16
- return fetchImpl(new Request(request, { headers }), transportInit);
17
- }, fetchImpl);
18
-
19
- export function makeCliClient(
20
- apiUrl: string,
21
- credentials: StoredCredentials,
22
- options: CliGeneratedClientOptions = {}
23
- ): DalpClient {
24
- return createDalpClient({
25
- apiKey: credentials.apiKey,
26
- baseUrl: apiUrl,
27
- fetch: withCliUserAgent(options.fetch ?? globalThis.fetch),
28
- runtime: "server",
29
- });
30
- }
@@ -1,86 +0,0 @@
1
- import { DalpApiError } from "@settlemint/dalp-sdk";
2
- import { z } from "incur";
3
- import type { StoredCredentials } from "./credential-store.ts";
4
- import { CLI_USER_AGENT } from "./vars.ts";
5
-
6
- const TRAILING_SLASHES_PATTERN = /\/+$/u;
7
-
8
- const AUTH_REQUEST_TIMEOUT_MS = 10_000;
9
-
10
- export async function authRequest(
11
- apiUrl: string,
12
- credentials: StoredCredentials,
13
- route: `/${string}`,
14
- init?: { readonly method: "GET" | "POST"; readonly body?: unknown }
15
- ): Promise<unknown> {
16
- const request = init ?? { method: "GET" as const };
17
- const headers = new Headers({ Accept: "application/json", "User-Agent": CLI_USER_AGENT });
18
- if (credentials.apiKey !== undefined) {
19
- headers.set("X-Api-Key", credentials.apiKey);
20
- }
21
- if (request.body !== undefined) {
22
- headers.set("Content-Type", "application/json");
23
- }
24
- const deadline = new AbortController();
25
- const timer = setTimeout(() => deadline.abort(), AUTH_REQUEST_TIMEOUT_MS);
26
- let response: Response;
27
- let text: string;
28
- try {
29
- // fallow-ignore-next-line security-sink -- apiUrl is the saved credentials.apiUrl (credential-store.ts:21, z.url()), written at login from the validated base (login.ts:406, 458). Client-side CLI; the stored host is the user's own instance, not attacker-set.
30
- response = await fetch(`${apiUrl.replace(TRAILING_SLASHES_PATTERN, "")}/api/auth${route}`, {
31
- method: request.method,
32
- headers,
33
- signal: deadline.signal,
34
- ...(request.body === undefined ? {} : { body: JSON.stringify(request.body) }),
35
- });
36
- text = await response.text();
37
- } catch (error) {
38
- if (!deadline.signal.aborted) {
39
- throw error;
40
- }
41
- throw new DalpApiError({
42
- message: `Auth request timed out: ${route}`,
43
- code: "sdk.transport.REQUEST_FAILED",
44
- category: "dependency",
45
- retryable: true,
46
- why: `The auth surface did not answer ${route} within ${String(AUTH_REQUEST_TIMEOUT_MS / 1000)} seconds.`,
47
- fix: "Check that the platform URL is reachable, then retry.",
48
- cause: error,
49
- });
50
- } finally {
51
- clearTimeout(timer);
52
- }
53
- const payload: unknown = text === "" ? undefined : JSON.parse(text);
54
- if (!response.ok) {
55
- throw new DalpApiError({
56
- message: `Auth request failed: ${String(response.status)} ${route}`,
57
- code: `sdk.transport.HTTP_${response.status}`,
58
- category: "dependency",
59
- retryable: response.status >= 500,
60
- why: `The auth surface answered ${String(response.status)} for ${route}.`,
61
- fix: "Check the API key and the platform URL, then retry.",
62
- status: response.status,
63
- cause: payload,
64
- });
65
- }
66
- return payload;
67
- }
68
-
69
- export type KeyRevocation = { readonly _tag: "Revoked" } | { readonly _tag: "Unconfirmed"; readonly cause: unknown };
70
-
71
- const KeyDisabledAnswerSchema = z.object({ code: z.literal("KEY_DISABLED") });
72
-
73
- const keyNoLongerExists = (error: unknown): boolean =>
74
- error instanceof DalpApiError && error.status === 401 && !KeyDisabledAnswerSchema.safeParse(error.cause).success;
75
-
76
- export async function revokeOwnApiKey(credentials: StoredCredentials): Promise<KeyRevocation> {
77
- try {
78
- await authRequest(credentials.apiUrl, credentials, "/api-key/delete", {
79
- method: "POST",
80
- body: { keyId: credentials.apiKeyId },
81
- });
82
- return { _tag: "Revoked" };
83
- } catch (error) {
84
- return keyNoLongerExists(error) ? { _tag: "Revoked" } : { _tag: "Unconfirmed", cause: error };
85
- }
86
- }
@@ -1,104 +0,0 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2
- import { homedir } from "node:os";
3
- import path from "node:path";
4
- import { z } from "zod";
5
- import { makeCliError } from "./errors.ts";
6
-
7
- const PROJECT_CONFIG_FILE = ".dalprc.json";
8
-
9
- const DalpConfigSchema = z.object({
10
- apiUrl: z.string().optional(),
11
- defaultOrg: z.string().optional(),
12
- format: z.enum(["toon", "json", "yaml", "md"]).optional(),
13
- });
14
-
15
- export type DalpConfig = z.infer<typeof DalpConfigSchema>;
16
-
17
- export function getConfigDir(): string {
18
- const override = process.env.DALP_CONFIG_DIR;
19
- if (override !== undefined && override.trim() !== "") {
20
- return override;
21
- }
22
- return path.join(homedir(), ".config", "dalp");
23
- }
24
-
25
- export interface CliConfigPaths {
26
- readonly configDir?: string;
27
- readonly cwd?: string;
28
- readonly environment?: () => DalpConfig;
29
- }
30
-
31
- const defaultEnvironment = (): DalpConfig => ({
32
- ...(process.env.DALP_URL === undefined || process.env.DALP_URL === "" ? {} : { apiUrl: process.env.DALP_URL }),
33
- ...(process.env.DALP_ORG === undefined || process.env.DALP_ORG === "" ? {} : { defaultOrg: process.env.DALP_ORG }),
34
- });
35
-
36
- const mergeConfig = (
37
- globalConfig: DalpConfig,
38
- projectConfig: DalpConfig,
39
- environmentConfig: DalpConfig,
40
- overrides: Partial<DalpConfig>
41
- ): DalpConfig => ({
42
- ...globalConfig,
43
- ...projectConfig,
44
- ...environmentConfig,
45
- ...Object.fromEntries(Object.entries(overrides).filter(([, value]) => value !== undefined)),
46
- });
47
-
48
- async function readConfigFile(file: string): Promise<DalpConfig> {
49
- try {
50
- const raw = await readFile(file, "utf8");
51
- return DalpConfigSchema.parse(JSON.parse(raw));
52
- } catch (error) {
53
- if (error instanceof Error && "code" in error && error.code === "ENOENT") {
54
- return {};
55
- }
56
- if (error instanceof z.ZodError) {
57
- throw makeCliError({
58
- code: "kit.cli.CONFIG_INVALID",
59
- message: `Invalid config at ${file}: ${error.message}`,
60
- why: "The configuration file does not match the expected schema.",
61
- fix: `Edit ${file} or delete it and re-run 'dalp config set'.`,
62
- cause: error,
63
- });
64
- }
65
- throw makeCliError({
66
- code: "kit.cli.CONFIG_READ_FAILED",
67
- message: error instanceof Error ? error.message : "Failed to read CLI configuration",
68
- cause: error,
69
- });
70
- }
71
- }
72
-
73
- async function ensureConfigDir(configDir: string = getConfigDir()): Promise<void> {
74
- await mkdir(configDir, { recursive: true, mode: 0o700 });
75
- }
76
-
77
- export async function loadGlobalConfig(configDir: string = getConfigDir()): Promise<DalpConfig> {
78
- return readConfigFile(path.join(configDir, "config.json"));
79
- }
80
-
81
- async function loadProjectConfig(cwd: string = process.cwd()): Promise<DalpConfig> {
82
- return readConfigFile(path.join(cwd, PROJECT_CONFIG_FILE));
83
- }
84
-
85
- export async function resolveConfig(
86
- overrides: Partial<DalpConfig> = {},
87
- paths: CliConfigPaths = {}
88
- ): Promise<DalpConfig> {
89
- const configDir = paths.configDir ?? getConfigDir();
90
- const cwd = paths.cwd ?? process.cwd();
91
- const environment = paths.environment ?? defaultEnvironment;
92
- const [globalConfig, projectConfig] = await Promise.all([loadGlobalConfig(configDir), loadProjectConfig(cwd)]);
93
- return mergeConfig(globalConfig, projectConfig, environment(), overrides);
94
- }
95
-
96
- export async function saveGlobalConfig(
97
- updates: Partial<DalpConfig>,
98
- configDir: string = getConfigDir()
99
- ): Promise<void> {
100
- await ensureConfigDir(configDir);
101
- const existing = await loadGlobalConfig(configDir);
102
- const next = DalpConfigSchema.parse({ ...existing, ...updates });
103
- await writeFile(path.join(configDir, "config.json"), `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
104
- }
@@ -1,296 +0,0 @@
1
- import { z } from "incur";
2
- import { cliCommandFor, routeTable } from "@settlemint/dalp-sdk/routes";
3
-
4
- export interface CliCtaCommandObject {
5
- readonly command: string;
6
- readonly description?: string;
7
- readonly args?: Readonly<Record<string, string | number | boolean>>;
8
- readonly options?: Readonly<Record<string, string | number | boolean>>;
9
- }
10
-
11
- export type CliCtaCommand = string | CliCtaCommandObject;
12
-
13
- export interface CliCtaBlock {
14
- readonly description: string;
15
- readonly commands: Array<CliCtaCommand>;
16
- }
17
-
18
- export type ResourceLinks = Readonly<Record<string, string | null | undefined>>;
19
-
20
- const LINK_REL_ORDER = ["next", "prev", "first", "last", "self", "history"] as const;
21
-
22
- const REL_DESCRIPTION: Readonly<Record<string, string>> = {
23
- next: "Fetch the next page",
24
- prev: "Fetch the previous page",
25
- first: "Fetch the first page",
26
- last: "Fetch the last page",
27
- self: "Re-fetch this resource",
28
- history: "Fetch related history",
29
- };
30
-
31
- const jsonBagSchema = z.record(z.string(), z.json());
32
-
33
- export type JsonBag = z.output<typeof jsonBagSchema>;
34
-
35
- const isJsonBag = (value: unknown): value is JsonBag => jsonBagSchema.safeParse(value).success;
36
-
37
- export function extractResourceLinks(value: unknown): ResourceLinks | undefined {
38
- if (!isJsonBag(value)) {
39
- return undefined;
40
- }
41
- const { links: direct, data } = value;
42
- if (isJsonBag(direct)) {
43
- return linksFromRecord(direct);
44
- }
45
- if (isJsonBag(data)) {
46
- const { links: nested } = data;
47
- if (isJsonBag(nested)) {
48
- return linksFromRecord(nested);
49
- }
50
- }
51
- return undefined;
52
- }
53
-
54
- function linksFromRecord(record: JsonBag): ResourceLinks | undefined {
55
- const out: Record<string, string | null> = {};
56
- let any = false;
57
- for (const [key, entry] of Object.entries(record)) {
58
- if (entry === null) {
59
- out[key] = null;
60
- any = true;
61
- continue;
62
- }
63
- if (typeof entry === "string" && entry.trim() !== "") {
64
- out[key] = entry;
65
- any = true;
66
- }
67
- }
68
- return any ? out : undefined;
69
- }
70
-
71
- function parseHref(href: string): { pathname: string; searchParams: URLSearchParams } | undefined {
72
- try {
73
- const url =
74
- href.startsWith("http://") || href.startsWith("https://") ? new URL(href) : new URL(href, "https://dalp.local");
75
- return { pathname: url.pathname, searchParams: url.searchParams };
76
- } catch {
77
- return undefined;
78
- }
79
- }
80
-
81
- const API_PREFIX = /^\/api(?:\/v2)?(?=\/)/u;
82
- const BRACKETED_KEY = /^([^[\]]+)\[([^\]]+)\]$/u;
83
-
84
- const readRoutes = Object.entries(routeTable.routes).flatMap(([routeId, row]) =>
85
- row === undefined || row.method.toUpperCase() !== "GET"
86
- ? []
87
- : [{ routeId, segments: row.path.split("/").filter((segment) => segment.length > 0) }]
88
- );
89
-
90
- const isParameterSegment = (segment: string): boolean =>
91
- (segment.startsWith("{") && segment.endsWith("}")) || segment.startsWith(":");
92
-
93
- function matchReadRoute(
94
- pathname: string
95
- ): { readonly routeId: string; readonly params: Record<string, string> } | undefined {
96
- const hrefSegments = pathname
97
- .replace(API_PREFIX, "")
98
- .split("/")
99
- .filter((segment) => segment.length > 0);
100
- let best: { routeId: string; params: Record<string, string>; statics: number } | undefined;
101
- for (const { routeId, segments } of readRoutes) {
102
- if (segments.length !== hrefSegments.length) {
103
- continue;
104
- }
105
- const params: Record<string, string> = {};
106
- let statics = 0;
107
- const matches = segments.every((segment, index) => {
108
- const actual = hrefSegments[index]!;
109
- if (!isParameterSegment(segment)) {
110
- statics += 1;
111
- return segment === actual;
112
- }
113
- params[segment.startsWith(":") ? segment.slice(1) : segment.slice(1, -1)] = decodeURIComponent(actual);
114
- return true;
115
- });
116
- if (matches && (best === undefined || statics > best.statics)) {
117
- best = { routeId, params, statics };
118
- }
119
- }
120
- return best;
121
- }
122
-
123
- export function mapApiHrefToCliCommand(href: string): { readonly command: string } | undefined {
124
- const parsed = parseHref(href);
125
- if (parsed === undefined) {
126
- return undefined;
127
- }
128
- const { pathname, searchParams } = parsed;
129
- if (!(pathname.includes("/api/v2/") || pathname.startsWith("/api/"))) {
130
- return undefined;
131
- }
132
- const route = matchReadRoute(pathname);
133
- if (route === undefined) {
134
- return undefined;
135
- }
136
- const query: Record<string, string | Record<string, string>> = {};
137
- for (const [key, value] of searchParams.entries()) {
138
- const bracketed = BRACKETED_KEY.exec(key);
139
- if (bracketed === null) {
140
- query[key] = value;
141
- continue;
142
- }
143
- const [, name, member] = bracketed;
144
- const nested = query[name!];
145
- query[name!] = { ...(typeof nested === "object" ? nested : {}), [member!]: value };
146
- }
147
- const command = cliCommandFor(route.routeId, { params: route.params, query });
148
- return command === null ? undefined : { command: command.text };
149
- }
150
-
151
- export function commandsFromLinks(links: ResourceLinks | undefined): Array<CliCtaCommandObject> {
152
- if (links === undefined) {
153
- return [];
154
- }
155
- const orderedKeys = [
156
- ...LINK_REL_ORDER.filter((rel) => rel in links),
157
- ...Object.keys(links)
158
- .filter((key) => !(LINK_REL_ORDER as ReadonlyArray<string>).includes(key))
159
- .sort((left, right) => left.localeCompare(right)),
160
- ];
161
- const commands: Array<CliCtaCommandObject> = [];
162
- const seen = new Set<string>();
163
- for (const rel of orderedKeys) {
164
- const href = links[rel];
165
- if (href === null || href === undefined || href === "") {
166
- continue;
167
- }
168
- const mapped = mapApiHrefToCliCommand(href);
169
- if (mapped === undefined) {
170
- continue;
171
- }
172
- if (seen.has(mapped.command)) {
173
- continue;
174
- }
175
- seen.add(mapped.command);
176
- commands.push({
177
- command: mapped.command,
178
- description: REL_DESCRIPTION[rel] ?? `Follow ${rel} link`,
179
- });
180
- }
181
- return commands;
182
- }
183
-
184
- function commandsFromErrorHints(input: {
185
- readonly code?: string;
186
- readonly category?: string;
187
- readonly fix: string;
188
- }): Array<CliCtaCommandObject> {
189
- const code = (input.code ?? "").toUpperCase();
190
- const category = (input.category ?? "").toLowerCase();
191
- const fix = input.fix.toLowerCase();
192
- const commands: Array<CliCtaCommandObject> = [];
193
-
194
- const auth =
195
- category === "auth" ||
196
- code.includes("AUTH") ||
197
- code.includes("NOT_AUTHENTICATED") ||
198
- code.includes("UNAUTHORIZED") ||
199
- fix.includes("authenticate") ||
200
- fix.includes("log in") ||
201
- fix.includes("sign in") ||
202
- fix.includes("api key");
203
- if (auth) {
204
- commands.push({ command: "login", description: "Authenticate with device authorization" });
205
- }
206
-
207
- const org =
208
- category === "permission" ||
209
- code.includes("ORGANIZATION") ||
210
- code.includes("ACTIVE_ORG") ||
211
- fix.includes("organization") ||
212
- fix.includes("org ");
213
- if (org) {
214
- commands.push({ command: "org switch", description: "Save the key's organization as active" });
215
- commands.push({ command: "whoami", description: "Show current user and organization" });
216
- }
217
-
218
- if (commands.length === 0 && (category === "client" || category === "config")) {
219
- commands.push({ command: "whoami", description: "Check the active session" });
220
- commands.push({ command: "config get", description: "Inspect local CLI configuration" });
221
- }
222
-
223
- return commands;
224
- }
225
-
226
- function dedupeCommands(commands: ReadonlyArray<CliCtaCommand>): Array<CliCtaCommand> {
227
- const seen = new Set<string>();
228
- const out: Array<CliCtaCommand> = [];
229
- for (const command of commands) {
230
- const key =
231
- typeof command === "string"
232
- ? command
233
- : `${command.command}|${JSON.stringify(command.args ?? {})}|${JSON.stringify(command.options ?? {})}`;
234
- if (seen.has(key)) {
235
- continue;
236
- }
237
- seen.add(key);
238
- out.push(command);
239
- }
240
- return out;
241
- }
242
-
243
- export function buildErrorCta(input: {
244
- readonly why: string;
245
- readonly fix: string;
246
- readonly code?: string;
247
- readonly category?: string;
248
- readonly links?: ResourceLinks;
249
- }): CliCtaBlock {
250
- const fromLinks = commandsFromLinks(input.links);
251
- const fromHints = commandsFromErrorHints(input);
252
- let commands = dedupeCommands([...fromLinks, ...fromHints]);
253
- if (commands.length === 0) {
254
- commands = [{ command: "whoami", description: input.fix }];
255
- }
256
- return {
257
- description: `Why: ${input.why}\nFix: ${input.fix}`,
258
- commands,
259
- };
260
- }
261
-
262
- function formatCtaCommand(cliName: string, entry: CliCtaCommand): { command: string; description?: string } {
263
- if (typeof entry === "string") {
264
- return { command: `${cliName} ${entry}` };
265
- }
266
- const prefixed = entry.command === cliName || entry.command.startsWith(`${cliName} `);
267
- let command = prefixed ? entry.command : `${cliName} ${entry.command}`;
268
- for (const [key, value] of Object.entries(entry.args ?? {})) {
269
- command += value === true ? ` <${key}>` : ` ${String(value)}`;
270
- }
271
- for (const [key, value] of Object.entries(entry.options ?? {})) {
272
- command += value === true ? ` --${key} <${key}>` : ` --${key} ${String(value)}`;
273
- }
274
- return entry.description === undefined ? { command } : { command, description: entry.description };
275
- }
276
-
277
- export function renderCtaText(cliName: string, block: CliCtaBlock): string {
278
- const commands = block.commands.map((entry) => formatCtaCommand(cliName, entry));
279
- const width = Math.max(0, ...commands.map((entry) => entry.command.length));
280
- const lines = commands.map((entry) =>
281
- entry.description === undefined ? ` ${entry.command}` : ` ${entry.command.padEnd(width)} # ${entry.description}`
282
- );
283
- return [block.description, ...lines].join("\n");
284
- }
285
-
286
- export function buildSuccessCtaFromResult(result: unknown): CliCtaBlock | undefined {
287
- const links = extractResourceLinks(result);
288
- const commands = commandsFromLinks(links);
289
- if (commands.length === 0) {
290
- return undefined;
291
- }
292
- return {
293
- description: commands.length === 1 ? "Related:" : "Related:",
294
- commands,
295
- };
296
- }