@settlemint/dalp-cli 3.1.32-main.42194559254 → 3.1.32-main.42209626311

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.
@@ -0,0 +1,127 @@
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 interface PrepareToolField {
18
+ readonly description: string;
19
+ readonly required: boolean;
20
+ readonly options?: ReadonlyArray<string>;
21
+ }
22
+
23
+ export interface PrepareTool {
24
+ readonly name: string;
25
+ readonly description: string;
26
+ readonly fields: Readonly<Record<string, PrepareToolField>>;
27
+ }
28
+
29
+ export interface PrepareCall {
30
+ readonly tool: string;
31
+ readonly assetId: string;
32
+ readonly fields: Readonly<Record<string, string>>;
33
+ readonly execution: HostedExecutionMode;
34
+ }
35
+
36
+ export type PrepareOutcome =
37
+ | { readonly prepared: unknown }
38
+ | { readonly refused: { readonly code: string; readonly message: string } };
39
+
40
+ export type PrepareExecutor = (call: PrepareCall) => Promise<PrepareOutcome>;
41
+
42
+ export interface HostedMcpRequest {
43
+ readonly client: DalpClient;
44
+ readonly prepare: PrepareExecutor;
45
+ }
46
+
47
+ export interface HostedMcpTools {
48
+ readonly reads: ReadonlyArray<OperationDescriptor>;
49
+ readonly register: (server: McpServer, request: HostedMcpRequest) => void;
50
+ }
51
+
52
+ const hostedVars = cliVars.extend({ prepare: z.custom<PrepareExecutor>().optional() });
53
+
54
+ const executionOption = z
55
+ .enum(HOSTED_EXECUTION_MODES)
56
+ .default("inbox")
57
+ .describe(
58
+ "inbox (default): post the prepared write to the member's DALP inbox, where a person reviews and signs it. mandate: execute as the organisation's agent principal, only inside its mandate."
59
+ );
60
+
61
+ const fieldOption = (field: PrepareToolField) => {
62
+ const value = field.options === undefined ? z.string() : z.enum(field.options);
63
+ const described = value.describe(field.description);
64
+ return field.required ? described : described.optional();
65
+ };
66
+
67
+ type PrepareOptions = Readonly<Record<string, string | undefined>>;
68
+
69
+ const prepareCommand = (tool: PrepareTool) => ({
70
+ description: tool.description,
71
+ mcp: { annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false } },
72
+ options: z.object({
73
+ assetId: z.string().describe("The asset the write acts on: its contract address."),
74
+ ...Object.fromEntries(Object.entries(tool.fields).map(([name, field]) => [name, fieldOption(field)])),
75
+ execution: executionOption,
76
+ }),
77
+ run: async (context: {
78
+ readonly options: PrepareOptions;
79
+ readonly var: { readonly prepare?: PrepareExecutor | undefined };
80
+ readonly error: (error: { readonly code: string; readonly message: string }) => unknown;
81
+ }) => {
82
+ const { assetId, execution, ...rest } = context.options;
83
+ if (context.var.prepare === undefined || typeof assetId !== "string") {
84
+ return context.error({ code: "PREPARE_UNAVAILABLE", message: "This server does not prepare writes." });
85
+ }
86
+ const fields = Object.fromEntries(
87
+ Object.entries(rest).flatMap(([name, value]) => (typeof value === "string" ? [[name, value]] : []))
88
+ );
89
+ const outcome = await context.var.prepare({
90
+ tool: tool.name,
91
+ assetId,
92
+ fields,
93
+ execution: execution === "mandate" ? "mandate" : "inbox",
94
+ });
95
+ return "refused" in outcome ? context.error(outcome.refused) : outcome.prepared;
96
+ },
97
+ });
98
+
99
+ export function makeHostedMcpTools(input: { readonly prepareTools: ReadonlyArray<PrepareTool> }): HostedMcpTools {
100
+ const reads = listCliOperations().filter((descriptor) => descriptor.method === "GET");
101
+ const cli = Cli.create("dalp", { version: CLI_VERSION, vars: hostedVars });
102
+ registerGeneratedOperations(cli, reads);
103
+ const prepare = Cli.create(PREPARE_GROUP, {
104
+ description: "Prepare a write for a person to review and sign in DALP.",
105
+ vars: hostedVars,
106
+ });
107
+ for (const tool of input.prepareTools) {
108
+ prepare.command(tool.name, prepareCommand(tool));
109
+ }
110
+ cli.command(prepare);
111
+ const registered: WeakMap<object, Map<string, unknown>> = Cli.toCommands;
112
+ const commands = registered.get(cli) ?? new Map<string, unknown>();
113
+ return {
114
+ reads,
115
+ register: (server, request) =>
116
+ Mcp.registerTools(server, commands, {
117
+ fromJsonSchema,
118
+ name: "dalp",
119
+ version: CLI_VERSION,
120
+ vars: hostedVars.extend({
121
+ client: z.custom<DalpClient>().default(() => request.client),
122
+ prepare: z.custom<PrepareExecutor>().default(() => request.prepare),
123
+ }),
124
+ sendNotification: (notification) => server.server.notification(notification),
125
+ }),
126
+ };
127
+ }
@@ -0,0 +1,50 @@
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
+ readonly organizationId?: string;
8
+ }
9
+
10
+ const withCliUserAgent = (fetchImpl: typeof globalThis.fetch): typeof globalThis.fetch =>
11
+ Object.assign(async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
12
+ const request = new Request(input, init);
13
+ const headers = new Headers(request.headers);
14
+ headers.set("User-Agent", CLI_USER_AGENT);
15
+ const compress = (init as CompressionRequestInit | undefined)?.compress;
16
+ const transportInit: CompressionRequestInit | undefined = compress === undefined ? undefined : { compress };
17
+ return fetchImpl(new Request(request, { headers }), transportInit);
18
+ }, fetchImpl);
19
+
20
+ export function resolveActiveOrganizationId(
21
+ credentials: StoredCredentials,
22
+ defaultOrg?: string,
23
+ override?: string
24
+ ): string | undefined {
25
+ if (override !== undefined && override.trim() !== "") {
26
+ return override;
27
+ }
28
+ if (credentials.activeOrgId !== undefined && credentials.activeOrgId.trim() !== "") {
29
+ return credentials.activeOrgId;
30
+ }
31
+ if (defaultOrg !== undefined && defaultOrg.trim() !== "") {
32
+ return defaultOrg;
33
+ }
34
+ return undefined;
35
+ }
36
+
37
+ export function makeCliClient(
38
+ apiUrl: string,
39
+ credentials: StoredCredentials,
40
+ options: CliGeneratedClientOptions & { readonly defaultOrg?: string } = {}
41
+ ): DalpClient {
42
+ const organizationId = resolveActiveOrganizationId(credentials, options.defaultOrg, options.organizationId);
43
+ return createDalpClient({
44
+ apiKey: credentials.apiKey,
45
+ baseUrl: apiUrl,
46
+ context: organizationId === undefined ? {} : { organizationId },
47
+ fetch: withCliUserAgent(options.fetch ?? globalThis.fetch),
48
+ runtime: "server",
49
+ });
50
+ }
@@ -0,0 +1,83 @@
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 AUTH_REQUEST_TIMEOUT_MS = 10_000;
7
+
8
+ export async function authRequest(
9
+ apiUrl: string,
10
+ credentials: StoredCredentials,
11
+ route: `/${string}`,
12
+ init?: { readonly method: "GET" | "POST"; readonly body?: unknown }
13
+ ): Promise<unknown> {
14
+ const request = init ?? { method: "GET" as const };
15
+ const headers = new Headers({ Accept: "application/json", "User-Agent": CLI_USER_AGENT });
16
+ if (credentials.apiKey !== undefined) {
17
+ headers.set("X-Api-Key", credentials.apiKey);
18
+ }
19
+ if (request.body !== undefined) {
20
+ headers.set("Content-Type", "application/json");
21
+ }
22
+ const deadline = new AbortController();
23
+ const timer = setTimeout(() => deadline.abort(), AUTH_REQUEST_TIMEOUT_MS);
24
+ let response: Response;
25
+ let text: string;
26
+ try {
27
+ response = await fetch(`${apiUrl.replace(/\/+$/u, "")}/api/auth${route}`, {
28
+ method: request.method,
29
+ headers,
30
+ signal: deadline.signal,
31
+ ...(request.body === undefined ? {} : { body: JSON.stringify(request.body) }),
32
+ });
33
+ text = await response.text();
34
+ } catch (error) {
35
+ if (!deadline.signal.aborted) {
36
+ throw error;
37
+ }
38
+ throw new DalpApiError({
39
+ message: `Auth request timed out: ${route}`,
40
+ code: "sdk.transport.REQUEST_FAILED",
41
+ category: "dependency",
42
+ retryable: true,
43
+ why: `The auth surface did not answer ${route} within ${String(AUTH_REQUEST_TIMEOUT_MS / 1000)} seconds.`,
44
+ fix: "Check that the platform URL is reachable, then retry.",
45
+ cause: error,
46
+ });
47
+ } finally {
48
+ clearTimeout(timer);
49
+ }
50
+ const payload: unknown = text === "" ? undefined : JSON.parse(text);
51
+ if (!response.ok) {
52
+ throw new DalpApiError({
53
+ message: `Auth request failed: ${String(response.status)} ${route}`,
54
+ code: `sdk.transport.HTTP_${response.status}`,
55
+ category: "dependency",
56
+ retryable: response.status >= 500,
57
+ why: `The auth surface answered ${String(response.status)} for ${route}.`,
58
+ fix: "Check the API key and the platform URL, then retry.",
59
+ status: response.status,
60
+ cause: payload,
61
+ });
62
+ }
63
+ return payload;
64
+ }
65
+
66
+ export type KeyRevocation = { readonly _tag: "Revoked" } | { readonly _tag: "Unconfirmed"; readonly cause: unknown };
67
+
68
+ const KeyDisabledAnswerSchema = z.object({ code: z.literal("KEY_DISABLED") });
69
+
70
+ const keyNoLongerExists = (error: unknown): boolean =>
71
+ error instanceof DalpApiError && error.status === 401 && !KeyDisabledAnswerSchema.safeParse(error.cause).success;
72
+
73
+ export async function revokeOwnApiKey(credentials: StoredCredentials): Promise<KeyRevocation> {
74
+ try {
75
+ await authRequest(credentials.apiUrl, credentials, "/api-key/delete", {
76
+ method: "POST",
77
+ body: { keyId: credentials.apiKeyId },
78
+ });
79
+ return { _tag: "Revoked" };
80
+ } catch (error) {
81
+ return keyNoLongerExists(error) ? { _tag: "Revoked" } : { _tag: "Unconfirmed", cause: error };
82
+ }
83
+ }
@@ -0,0 +1,104 @@
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
+ }
@@ -0,0 +1,231 @@
1
+ import { z } from "incur";
2
+ import { commandPathForUrl } 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
+ export function mapApiHrefToCliCommand(
82
+ href: string,
83
+ method = "GET"
84
+ ): { readonly command: string; readonly options?: Readonly<Record<string, string>> } | undefined {
85
+ const parsed = parseHref(href);
86
+ if (parsed === undefined) {
87
+ return undefined;
88
+ }
89
+ const { pathname, searchParams } = parsed;
90
+ if (!(pathname.includes("/api/v2/") || pathname.startsWith("/api/"))) {
91
+ return undefined;
92
+ }
93
+ const path = commandPathForUrl(method, pathname);
94
+ if (path.length === 0) {
95
+ return undefined;
96
+ }
97
+ const options: Record<string, string> = {};
98
+ for (const [key, value] of searchParams.entries()) {
99
+ const optionKey = key.includes("[") ? key : key;
100
+ options[optionKey] = value;
101
+ }
102
+ return {
103
+ command: path.join(" "),
104
+ ...(Object.keys(options).length > 0 ? { options } : {}),
105
+ };
106
+ }
107
+
108
+ export function commandsFromLinks(links: ResourceLinks | undefined): Array<CliCtaCommandObject> {
109
+ if (links === undefined) {
110
+ return [];
111
+ }
112
+ const orderedKeys = [
113
+ ...LINK_REL_ORDER.filter((rel) => rel in links),
114
+ ...Object.keys(links)
115
+ .filter((key) => !(LINK_REL_ORDER as ReadonlyArray<string>).includes(key))
116
+ .sort((left, right) => left.localeCompare(right)),
117
+ ];
118
+ const commands: Array<CliCtaCommandObject> = [];
119
+ const seen = new Set<string>();
120
+ for (const rel of orderedKeys) {
121
+ const href = links[rel];
122
+ if (href === null || href === undefined || href === "") {
123
+ continue;
124
+ }
125
+ const mapped = mapApiHrefToCliCommand(href);
126
+ if (mapped === undefined) {
127
+ continue;
128
+ }
129
+ const signature = `${mapped.command}|${JSON.stringify(mapped.options ?? {})}`;
130
+ if (seen.has(signature)) {
131
+ continue;
132
+ }
133
+ seen.add(signature);
134
+ commands.push({
135
+ command: mapped.command,
136
+ ...(mapped.options === undefined ? {} : { options: mapped.options }),
137
+ description: REL_DESCRIPTION[rel] ?? `Follow ${rel} link`,
138
+ });
139
+ }
140
+ return commands;
141
+ }
142
+
143
+ function commandsFromErrorHints(input: {
144
+ readonly code?: string;
145
+ readonly category?: string;
146
+ readonly fix: string;
147
+ }): Array<CliCtaCommandObject> {
148
+ const code = (input.code ?? "").toUpperCase();
149
+ const category = (input.category ?? "").toLowerCase();
150
+ const fix = input.fix.toLowerCase();
151
+ const commands: Array<CliCtaCommandObject> = [];
152
+
153
+ const auth =
154
+ category === "auth" ||
155
+ code.includes("AUTH") ||
156
+ code.includes("NOT_AUTHENTICATED") ||
157
+ code.includes("UNAUTHORIZED") ||
158
+ fix.includes("authenticate") ||
159
+ fix.includes("log in") ||
160
+ fix.includes("sign in") ||
161
+ fix.includes("api key");
162
+ if (auth) {
163
+ commands.push({ command: "login", description: "Authenticate with device authorization" });
164
+ }
165
+
166
+ const org =
167
+ category === "permission" ||
168
+ code.includes("ORGANIZATION") ||
169
+ code.includes("ACTIVE_ORG") ||
170
+ fix.includes("organization") ||
171
+ fix.includes("org ");
172
+ if (org) {
173
+ commands.push({ command: "org switch", description: "Save the key's organization as active" });
174
+ commands.push({ command: "whoami", description: "Show current user and organization" });
175
+ }
176
+
177
+ if (commands.length === 0 && (category === "client" || category === "config")) {
178
+ commands.push({ command: "whoami", description: "Check the active session" });
179
+ commands.push({ command: "config get", description: "Inspect local CLI configuration" });
180
+ }
181
+
182
+ return commands;
183
+ }
184
+
185
+ function dedupeCommands(commands: ReadonlyArray<CliCtaCommand>): Array<CliCtaCommand> {
186
+ const seen = new Set<string>();
187
+ const out: Array<CliCtaCommand> = [];
188
+ for (const command of commands) {
189
+ const key =
190
+ typeof command === "string"
191
+ ? command
192
+ : `${command.command}|${JSON.stringify(command.args ?? {})}|${JSON.stringify(command.options ?? {})}`;
193
+ if (seen.has(key)) {
194
+ continue;
195
+ }
196
+ seen.add(key);
197
+ out.push(command);
198
+ }
199
+ return out;
200
+ }
201
+
202
+ export function buildErrorCta(input: {
203
+ readonly why: string;
204
+ readonly fix: string;
205
+ readonly code?: string;
206
+ readonly category?: string;
207
+ readonly links?: ResourceLinks;
208
+ }): CliCtaBlock {
209
+ const fromLinks = commandsFromLinks(input.links);
210
+ const fromHints = commandsFromErrorHints(input);
211
+ let commands = dedupeCommands([...fromLinks, ...fromHints]);
212
+ if (commands.length === 0) {
213
+ commands = [{ command: "whoami", description: input.fix }];
214
+ }
215
+ return {
216
+ description: `Why: ${input.why}\nFix: ${input.fix}`,
217
+ commands,
218
+ };
219
+ }
220
+
221
+ export function buildSuccessCtaFromResult(result: unknown): CliCtaBlock | undefined {
222
+ const links = extractResourceLinks(result);
223
+ const commands = commandsFromLinks(links);
224
+ if (commands.length === 0) {
225
+ return undefined;
226
+ }
227
+ return {
228
+ description: commands.length === 1 ? "Related:" : "Related:",
229
+ commands,
230
+ };
231
+ }
@@ -0,0 +1,102 @@
1
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
2
+
3
+ export interface StatusEvent {
4
+ readonly phase: string;
5
+ readonly message: string;
6
+ }
7
+
8
+ export interface CliStatusReporter {
9
+ readonly update: (event: StatusEvent) => StatusEvent | undefined;
10
+ readonly succeed: (message: string) => StatusEvent | undefined;
11
+ readonly fail: (message: string) => StatusEvent | undefined;
12
+ readonly stop: () => void;
13
+ }
14
+
15
+ export interface CreateCliStatusOptions {
16
+ readonly agent: boolean;
17
+ readonly stream?: { write: (chunk: string) => unknown; isTTY?: boolean };
18
+ readonly isTty?: boolean;
19
+ }
20
+
21
+ export function createCliStatus(options: CreateCliStatusOptions): CliStatusReporter {
22
+ const stream = options.stream ?? process.stderr;
23
+ const isTty = options.isTty ?? Boolean("isTTY" in stream && stream.isTTY);
24
+ const human = options.agent !== true;
25
+ let frame = 0;
26
+ let currentMessage = "";
27
+ let timer: ReturnType<typeof setInterval> | undefined;
28
+ let spinning = false;
29
+
30
+ const clearLine = (): void => {
31
+ if (human && isTty) {
32
+ stream.write("\r\u001B[K");
33
+ }
34
+ };
35
+
36
+ const paint = (): void => {
37
+ if (!human || !isTty || !spinning) {
38
+ return;
39
+ }
40
+ const glyph = SPINNER_FRAMES[frame % SPINNER_FRAMES.length]!;
41
+ stream.write(`\r\u001B[K${glyph} ${currentMessage}`);
42
+ frame += 1;
43
+ };
44
+
45
+ const startSpinner = (): void => {
46
+ if (!human || !isTty || spinning) {
47
+ return;
48
+ }
49
+ spinning = true;
50
+ timer = setInterval(paint, 80);
51
+ paint();
52
+ };
53
+
54
+ const stopSpinner = (): void => {
55
+ if (timer !== undefined) {
56
+ clearInterval(timer);
57
+ timer = undefined;
58
+ }
59
+ if (spinning) {
60
+ clearLine();
61
+ spinning = false;
62
+ }
63
+ };
64
+
65
+ const emitHuman = (prefix: string, message: string): void => {
66
+ stopSpinner();
67
+ if (!human) {
68
+ return;
69
+ }
70
+ if (isTty) {
71
+ stream.write(`${prefix} ${message}\n`);
72
+ } else {
73
+ stream.write(`${message}\n`);
74
+ }
75
+ };
76
+
77
+ return {
78
+ update(event) {
79
+ currentMessage = event.message;
80
+ if (!options.agent) {
81
+ if (isTty) {
82
+ startSpinner();
83
+ paint();
84
+ } else {
85
+ stream.write(`${event.message}\n`);
86
+ }
87
+ }
88
+ return options.agent ? event : undefined;
89
+ },
90
+ succeed(message) {
91
+ emitHuman("✔", message);
92
+ return options.agent ? { phase: "done", message } : undefined;
93
+ },
94
+ fail(message) {
95
+ emitHuman("✖", message);
96
+ return options.agent ? { phase: "error", message } : undefined;
97
+ },
98
+ stop() {
99
+ stopSpinner();
100
+ },
101
+ };
102
+ }