@grapecool/cli 0.0.0-stage → 1.0.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +160 -2
  3. package/dist/cli.js +1909 -0
  4. package/package.json +56 -3
package/dist/cli.js ADDED
@@ -0,0 +1,1909 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/io.ts
4
+ import fs from "node:fs";
5
+ var detectStdinKind = ({ fd = 0 } = {}) => {
6
+ if (fd === 0 && process.stdin.isTTY) return "tty";
7
+ try {
8
+ const stats = fs.fstatSync(fd);
9
+ if (stats.isFIFO()) return "pipe";
10
+ if (stats.isFile()) return "file";
11
+ return "other";
12
+ } catch {
13
+ return "other";
14
+ }
15
+ };
16
+ var processIo = () => {
17
+ process.stdout.on("error", (error) => {
18
+ if (error.code === "EPIPE") process.exit(0);
19
+ });
20
+ return {
21
+ stdout: (text) => {
22
+ process.stdout.write(text);
23
+ },
24
+ stderr: (text) => {
25
+ process.stderr.write(text);
26
+ },
27
+ stdin: process.stdin,
28
+ stdinKind: detectStdinKind(),
29
+ stdoutIsTty: Boolean(process.stdout.isTTY)
30
+ };
31
+ };
32
+ var readStream = async ({
33
+ stream
34
+ }) => {
35
+ const chunks = [];
36
+ for await (const chunk of stream) {
37
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
38
+ }
39
+ return Buffer.concat(chunks).toString("utf8");
40
+ };
41
+ var writeLine = ({ io, text = "" }) => io.stdout(`${text}
42
+ `);
43
+ var writeJson = ({ io, value }) => io.stdout(`${JSON.stringify(value, null, 2)}
44
+ `);
45
+ var writeNotice = ({ io, text }) => io.stderr(`${text}
46
+ `);
47
+
48
+ // src/open-url.ts
49
+ import { spawn } from "node:child_process";
50
+ var commandFor = ({ url }) => {
51
+ if (process.platform === "darwin") return { file: "open", args: [url] };
52
+ if (process.platform === "win32") {
53
+ return { file: "cmd", args: ["/c", "start", '""', url.replace(/&/g, "^&")] };
54
+ }
55
+ return { file: "xdg-open", args: [url] };
56
+ };
57
+ var openUrl = ({ url }) => new Promise((resolve) => {
58
+ try {
59
+ const { file, args } = commandFor({ url });
60
+ const child = spawn(file, args, {
61
+ stdio: "ignore",
62
+ detached: true,
63
+ windowsHide: true
64
+ });
65
+ child.once("error", () => resolve(false));
66
+ child.once("spawn", () => {
67
+ child.unref();
68
+ resolve(true);
69
+ });
70
+ } catch {
71
+ resolve(false);
72
+ }
73
+ });
74
+
75
+ // src/config.ts
76
+ import os from "node:os";
77
+ import path from "node:path";
78
+ var DEFAULT_GRAPE_URL = "https://grape.cool";
79
+ var DEFAULT_MCP_URL = "https://mcp.grape.cool";
80
+ var trimTrailingSlashes = ({ value }) => value.replace(/\/+$/, "");
81
+ var nonEmpty = ({ value }) => {
82
+ const trimmed = value?.trim();
83
+ return trimmed ? trimmed : null;
84
+ };
85
+ var defaultConfigDir = ({
86
+ env,
87
+ platform,
88
+ homeDir
89
+ }) => {
90
+ if (platform === "win32") {
91
+ return path.join(
92
+ nonEmpty({ value: env.APPDATA }) ?? path.join(homeDir, "AppData", "Roaming"),
93
+ "grape"
94
+ );
95
+ }
96
+ return path.join(
97
+ nonEmpty({ value: env.XDG_CONFIG_HOME }) ?? path.join(homeDir, ".config"),
98
+ "grape"
99
+ );
100
+ };
101
+ var readConfig = ({
102
+ env = process.env,
103
+ platform = process.platform,
104
+ homeDir = os.homedir()
105
+ } = {}) => ({
106
+ grapeUrl: trimTrailingSlashes({
107
+ value: nonEmpty({ value: env.GRAPE_URL }) ?? DEFAULT_GRAPE_URL
108
+ }),
109
+ mcpUrl: trimTrailingSlashes({
110
+ value: nonEmpty({ value: env.GRAPE_MCP_URL }) ?? DEFAULT_MCP_URL
111
+ }),
112
+ configDir: nonEmpty({ value: env.GRAPE_CONFIG_DIR }) ?? defaultConfigDir({ env, platform, homeDir }),
113
+ accessToken: nonEmpty({ value: env.GRAPE_ACCESS_TOKEN })
114
+ });
115
+
116
+ // src/program.ts
117
+ import { Command, CommanderError } from "commander";
118
+
119
+ // package.json
120
+ var version = "1.0.0";
121
+
122
+ // src/version.ts
123
+ var CLI_VERSION = version;
124
+ var USER_AGENT = `grape-cli/${version} (+https://grape.cool/docs#cli)`;
125
+
126
+ // src/errors.ts
127
+ var EXIT_CODES = {
128
+ ok: 0,
129
+ failure: 1,
130
+ usage: 2,
131
+ signedOut: 3
132
+ };
133
+ var CliError = class extends Error {
134
+ code;
135
+ hint;
136
+ exitCode;
137
+ constructor({
138
+ message,
139
+ code = "failed",
140
+ hint = null,
141
+ exitCode = EXIT_CODES.failure
142
+ }) {
143
+ super(message);
144
+ this.name = "CliError";
145
+ this.code = code;
146
+ this.hint = hint;
147
+ this.exitCode = exitCode;
148
+ }
149
+ };
150
+ var signedOutError = ({
151
+ message = "You are not signed in to Grape."
152
+ } = {}) => new CliError({
153
+ message,
154
+ code: "signed_out",
155
+ hint: "Run grape login to sign in.",
156
+ exitCode: EXIT_CODES.signedOut
157
+ });
158
+ var usageError = ({
159
+ message,
160
+ hint = null
161
+ }) => new CliError({ message, hint, code: "usage", exitCode: EXIT_CODES.usage });
162
+ var isCliError = (error) => error instanceof CliError;
163
+
164
+ // src/commands/site.ts
165
+ import { Option } from "commander";
166
+
167
+ // src/commands/options.ts
168
+ var parseLimit = ({
169
+ value,
170
+ name = "--limit",
171
+ max
172
+ }) => {
173
+ if (value === void 0) return null;
174
+ const parsed = Number(value);
175
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > max) {
176
+ throw usageError({
177
+ message: `${name} takes a whole number from 1 to ${max}, not "${value}".`
178
+ });
179
+ }
180
+ return parsed;
181
+ };
182
+ var joinWords = ({ words }) => (words ?? []).join(" ").trim();
183
+
184
+ // src/http.ts
185
+ import axios from "axios";
186
+ var REQUEST_TIMEOUT_MS = 3e4;
187
+ var client = axios.create({
188
+ timeout: REQUEST_TIMEOUT_MS,
189
+ maxRedirects: 0,
190
+ validateStatus: () => true,
191
+ headers: { "User-Agent": USER_AGENT }
192
+ });
193
+ var flattenHeaders = ({ response }) => Object.fromEntries(
194
+ Object.entries(response.headers).map(([name, value]) => [
195
+ name.toLowerCase(),
196
+ Array.isArray(value) ? value.join(", ") : String(value ?? "")
197
+ ])
198
+ );
199
+ var unreachableError = ({ url, error }) => {
200
+ const host = new URL(url).host;
201
+ const reason = axios.isAxiosError(error) ? error.code === "ECONNABORTED" || error.code === "ETIMEDOUT" ? "the request timed out" : error.code ?? error.message : String(error);
202
+ return new CliError({
203
+ code: "network_error",
204
+ message: `Could not reach ${host}: ${reason}.`,
205
+ hint: "Check the internet connection and try again."
206
+ });
207
+ };
208
+ var postRequest = async ({
209
+ url,
210
+ data,
211
+ headers = {},
212
+ responseType = "json"
213
+ }) => {
214
+ try {
215
+ const response = await client.post(url, data, {
216
+ headers: {
217
+ Accept: "application/json",
218
+ ...data instanceof URLSearchParams ? {} : { "Content-Type": "application/json" },
219
+ ...headers
220
+ },
221
+ responseType,
222
+ // Text stays text: the MCP reply is parsed by its own reader, which
223
+ // also understands an SSE framed answer.
224
+ ...responseType === "text" ? { transformResponse: [] } : {}
225
+ });
226
+ return {
227
+ status: response.status,
228
+ data: response.data,
229
+ headers: flattenHeaders({ response })
230
+ };
231
+ } catch (error) {
232
+ throw unreachableError({ url, error });
233
+ }
234
+ };
235
+
236
+ // src/api/public-api.ts
237
+ var isEnvelope = (value) => Boolean(value) && typeof value === "object" && typeof value.success === "boolean";
238
+ var callPublicApi = async ({
239
+ config,
240
+ path: path3,
241
+ body = {}
242
+ }) => {
243
+ const url = `${config.grapeUrl}/api/v1${path3}`;
244
+ const response = await postRequest({ url, data: body });
245
+ const envelope = response.data;
246
+ if (!isEnvelope(envelope)) {
247
+ throw new CliError({
248
+ code: "unexpected_response",
249
+ message: `${url} answered ${response.status} with something that is not the Grape API envelope.`
250
+ });
251
+ }
252
+ if (!envelope.success) {
253
+ throw new CliError({
254
+ code: envelope.error?.code ?? "api_error",
255
+ message: envelope.error?.message ?? `${url} answered ${response.status}.`,
256
+ hint: envelope.error?.hint ?? null
257
+ });
258
+ }
259
+ return { data: envelope.data, meta: envelope.meta ?? {} };
260
+ };
261
+
262
+ // src/context.ts
263
+ var contextFor = ({
264
+ base,
265
+ command
266
+ }) => ({
267
+ ...base,
268
+ json: Boolean(command.optsWithGlobals().json)
269
+ });
270
+
271
+ // src/commands/site.ts
272
+ var DEFAULT_RELEASES_SHOWN = 3;
273
+ var showPlans = async ({ context }) => {
274
+ const { data } = await callPublicApi({
275
+ config: context.config,
276
+ path: "/plans"
277
+ });
278
+ if (context.json) return writeJson({ io: context.io, value: data });
279
+ const blocks = data.map(
280
+ (plan) => [
281
+ `${plan.name} ${plan.price} ${plan.period}`,
282
+ ` ${plan.description}`,
283
+ ...plan.features.map((feature) => ` - ${feature}`),
284
+ ` ${plan.url}`
285
+ ].join("\n")
286
+ );
287
+ writeLine({ io: context.io, text: blocks.join("\n\n") });
288
+ };
289
+ var showChangelog = async ({
290
+ context,
291
+ platform,
292
+ limit
293
+ }) => {
294
+ const { data } = await callPublicApi({
295
+ config: context.config,
296
+ path: "/changelog",
297
+ body: platform ? { platform } : {}
298
+ });
299
+ if (context.json) {
300
+ return writeJson({
301
+ io: context.io,
302
+ value: limit ? data.slice(0, limit) : data
303
+ });
304
+ }
305
+ const blocks = data.slice(0, limit ?? DEFAULT_RELEASES_SHOWN).map(
306
+ (release) => [
307
+ `${release.version} ${release.platform} ${release.date}`,
308
+ ...release.changes.map((change) => ` - ${change}`)
309
+ ].join("\n")
310
+ );
311
+ writeLine({ io: context.io, text: blocks.join("\n\n") });
312
+ };
313
+ var showDownloads = async ({
314
+ context,
315
+ kind
316
+ }) => {
317
+ const { data } = await callPublicApi({
318
+ config: context.config,
319
+ path: "/downloads",
320
+ body: kind ? { kind } : {}
321
+ });
322
+ if (context.json) return writeJson({ io: context.io, value: data });
323
+ const blocks = data.map(
324
+ (platform) => [
325
+ `${platform.name} ${platform.version}`,
326
+ ...platform.downloads.map(
327
+ (download) => ` ${download.label} ${download.url}`
328
+ )
329
+ ].join("\n")
330
+ );
331
+ writeLine({ io: context.io, text: blocks.join("\n\n") });
332
+ };
333
+ var registerSiteCommands = ({
334
+ program,
335
+ base
336
+ }) => {
337
+ program.command("plans").description("List the Grape plans, their prices and what each includes").action(
338
+ async (_options, command) => showPlans({ context: contextFor({ base, command }) })
339
+ );
340
+ program.command("changelog").description("Show what changed in the latest Grape releases").addOption(
341
+ new Option("--platform <platform>", "Only one app").choices([
342
+ "desktop",
343
+ "mobile"
344
+ ])
345
+ ).option("--limit <count>", "How many releases to show (default 3)").action(
346
+ async (options, command) => showChangelog({
347
+ context: contextFor({ base, command }),
348
+ platform: options.platform ?? null,
349
+ limit: parseLimit({ value: options.limit, max: 100 })
350
+ })
351
+ );
352
+ program.command("downloads").description("Show the download links for every platform").addOption(
353
+ new Option("--kind <kind>", "Only desktop or only mobile").choices([
354
+ "desktop",
355
+ "mobile"
356
+ ])
357
+ ).action(
358
+ async (options, command) => showDownloads({
359
+ context: contextFor({ base, command }),
360
+ kind: options.kind ?? null
361
+ })
362
+ );
363
+ };
364
+
365
+ // src/auth/oauth.ts
366
+ var OAUTH_SCOPE = "notes:read notes:write";
367
+ var CLIENT_NAME = "Grape CLI";
368
+ var authorizeEndpoint = ({ config }) => `${config.grapeUrl}/oauth/authorize`;
369
+ var tokenEndpoint = ({ config }) => `${config.grapeUrl}/api/oauth/token`;
370
+ var registrationEndpoint = ({ config }) => `${config.grapeUrl}/api/oauth/register`;
371
+ var describeFailure = ({
372
+ status,
373
+ data
374
+ }) => {
375
+ const body = data && typeof data === "object" ? data : {};
376
+ return {
377
+ error: body.error ?? `http_${status}`,
378
+ description: body.error_description ?? `grape.cool answered ${status}.`
379
+ };
380
+ };
381
+ var registerClient = async ({
382
+ config,
383
+ redirectUri
384
+ }) => {
385
+ const response = await postRequest({
386
+ url: registrationEndpoint({ config }),
387
+ data: {
388
+ client_name: CLIENT_NAME,
389
+ redirect_uris: [redirectUri],
390
+ grant_types: ["authorization_code", "refresh_token"],
391
+ response_types: ["code"],
392
+ token_endpoint_auth_method: "none"
393
+ }
394
+ });
395
+ const clientId = response.data?.client_id;
396
+ if (response.status !== 201 || typeof clientId !== "string") {
397
+ const { description } = describeFailure(response);
398
+ throw new CliError({
399
+ code: "registration_failed",
400
+ message: `Could not start the sign in: ${description}`
401
+ });
402
+ }
403
+ return clientId;
404
+ };
405
+ var buildAuthorizeUrl = ({
406
+ config,
407
+ clientId,
408
+ redirectUri,
409
+ codeChallenge,
410
+ state
411
+ }) => {
412
+ const url = new URL(authorizeEndpoint({ config }));
413
+ url.search = new URLSearchParams({
414
+ response_type: "code",
415
+ client_id: clientId,
416
+ redirect_uri: redirectUri,
417
+ code_challenge: codeChallenge,
418
+ code_challenge_method: "S256",
419
+ scope: OAUTH_SCOPE,
420
+ state,
421
+ resource: config.mcpUrl
422
+ }).toString();
423
+ return url.toString();
424
+ };
425
+ var toTokenSet = ({
426
+ data,
427
+ now
428
+ }) => {
429
+ const { access_token, refresh_token, expires_in, scope } = data;
430
+ if (typeof access_token !== "string" || typeof refresh_token !== "string") {
431
+ return null;
432
+ }
433
+ const seconds = typeof expires_in === "number" ? expires_in : 3600;
434
+ return {
435
+ accessToken: access_token,
436
+ refreshToken: refresh_token,
437
+ expiresAt: new Date(now.getTime() + seconds * 1e3).toISOString(),
438
+ scope: typeof scope === "string" ? scope : OAUTH_SCOPE
439
+ };
440
+ };
441
+ var requestTokens = async ({
442
+ config,
443
+ fields,
444
+ now
445
+ }) => {
446
+ const response = await postRequest({
447
+ url: tokenEndpoint({ config }),
448
+ data: new URLSearchParams(fields)
449
+ });
450
+ const tokens = response.status === 200 && response.data && typeof response.data === "object" ? toTokenSet({ data: response.data, now }) : null;
451
+ return { tokens, failure: tokens ? null : describeFailure(response) };
452
+ };
453
+ var exchangeCode = async ({
454
+ config,
455
+ clientId,
456
+ code,
457
+ codeVerifier,
458
+ redirectUri,
459
+ now
460
+ }) => {
461
+ const { tokens, failure } = await requestTokens({
462
+ config,
463
+ now,
464
+ fields: {
465
+ grant_type: "authorization_code",
466
+ code,
467
+ code_verifier: codeVerifier,
468
+ client_id: clientId,
469
+ redirect_uri: redirectUri,
470
+ resource: config.mcpUrl
471
+ }
472
+ });
473
+ if (!tokens) {
474
+ throw new CliError({
475
+ code: failure?.error ?? "token_failed",
476
+ message: `The sign in did not finish: ${failure?.description}`,
477
+ hint: "Run grape login again."
478
+ });
479
+ }
480
+ return tokens;
481
+ };
482
+ var refreshTokens = async ({
483
+ config,
484
+ clientId,
485
+ refreshToken,
486
+ now
487
+ }) => {
488
+ const { tokens, failure } = await requestTokens({
489
+ config,
490
+ now,
491
+ fields: {
492
+ grant_type: "refresh_token",
493
+ refresh_token: refreshToken,
494
+ client_id: clientId,
495
+ resource: config.mcpUrl
496
+ }
497
+ });
498
+ if (tokens) return tokens;
499
+ if (failure?.error === "invalid_grant" || failure?.error === "invalid_client") {
500
+ throw signedOutError({
501
+ message: "Your Grape sign in has ended, so it was removed from this computer."
502
+ });
503
+ }
504
+ throw new CliError({
505
+ code: failure?.error ?? "refresh_failed",
506
+ message: `Could not renew the sign in: ${failure?.description}`,
507
+ hint: "Try again in a moment, or run grape login."
508
+ });
509
+ };
510
+
511
+ // src/auth/credentials.ts
512
+ import path2 from "node:path";
513
+ import fs2 from "node:fs/promises";
514
+ var LOCK_WAIT_MS = 15e3;
515
+ var LOCK_STALE_MS = 3e4;
516
+ var LOCK_POLL_MS = 100;
517
+ var credentialsPath = ({ config }) => path2.join(config.configDir, "credentials.json");
518
+ var lockPath = ({ config }) => path2.join(config.configDir, "credentials.lock");
519
+ var isCredentials = (value) => {
520
+ if (!value || typeof value !== "object") return false;
521
+ const record = value;
522
+ return [
523
+ "grapeUrl",
524
+ "mcpUrl",
525
+ "clientId",
526
+ "accessToken",
527
+ "refreshToken",
528
+ "expiresAt"
529
+ ].every((key) => typeof record[key] === "string" && record[key] !== "");
530
+ };
531
+ var readCredentials = async ({
532
+ config
533
+ }) => {
534
+ let text;
535
+ try {
536
+ text = await fs2.readFile(credentialsPath({ config }), "utf8");
537
+ } catch {
538
+ return null;
539
+ }
540
+ try {
541
+ const parsed = JSON.parse(text);
542
+ if (!isCredentials(parsed)) return null;
543
+ if (parsed.grapeUrl !== config.grapeUrl) return null;
544
+ if (parsed.mcpUrl !== config.mcpUrl) return null;
545
+ return { ...parsed, scope: parsed.scope ?? "", account: parsed.account ?? null };
546
+ } catch {
547
+ return null;
548
+ }
549
+ };
550
+ var writeCredentials = async ({
551
+ config,
552
+ credentials
553
+ }) => {
554
+ const target = credentialsPath({ config });
555
+ const temporary = `${target}.${process.pid}.tmp`;
556
+ await fs2.mkdir(config.configDir, { recursive: true, mode: 448 });
557
+ await fs2.writeFile(temporary, `${JSON.stringify(credentials, null, 2)}
558
+ `, {
559
+ mode: 384
560
+ });
561
+ await fs2.rename(temporary, target);
562
+ };
563
+ var deleteCredentials = async ({ config }) => {
564
+ try {
565
+ await fs2.rm(credentialsPath({ config }));
566
+ return true;
567
+ } catch {
568
+ return false;
569
+ }
570
+ };
571
+ var sleep = ({ ms }) => new Promise((resolve) => setTimeout(resolve, ms));
572
+ var acquireLock = async ({ config }) => {
573
+ const target = lockPath({ config });
574
+ const deadline = Date.now() + LOCK_WAIT_MS;
575
+ await fs2.mkdir(config.configDir, { recursive: true, mode: 448 });
576
+ while (true) {
577
+ try {
578
+ const handle = await fs2.open(target, "wx", 384);
579
+ await handle.writeFile(String(process.pid));
580
+ await handle.close();
581
+ return;
582
+ } catch (error) {
583
+ if (error.code !== "EEXIST") throw error;
584
+ }
585
+ const stats = await fs2.stat(target).catch(() => null);
586
+ if (stats && Date.now() - stats.mtimeMs > LOCK_STALE_MS) {
587
+ await fs2.rm(target, { force: true });
588
+ continue;
589
+ }
590
+ if (Date.now() > deadline) {
591
+ throw new CliError({
592
+ code: "locked",
593
+ message: "Another grape command is still refreshing the sign in.",
594
+ hint: "Try again in a moment."
595
+ });
596
+ }
597
+ await sleep({ ms: LOCK_POLL_MS });
598
+ }
599
+ };
600
+ var withCredentialsLock = async ({
601
+ config,
602
+ run
603
+ }) => {
604
+ await acquireLock({ config });
605
+ try {
606
+ return await run();
607
+ } finally {
608
+ await fs2.rm(lockPath({ config }), { force: true });
609
+ }
610
+ };
611
+
612
+ // src/auth/session.ts
613
+ var EXPIRY_MARGIN_MS = 6e4;
614
+ var isUsable = ({
615
+ credentials,
616
+ rejectedToken,
617
+ now
618
+ }) => credentials.accessToken !== rejectedToken && Date.parse(credentials.expiresAt) - EXPIRY_MARGIN_MS > now.getTime();
619
+ var getAccessToken = async ({
620
+ config,
621
+ now,
622
+ rejectedToken = null
623
+ }) => {
624
+ if (config.accessToken) {
625
+ if (rejectedToken === config.accessToken) {
626
+ throw new CliError({
627
+ code: "signed_out",
628
+ message: "Grape did not accept the token in GRAPE_ACCESS_TOKEN.",
629
+ hint: "Get a new token, or unset GRAPE_ACCESS_TOKEN and run grape login.",
630
+ exitCode: signedOutError().exitCode
631
+ });
632
+ }
633
+ return config.accessToken;
634
+ }
635
+ const stored = await readCredentials({ config });
636
+ if (!stored) throw signedOutError();
637
+ if (isUsable({ credentials: stored, rejectedToken, now })) {
638
+ return stored.accessToken;
639
+ }
640
+ return withCredentialsLock({
641
+ config,
642
+ run: async () => {
643
+ const current = await readCredentials({ config });
644
+ if (!current) throw signedOutError();
645
+ if (isUsable({ credentials: current, rejectedToken, now })) {
646
+ return current.accessToken;
647
+ }
648
+ try {
649
+ const tokens = await refreshTokens({
650
+ config,
651
+ now,
652
+ clientId: current.clientId,
653
+ refreshToken: current.refreshToken
654
+ });
655
+ await writeCredentials({ config, credentials: { ...current, ...tokens } });
656
+ return tokens.accessToken;
657
+ } catch (error) {
658
+ if (error instanceof CliError && error.code === "signed_out") {
659
+ await deleteCredentials({ config });
660
+ }
661
+ throw error;
662
+ }
663
+ }
664
+ });
665
+ };
666
+
667
+ // src/api/mcp.ts
668
+ var MCP_PROTOCOL_VERSION = "2026-07-28";
669
+ var PROTOCOL_VERSION_META_KEY = "io.modelcontextprotocol/protocolVersion";
670
+ var CLIENT_INFO_META_KEY = "io.modelcontextprotocol/clientInfo";
671
+ var CLIENT_CAPABILITIES_META_KEY = "io.modelcontextprotocol/clientCapabilities";
672
+ var CLIENT_INFO = { name: "grape-cli", title: "Grape CLI", version: CLI_VERSION };
673
+ var nextRequestId = 1;
674
+ var buildMcpRequest = ({
675
+ id,
676
+ method,
677
+ params
678
+ }) => ({
679
+ body: {
680
+ jsonrpc: "2.0",
681
+ id,
682
+ method,
683
+ params: {
684
+ ...params,
685
+ _meta: {
686
+ [PROTOCOL_VERSION_META_KEY]: MCP_PROTOCOL_VERSION,
687
+ [CLIENT_INFO_META_KEY]: CLIENT_INFO,
688
+ [CLIENT_CAPABILITIES_META_KEY]: {}
689
+ }
690
+ }
691
+ },
692
+ headers: {
693
+ "MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
694
+ "Mcp-Method": method,
695
+ ...typeof params.name === "string" ? { "Mcp-Name": params.name } : {}
696
+ }
697
+ });
698
+ var isReply = (value) => Boolean(value) && typeof value === "object" && value.jsonrpc === "2.0" && ("result" in value || "error" in value);
699
+ var parseJson = ({ text }) => {
700
+ try {
701
+ return JSON.parse(text);
702
+ } catch {
703
+ return null;
704
+ }
705
+ };
706
+ var parseMcpReply = ({ body }) => {
707
+ const trimmed = body.trim();
708
+ if (!trimmed) return null;
709
+ const direct = parseJson({ text: trimmed });
710
+ if (isReply(direct)) return direct;
711
+ let reply = null;
712
+ for (const event of trimmed.split(/\r?\n\r?\n/)) {
713
+ const data = event.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice("data:".length).replace(/^ /, "")).join("\n");
714
+ const message = data ? parseJson({ text: data }) : null;
715
+ if (isReply(message)) reply = message;
716
+ }
717
+ return reply;
718
+ };
719
+ var toolText = ({ result }) => (result.content ?? []).filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n").trim();
720
+ var send = async ({
721
+ context,
722
+ accessToken,
723
+ method,
724
+ params
725
+ }) => {
726
+ const { body, headers } = buildMcpRequest({
727
+ id: nextRequestId++,
728
+ method,
729
+ params
730
+ });
731
+ return postRequest({
732
+ url: context.config.mcpUrl,
733
+ data: body,
734
+ responseType: "text",
735
+ headers: {
736
+ ...headers,
737
+ Authorization: `Bearer ${accessToken}`,
738
+ Accept: "application/json, text/event-stream"
739
+ }
740
+ });
741
+ };
742
+ var forbiddenError = ({ challenge }) => new CliError({
743
+ code: "forbidden",
744
+ message: challenge.includes("insufficient_scope") ? "This sign in is missing the permission this needs." : "Grape refused the request.",
745
+ hint: "Run grape login to sign in again."
746
+ });
747
+ var requestMcp = async ({
748
+ context,
749
+ method,
750
+ params = {},
751
+ accessToken
752
+ }) => {
753
+ const { config } = context;
754
+ let token = accessToken ?? await getAccessToken({ config, now: context.now() });
755
+ let response = await send({ context, accessToken: token, method, params });
756
+ if (response.status === 401 && !accessToken) {
757
+ token = await getAccessToken({
758
+ config,
759
+ now: context.now(),
760
+ rejectedToken: token
761
+ });
762
+ response = await send({ context, accessToken: token, method, params });
763
+ }
764
+ if (response.status === 401) {
765
+ throw signedOutError({ message: "Grape did not accept the sign in." });
766
+ }
767
+ if (response.status === 403) {
768
+ throw forbiddenError({ challenge: response.headers["www-authenticate"] ?? "" });
769
+ }
770
+ const reply = parseMcpReply({ body: String(response.data ?? "") });
771
+ if (!reply) {
772
+ throw new CliError({
773
+ code: "unexpected_response",
774
+ message: `${new URL(config.mcpUrl).host} answered ${response.status} without an MCP reply.`
775
+ });
776
+ }
777
+ if (reply.error) {
778
+ throw new CliError({
779
+ code: "mcp_error",
780
+ message: reply.error.message,
781
+ hint: reply.error.code === -32022 ? "This version of the CLI is out of date. Update it with npm install -g @grapecool/cli." : null
782
+ });
783
+ }
784
+ const result = reply.result ?? {};
785
+ if (typeof result.resultType === "string" && result.resultType !== "complete") {
786
+ throw new CliError({
787
+ code: "unsupported_result",
788
+ message: `The server answered ${method} with a ${result.resultType} result, which the CLI cannot handle.`
789
+ });
790
+ }
791
+ return result;
792
+ };
793
+ var toolErrorCode = ({ text }) => {
794
+ if (/not on the Pro plan/i.test(text)) return "pro_required";
795
+ if (/too many grape calls/i.test(text)) return "rate_limited";
796
+ if (/does not have the notes:\w+ permission/i.test(text)) return "forbidden";
797
+ return "tool_error";
798
+ };
799
+ var callTool = async ({
800
+ context,
801
+ name,
802
+ args = {},
803
+ accessToken
804
+ }) => {
805
+ const result = await requestMcp({
806
+ context,
807
+ method: "tools/call",
808
+ params: { name, arguments: args },
809
+ accessToken
810
+ });
811
+ const text = toolText({ result });
812
+ if (result.isError) {
813
+ const code = toolErrorCode({ text });
814
+ throw new CliError({
815
+ code,
816
+ message: text || `The ${name} tool failed.`,
817
+ hint: code === "forbidden" ? "Run grape login to sign in again." : null
818
+ });
819
+ }
820
+ return { structured: result.structuredContent ?? {}, text, result };
821
+ };
822
+ var listTools = async ({ context }) => {
823
+ const result = await requestMcp({ context, method: "tools/list" });
824
+ return Array.isArray(result.tools) ? result.tools : [];
825
+ };
826
+
827
+ // src/auth/pkce.ts
828
+ import crypto from "node:crypto";
829
+ var createPkcePair = () => {
830
+ const verifier = crypto.randomBytes(32).toString("base64url");
831
+ const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
832
+ return { verifier, challenge };
833
+ };
834
+ var createState = () => crypto.randomBytes(16).toString("base64url");
835
+
836
+ // src/auth/callback.ts
837
+ import http from "node:http";
838
+ import readline from "node:readline";
839
+ var CALLBACK_PATH = "/callback";
840
+ var LOOPBACK_HOST = "127.0.0.1";
841
+ var readAuthorizationResponse = ({
842
+ url,
843
+ state,
844
+ issuer
845
+ }) => {
846
+ const params = url.searchParams;
847
+ if (params.get("state") !== state) {
848
+ throw new CliError({
849
+ code: "state_mismatch",
850
+ message: "That sign in answer belongs to another grape login."
851
+ });
852
+ }
853
+ const iss = params.get("iss");
854
+ if (iss !== null && iss.replace(/\/+$/, "") !== issuer) {
855
+ throw new CliError({
856
+ code: "issuer_mismatch",
857
+ message: `The sign in answer came from ${iss}, not ${issuer}.`
858
+ });
859
+ }
860
+ const error = params.get("error");
861
+ if (error) {
862
+ throw new CliError({
863
+ code: error,
864
+ message: error === "access_denied" ? "The sign in was cancelled in the browser." : `The sign in failed: ${params.get("error_description") ?? error}.`,
865
+ hint: "Run grape login to try again."
866
+ });
867
+ }
868
+ const code = params.get("code");
869
+ if (!code) {
870
+ throw new CliError({
871
+ code: "invalid_response",
872
+ message: "The sign in answer did not include a code.",
873
+ hint: "Run grape login to try again."
874
+ });
875
+ }
876
+ return code;
877
+ };
878
+ var escapeHtml = ({ text }) => text.replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`);
879
+ var page = ({ title, message }) => `<!doctype html>
880
+ <html lang="en">
881
+ <head>
882
+ <meta charset="utf-8">
883
+ <meta name="viewport" content="width=device-width, initial-scale=1">
884
+ <title>${escapeHtml({ text: title })}</title>
885
+ <style>
886
+ :root { --paper: #f7f1e8; --ink: #2b1d14; --muted: #6b5646; }
887
+ @media (prefers-color-scheme: dark) { :root { --paper: #1d1510; --ink: #f7f1e8; --muted: #c9b8a8; } }
888
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: var(--paper); color: var(--ink); font: 16px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
889
+ main { max-width: 28rem; padding: 2rem 16px; text-align: center; }
890
+ h1 { font-size: 1.5rem; margin: 0 0 0.5rem; }
891
+ p { margin: 0; color: var(--muted); }
892
+ </style>
893
+ </head>
894
+ <body><main><h1>${escapeHtml({ text: title })}</h1><p>${escapeHtml({ text: message })}</p></main></body>
895
+ </html>`;
896
+ var startCallbackServer = async ({
897
+ state,
898
+ issuer
899
+ }) => {
900
+ let resolveCode = () => {
901
+ };
902
+ let rejectCode = () => {
903
+ };
904
+ const code = new Promise((resolve, reject) => {
905
+ resolveCode = resolve;
906
+ rejectCode = reject;
907
+ });
908
+ code.catch(() => {
909
+ });
910
+ const server = http.createServer((request, response) => {
911
+ const url = new URL(request.url ?? "/", `http://${LOOPBACK_HOST}`);
912
+ const reply = ({
913
+ status,
914
+ title,
915
+ message
916
+ }) => {
917
+ response.writeHead(status, {
918
+ "Content-Type": "text/html; charset=utf-8",
919
+ "Cache-Control": "no-store",
920
+ "Referrer-Policy": "no-referrer",
921
+ Connection: "close"
922
+ });
923
+ response.end(page({ title, message }));
924
+ };
925
+ if (request.method !== "GET" || url.pathname !== CALLBACK_PATH) {
926
+ response.writeHead(404, { "Content-Type": "text/plain", Connection: "close" });
927
+ response.end("Not found");
928
+ return;
929
+ }
930
+ try {
931
+ const received = readAuthorizationResponse({ url, state, issuer });
932
+ reply({
933
+ status: 200,
934
+ title: "Signed in to Grape",
935
+ message: "You can close this tab and go back to the terminal."
936
+ });
937
+ resolveCode(received);
938
+ } catch (error) {
939
+ reply({
940
+ status: 400,
941
+ title: "Not signed in",
942
+ message: error instanceof Error ? error.message : String(error)
943
+ });
944
+ if (!(error instanceof CliError && error.code === "state_mismatch")) {
945
+ rejectCode(error);
946
+ }
947
+ }
948
+ });
949
+ await new Promise((resolve, reject) => {
950
+ server.once("error", reject);
951
+ server.listen(0, LOOPBACK_HOST, () => resolve());
952
+ });
953
+ const { port } = server.address();
954
+ return {
955
+ redirectUri: `http://${LOOPBACK_HOST}:${port}${CALLBACK_PATH}`,
956
+ code,
957
+ close: () => {
958
+ server.closeAllConnections();
959
+ server.close();
960
+ }
961
+ };
962
+ };
963
+ var waitForPastedAnswer = ({
964
+ io,
965
+ state,
966
+ issuer
967
+ }) => {
968
+ const lines = readline.createInterface({ input: io.stdin, terminal: false });
969
+ const code = new Promise((resolve, reject) => {
970
+ lines.on("line", (line) => {
971
+ const pasted = line.trim();
972
+ if (!pasted) return;
973
+ let url;
974
+ try {
975
+ url = new URL(pasted);
976
+ } catch {
977
+ io.stderr("That is not an address. Paste the whole address from the browser.\n");
978
+ return;
979
+ }
980
+ try {
981
+ resolve(readAuthorizationResponse({ url, state, issuer }));
982
+ } catch (error) {
983
+ if (error instanceof CliError && error.code === "state_mismatch") {
984
+ io.stderr(`${error.message} Paste the address from this login.
985
+ `);
986
+ return;
987
+ }
988
+ reject(error);
989
+ }
990
+ });
991
+ });
992
+ code.catch(() => {
993
+ });
994
+ return { code, close: () => lines.close() };
995
+ };
996
+
997
+ // src/auth/login.ts
998
+ var LOGIN_TIMEOUT_MS = 10 * 6e4;
999
+ var withTimeout = ({ promise, ms }) => {
1000
+ let timer;
1001
+ return Promise.race([
1002
+ promise,
1003
+ new Promise((_resolve, reject) => {
1004
+ timer = setTimeout(
1005
+ () => reject(
1006
+ new CliError({
1007
+ code: "timeout",
1008
+ message: `No answer came back from the browser in ${Math.round(ms / 6e4)} minutes.`,
1009
+ hint: "Run grape login to try again."
1010
+ })
1011
+ ),
1012
+ ms
1013
+ );
1014
+ })
1015
+ ]).finally(() => clearTimeout(timer));
1016
+ };
1017
+ var login = async ({
1018
+ context,
1019
+ openBrowser,
1020
+ timeoutMs = LOGIN_TIMEOUT_MS
1021
+ }) => {
1022
+ const { config, io } = context;
1023
+ const state = createState();
1024
+ const pkce = createPkcePair();
1025
+ const callback = await startCallbackServer({ state, issuer: config.grapeUrl });
1026
+ const cleanups = [callback.close];
1027
+ try {
1028
+ const clientId = await registerClient({
1029
+ config,
1030
+ redirectUri: callback.redirectUri
1031
+ });
1032
+ const authorizeUrl = buildAuthorizeUrl({
1033
+ config,
1034
+ clientId,
1035
+ redirectUri: callback.redirectUri,
1036
+ codeChallenge: pkce.challenge,
1037
+ state
1038
+ });
1039
+ const opened = openBrowser ? await context.openUrl({ url: authorizeUrl }) : false;
1040
+ const answers = [callback.code];
1041
+ writeNotice({
1042
+ io,
1043
+ text: opened ? `Opened the Grape sign in page in your browser. If it did not open, go to:
1044
+
1045
+ ${authorizeUrl}
1046
+ ` : `Open this page in a browser to sign in to Grape:
1047
+
1048
+ ${authorizeUrl}
1049
+ `
1050
+ });
1051
+ if (!opened && io.stdinKind === "tty") {
1052
+ const pasted = waitForPastedAnswer({ io, state, issuer: config.grapeUrl });
1053
+ cleanups.push(pasted.close);
1054
+ answers.push(pasted.code);
1055
+ writeNotice({
1056
+ io,
1057
+ text: "Choose Allow and this finishes by itself. If the browser is on another device, paste the address it ends up on here."
1058
+ });
1059
+ } else {
1060
+ writeNotice({ io, text: "Waiting for you to choose Allow\u2026" });
1061
+ }
1062
+ const code = await withTimeout({ promise: Promise.race(answers), ms: timeoutMs });
1063
+ const tokens = await exchangeCode({
1064
+ config,
1065
+ clientId,
1066
+ code,
1067
+ codeVerifier: pkce.verifier,
1068
+ redirectUri: callback.redirectUri,
1069
+ now: context.now()
1070
+ });
1071
+ const { structured: account } = await callTool({
1072
+ context,
1073
+ name: "get_profile",
1074
+ accessToken: tokens.accessToken
1075
+ });
1076
+ const credentials = {
1077
+ grapeUrl: config.grapeUrl,
1078
+ mcpUrl: config.mcpUrl,
1079
+ clientId,
1080
+ ...tokens,
1081
+ account: { id: account.id, name: account.name, email: account.email }
1082
+ };
1083
+ await withCredentialsLock({
1084
+ config,
1085
+ run: () => writeCredentials({ config, credentials })
1086
+ });
1087
+ return credentials;
1088
+ } finally {
1089
+ cleanups.forEach((cleanup) => cleanup());
1090
+ }
1091
+ };
1092
+
1093
+ // src/commands/account.ts
1094
+ var runLogin = async ({
1095
+ context,
1096
+ openBrowser
1097
+ }) => {
1098
+ const credentials = await login({ context, openBrowser });
1099
+ const account = credentials.account;
1100
+ if (context.config.accessToken) {
1101
+ writeNotice({
1102
+ io: context.io,
1103
+ text: "GRAPE_ACCESS_TOKEN is set, so commands keep using that token until you unset it."
1104
+ });
1105
+ }
1106
+ if (context.json) {
1107
+ return writeJson({
1108
+ io: context.io,
1109
+ value: { signedIn: true, account, scope: credentials.scope }
1110
+ });
1111
+ }
1112
+ writeLine({
1113
+ io: context.io,
1114
+ text: `Signed in as ${account?.name} (${account?.email}).`
1115
+ });
1116
+ };
1117
+ var runLogout = async ({ context }) => {
1118
+ const removed = await withCredentialsLock({
1119
+ config: context.config,
1120
+ run: () => deleteCredentials({ config: context.config })
1121
+ });
1122
+ if (context.json) return writeJson({ io: context.io, value: { signedOut: removed } });
1123
+ writeLine({
1124
+ io: context.io,
1125
+ text: removed ? "Signed out. The sign in was removed from this computer." : "You were not signed in on this computer."
1126
+ });
1127
+ };
1128
+ var runWhoami = async ({ context }) => {
1129
+ const { config } = context;
1130
+ const { structured: account } = await callTool({
1131
+ context,
1132
+ name: "get_profile"
1133
+ });
1134
+ if (!config.accessToken) {
1135
+ await withCredentialsLock({
1136
+ config,
1137
+ run: async () => {
1138
+ const current = await readCredentials({ config });
1139
+ if (current) {
1140
+ await writeCredentials({
1141
+ config,
1142
+ credentials: {
1143
+ ...current,
1144
+ account: { id: account.id, name: account.name, email: account.email }
1145
+ }
1146
+ });
1147
+ }
1148
+ }
1149
+ });
1150
+ }
1151
+ if (context.json) {
1152
+ return writeJson({
1153
+ io: context.io,
1154
+ value: {
1155
+ ...account,
1156
+ grapeUrl: config.grapeUrl,
1157
+ mcpUrl: config.mcpUrl,
1158
+ source: config.accessToken ? "GRAPE_ACCESS_TOKEN" : credentialsPath({ config })
1159
+ }
1160
+ });
1161
+ }
1162
+ writeLine({
1163
+ io: context.io,
1164
+ text: `Signed in as ${account.name} (${account.email}) on ${new URL(config.grapeUrl).host}.`
1165
+ });
1166
+ };
1167
+ var registerAccountCommands = ({
1168
+ program,
1169
+ base
1170
+ }) => {
1171
+ program.command("login").description("Sign in with your Grape account in the browser").option("--no-browser", "Print the sign in page address instead of opening it").action(
1172
+ async (options, command) => runLogin({
1173
+ context: contextFor({ base, command }),
1174
+ openBrowser: options.browser
1175
+ })
1176
+ );
1177
+ program.command("logout").description("Remove the sign in from this computer").action(
1178
+ async (_options, command) => runLogout({ context: contextFor({ base, command }) })
1179
+ );
1180
+ program.command("whoami").description("Show which Grape account the CLI is signed in to").action(
1181
+ async (_options, command) => runWhoami({ context: contextFor({ base, command }) })
1182
+ );
1183
+ };
1184
+
1185
+ // src/format/text.ts
1186
+ import { isValid, parseISO, formatDistanceStrict } from "date-fns";
1187
+ var plural = ({
1188
+ count,
1189
+ one,
1190
+ many
1191
+ }) => `${count} ${count === 1 ? one : many}`;
1192
+ var truncate = ({ text, max }) => text.length > max ? `${text.slice(0, max - 1)}\u2026` : text;
1193
+ var singleLine = ({ text }) => text.replace(/\s+/g, " ").trim();
1194
+ var relativeTime = ({ iso, now }) => {
1195
+ const date = parseISO(iso);
1196
+ if (!isValid(date)) return iso;
1197
+ return formatDistanceStrict(date, now, { addSuffix: true });
1198
+ };
1199
+ var renderTable = ({
1200
+ headers,
1201
+ rows
1202
+ }) => {
1203
+ const widths = headers.map(
1204
+ (header, index) => Math.max(header.length, ...rows.map((row) => (row[index] ?? "").length))
1205
+ );
1206
+ const renderRow = ({ cells }) => cells.map(
1207
+ (cell, index) => index === cells.length - 1 ? cell : cell.padEnd(widths[index])
1208
+ ).join(" ").trimEnd();
1209
+ return [
1210
+ renderRow({ cells: headers }),
1211
+ ...rows.map((cells) => renderRow({ cells }))
1212
+ ].join("\n");
1213
+ };
1214
+ var slugFromUrl = ({ url }) => {
1215
+ try {
1216
+ return new URL(url).pathname.split("/").filter(Boolean).pop() ?? url;
1217
+ } catch {
1218
+ return url;
1219
+ }
1220
+ };
1221
+
1222
+ // src/commands/markdown-input.ts
1223
+ import fs3 from "node:fs/promises";
1224
+ var readMarkdown = async ({
1225
+ io,
1226
+ words,
1227
+ file
1228
+ }) => {
1229
+ const typed = joinWords({ words });
1230
+ if (typed && file !== void 0) {
1231
+ throw usageError({
1232
+ message: "Give the text as words or with --file, not both."
1233
+ });
1234
+ }
1235
+ let markdown = typed;
1236
+ if (file === "-") {
1237
+ markdown = await readStream({ stream: io.stdin });
1238
+ } else if (file !== void 0) {
1239
+ try {
1240
+ markdown = await fs3.readFile(file, "utf8");
1241
+ } catch (error) {
1242
+ throw usageError({
1243
+ message: `Could not read ${file}: ${error.code ?? String(error)}.`
1244
+ });
1245
+ }
1246
+ } else if (!typed && (io.stdinKind === "pipe" || io.stdinKind === "file")) {
1247
+ markdown = await readStream({ stream: io.stdin });
1248
+ }
1249
+ if (!markdown.trim()) {
1250
+ throw usageError({
1251
+ message: "There is no text to write.",
1252
+ hint: 'Pass it as words, with --file <path>, or on stdin: echo "# Title" | grape notes create'
1253
+ });
1254
+ }
1255
+ return markdown;
1256
+ };
1257
+ var BLOCK_PLACEHOLDER = /\{\{grape-block:[A-Za-z0-9_-]+:\d+:\d+\}\}/g;
1258
+ var blockPlaceholders = ({ markdown }) => markdown.match(BLOCK_PLACEHOLDER) ?? [];
1259
+
1260
+ // src/format/notes.ts
1261
+ var TITLE_WIDTH = 60;
1262
+ var titleOf = ({ note }) => singleLine({ text: note.title }) || "Untitled";
1263
+ var describeNote = ({ note }) => `"${titleOf({ note })}" (${note.localId})`;
1264
+ var noteLabel = ({ note }) => {
1265
+ const marks = [
1266
+ note.pinned ? "pinned" : null,
1267
+ note.inRecentlyDeleted ? "in Recently Deleted" : null
1268
+ ].filter(Boolean);
1269
+ const title = truncate({ text: titleOf({ note }), max: TITLE_WIDTH });
1270
+ return marks.length ? `${title} (${marks.join(", ")})` : title;
1271
+ };
1272
+ var renderNoteList = ({
1273
+ notes,
1274
+ now
1275
+ }) => {
1276
+ if (notes.length === 0) return "No notes.";
1277
+ return renderTable({
1278
+ headers: ["ID", "UPDATED", "TITLE"],
1279
+ rows: notes.map((note) => [
1280
+ note.localId,
1281
+ relativeTime({ iso: note.updatedAt, now }),
1282
+ noteLabel({ note })
1283
+ ])
1284
+ });
1285
+ };
1286
+ var renderSearchHits = ({ hits }) => hits.map(
1287
+ (hit) => [
1288
+ `${noteLabel({ note: hit })} ${hit.localId}`,
1289
+ ` ${singleLine({ text: hit.snippet || hit.excerpt })}`
1290
+ ].join("\n")
1291
+ ).join("\n\n");
1292
+ var renderFolderTree = ({ folders }) => {
1293
+ if (folders.length === 0) return "No folders.";
1294
+ const known = new Set(folders.map((folder) => folder.localId));
1295
+ const children = /* @__PURE__ */ new Map();
1296
+ for (const folder of folders) {
1297
+ const parent = folder.parentLocalId && known.has(folder.parentLocalId) ? folder.parentLocalId : null;
1298
+ children.set(parent, [...children.get(parent) ?? [], folder]);
1299
+ }
1300
+ const rows = [];
1301
+ const visited = /* @__PURE__ */ new Set();
1302
+ const walk = ({ parent, depth }) => {
1303
+ const siblings = [...children.get(parent) ?? []].sort(
1304
+ (a, b) => a.order - b.order || a.name.localeCompare(b.name)
1305
+ );
1306
+ for (const folder of siblings) {
1307
+ if (visited.has(folder.localId)) continue;
1308
+ visited.add(folder.localId);
1309
+ rows.push([
1310
+ folder.localId,
1311
+ String(folder.noteCount),
1312
+ `${" ".repeat(depth)}${singleLine({ text: folder.name })}`
1313
+ ]);
1314
+ walk({ parent: folder.localId, depth: depth + 1 });
1315
+ }
1316
+ };
1317
+ walk({ parent: null, depth: 0 });
1318
+ return renderTable({ headers: ["ID", "NOTES", "FOLDER"], rows });
1319
+ };
1320
+ var describeFolder = ({ folder }) => `"${singleLine({ text: folder.name })}" (${folder.localId}, ${plural({
1321
+ count: folder.noteCount,
1322
+ one: "note",
1323
+ many: "notes"
1324
+ })})`;
1325
+
1326
+ // src/commands/notes.ts
1327
+ var PAGE_SIZE = 50;
1328
+ var MAX_PAGES = 100;
1329
+ var pinnedFrom = ({ options }) => {
1330
+ if (options.pin && options.unpin) {
1331
+ throw usageError({ message: "Choose --pin or --unpin, not both." });
1332
+ }
1333
+ if (options.pin) return true;
1334
+ if (options.unpin) return false;
1335
+ return null;
1336
+ };
1337
+ var listNotes = async ({
1338
+ context,
1339
+ folder,
1340
+ pinned,
1341
+ deleted,
1342
+ limit,
1343
+ cursor,
1344
+ all
1345
+ }) => {
1346
+ const filters = {
1347
+ folderLocalId: folder,
1348
+ pinnedOnly: pinned,
1349
+ includeRecentlyDeleted: deleted
1350
+ };
1351
+ const notes = [];
1352
+ let next = cursor;
1353
+ let pages = 0;
1354
+ do {
1355
+ const { structured } = await callTool({
1356
+ context,
1357
+ name: "list_notes",
1358
+ args: { ...filters, cursor: next, limit: all ? PAGE_SIZE : limit ?? 20 }
1359
+ });
1360
+ notes.push(...structured.notes);
1361
+ next = structured.nextCursor;
1362
+ pages += 1;
1363
+ } while (all && next && pages < MAX_PAGES);
1364
+ if (context.json) {
1365
+ return writeJson({ io: context.io, value: { notes, nextCursor: next } });
1366
+ }
1367
+ writeLine({ io: context.io, text: renderNoteList({ notes, now: context.now() }) });
1368
+ if (next) {
1369
+ writeNotice({
1370
+ io: context.io,
1371
+ text: `
1372
+ More notes: grape notes list --cursor ${next}${all ? "" : " (or --all)"}`
1373
+ });
1374
+ }
1375
+ };
1376
+ var searchNotes = async ({
1377
+ context,
1378
+ query,
1379
+ limit
1380
+ }) => {
1381
+ const { structured } = await callTool({
1382
+ context,
1383
+ name: "search_notes",
1384
+ args: { query, limit }
1385
+ });
1386
+ if (context.json) return writeJson({ io: context.io, value: structured });
1387
+ writeLine({
1388
+ io: context.io,
1389
+ text: structured.notes.length ? renderSearchHits({ hits: structured.notes }) : `No note mentions "${query}".`
1390
+ });
1391
+ };
1392
+ var getNote = async ({
1393
+ context,
1394
+ localId
1395
+ }) => {
1396
+ const { structured } = await callTool({
1397
+ context,
1398
+ name: "get_note",
1399
+ args: { localId }
1400
+ });
1401
+ if (context.json) return writeJson({ io: context.io, value: structured });
1402
+ const { markdown, title } = structured.note;
1403
+ const body = markdown.trim() ? markdown : `${title || "Untitled"}
1404
+
1405
+ (empty note)`;
1406
+ writeLine({ io: context.io, text: body.replace(/\n+$/, "") });
1407
+ };
1408
+ var createNote = async ({
1409
+ context,
1410
+ markdown,
1411
+ folder,
1412
+ pinned
1413
+ }) => {
1414
+ const { structured } = await callTool({
1415
+ context,
1416
+ name: "create_note",
1417
+ args: { markdown, folderLocalId: folder, pinned }
1418
+ });
1419
+ if (context.json) return writeJson({ io: context.io, value: structured });
1420
+ writeLine({ io: context.io, text: `Created ${describeNote({ note: structured.note })}.` });
1421
+ };
1422
+ var guardBlocks = async ({
1423
+ context,
1424
+ localId,
1425
+ markdown
1426
+ }) => {
1427
+ const { structured } = await callTool({
1428
+ context,
1429
+ name: "get_note",
1430
+ args: { localId }
1431
+ });
1432
+ const kept = new Set(blockPlaceholders({ markdown }));
1433
+ const dropped = blockPlaceholders({ markdown: structured.note.markdown }).filter(
1434
+ (placeholder) => !kept.has(placeholder)
1435
+ );
1436
+ if (dropped.length > 0) {
1437
+ throw new CliError({
1438
+ code: "would_remove_blocks",
1439
+ message: `This would remove ${plural({
1440
+ count: dropped.length,
1441
+ one: "block",
1442
+ many: "blocks"
1443
+ })} (images, drawings, decks and the like) from the note: ${dropped.join(", ")}.`,
1444
+ hint: "Keep those lines from grape notes get in the new text, or add --force to remove them."
1445
+ });
1446
+ }
1447
+ };
1448
+ var updateNote = async ({
1449
+ context,
1450
+ localId,
1451
+ mode,
1452
+ markdown,
1453
+ pinned,
1454
+ force
1455
+ }) => {
1456
+ if (mode === "replace" && !force) {
1457
+ await guardBlocks({ context, localId, markdown });
1458
+ }
1459
+ const { structured } = await callTool({
1460
+ context,
1461
+ name: "update_note",
1462
+ args: { localId, mode, markdown, pinned }
1463
+ });
1464
+ if (context.json) return writeJson({ io: context.io, value: structured });
1465
+ writeLine({
1466
+ io: context.io,
1467
+ text: `${mode === "append" ? "Added to" : "Replaced"} ${describeNote({ note: structured.note })}.`
1468
+ });
1469
+ };
1470
+ var moveNote = async ({
1471
+ context,
1472
+ localId,
1473
+ folder
1474
+ }) => {
1475
+ const { structured } = await callTool({
1476
+ context,
1477
+ name: "move_note",
1478
+ args: { localId, folderLocalId: folder }
1479
+ });
1480
+ if (context.json) return writeJson({ io: context.io, value: structured });
1481
+ writeLine({ io: context.io, text: `Moved ${describeNote({ note: structured.note })}.` });
1482
+ };
1483
+ var deleteNote = async ({
1484
+ context,
1485
+ localId
1486
+ }) => {
1487
+ const { structured } = await callTool({
1488
+ context,
1489
+ name: "delete_note",
1490
+ args: { localId }
1491
+ });
1492
+ if (context.json) return writeJson({ io: context.io, value: structured });
1493
+ writeLine({
1494
+ io: context.io,
1495
+ text: `Moved ${describeNote({ note: structured.note })} to Recently Deleted. Restore it in Grape within 30 days, or with grape notes move.`
1496
+ });
1497
+ };
1498
+ var addWriteOptions = ({
1499
+ command,
1500
+ canUnpin = true
1501
+ }) => {
1502
+ command.option("--file <path>", "Read the markdown from a file, or - for stdin").option("--pin", "Pin the note");
1503
+ return canUnpin ? command.option("--unpin", "Unpin the note") : command;
1504
+ };
1505
+ var registerNoteCommands = ({
1506
+ program,
1507
+ base
1508
+ }) => {
1509
+ const notes = program.command("notes").description("Read and write the notes in your Grape account (Pro)");
1510
+ notes.command("list").description("List notes, newest first").option("--folder <id>", "Only the notes in this folder").option("--pinned", "Only pinned notes").option("--deleted", "Include the notes in Recently Deleted").option("--limit <count>", "Notes per page, at most 50 (default 20)").option("--cursor <cursor>", "Start at the page a previous list pointed to").option("--all", "Every page, not just the first").action(
1511
+ async (options, command) => listNotes({
1512
+ context: contextFor({ base, command }),
1513
+ folder: options.folder ?? null,
1514
+ pinned: Boolean(options.pinned),
1515
+ deleted: Boolean(options.deleted),
1516
+ limit: parseLimit({ value: options.limit, max: 50 }),
1517
+ cursor: options.cursor ?? null,
1518
+ all: Boolean(options.all)
1519
+ })
1520
+ );
1521
+ notes.command("search").description("Find the notes that contain a word or phrase").argument("<query...>", "What to look for").option("--limit <count>", "How many notes, at most 20 (default 10)").action(
1522
+ async (words, options, command) => searchNotes({
1523
+ context: contextFor({ base, command }),
1524
+ query: joinWords({ words }),
1525
+ limit: parseLimit({ value: options.limit, max: 20 })
1526
+ })
1527
+ );
1528
+ notes.command("get").alias("read").description("Print one note as markdown").argument("<id>", "The note id, as notes list shows it").action(
1529
+ async (localId, _options, command) => getNote({ context: contextFor({ base, command }), localId })
1530
+ );
1531
+ addWriteOptions({
1532
+ command: notes.command("create").description("Create a note from markdown; the first line is its title").argument("[markdown...]", "The note text, or use --file or stdin").option("--folder <id>", "The folder to put it in (default: Notes)"),
1533
+ canUnpin: false
1534
+ }).action(
1535
+ async (words, options, command) => {
1536
+ const context = contextFor({ base, command });
1537
+ await createNote({
1538
+ context,
1539
+ markdown: await readMarkdown({ io: context.io, words, file: options.file }),
1540
+ folder: options.folder ?? null,
1541
+ pinned: Boolean(options.pin)
1542
+ });
1543
+ }
1544
+ );
1545
+ addWriteOptions({
1546
+ command: notes.command("append").description("Add markdown to the end of a note").argument("<id>", "The note id").argument("[markdown...]", "The text to add, or use --file or stdin")
1547
+ }).action(async (localId, words, options, command) => {
1548
+ const context = contextFor({ base, command });
1549
+ await updateNote({
1550
+ context,
1551
+ localId,
1552
+ mode: "append",
1553
+ markdown: await readMarkdown({ io: context.io, words, file: options.file }),
1554
+ pinned: pinnedFrom({ options }),
1555
+ force: false
1556
+ });
1557
+ });
1558
+ addWriteOptions({
1559
+ command: notes.command("replace").description("Replace the whole text of a note with markdown").argument("<id>", "The note id").argument("[markdown...]", "The new text, or use --file or stdin").option("--force", "Remove the blocks whose placeholders the new text leaves out")
1560
+ }).action(
1561
+ async (localId, words, options, command) => {
1562
+ const context = contextFor({ base, command });
1563
+ await updateNote({
1564
+ context,
1565
+ localId,
1566
+ mode: "replace",
1567
+ markdown: await readMarkdown({ io: context.io, words, file: options.file }),
1568
+ pinned: pinnedFrom({ options }),
1569
+ force: Boolean(options.force)
1570
+ });
1571
+ }
1572
+ );
1573
+ notes.command("move").description("Move a note to another folder, which also restores a deleted one").argument("<id>", "The note id").argument("<folder-id>", "The folder id, as folders list shows it").action(
1574
+ async (localId, folder, _options, command) => moveNote({ context: contextFor({ base, command }), localId, folder })
1575
+ );
1576
+ notes.command("delete").description("Move a note to Recently Deleted, restorable for 30 days").argument("<id>", "The note id").action(
1577
+ async (localId, _options, command) => deleteNote({ context: contextFor({ base, command }), localId })
1578
+ );
1579
+ };
1580
+
1581
+ // src/commands/tools.ts
1582
+ var showTools = async ({ context }) => {
1583
+ const tools = await listTools({ context });
1584
+ if (context.json) return writeJson({ io: context.io, value: { tools } });
1585
+ writeLine({
1586
+ io: context.io,
1587
+ text: renderTable({
1588
+ headers: ["TOOL", "WHAT IT DOES"],
1589
+ rows: tools.map((tool) => [
1590
+ tool.name,
1591
+ truncate({ text: singleLine({ text: tool.description ?? tool.title ?? "" }), max: 100 })
1592
+ ])
1593
+ })
1594
+ });
1595
+ };
1596
+ var parseArguments = ({ text }) => {
1597
+ let parsed;
1598
+ try {
1599
+ parsed = JSON.parse(text);
1600
+ } catch {
1601
+ throw usageError({ message: "The tool arguments are not valid JSON." });
1602
+ }
1603
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1604
+ throw usageError({ message: 'The tool arguments must be a JSON object, like {"localId":"123"}.' });
1605
+ }
1606
+ return parsed;
1607
+ };
1608
+ var runTool = async ({
1609
+ context,
1610
+ name,
1611
+ args
1612
+ }) => {
1613
+ const { structured, text, result } = await callTool({ context, name, args });
1614
+ if (context.json) {
1615
+ return writeJson({
1616
+ io: context.io,
1617
+ value: result.structuredContent ?? { content: result.content ?? [] }
1618
+ });
1619
+ }
1620
+ writeLine({
1621
+ io: context.io,
1622
+ text: text || JSON.stringify(structured, null, 2)
1623
+ });
1624
+ };
1625
+ var registerToolCommands = ({
1626
+ program,
1627
+ base
1628
+ }) => {
1629
+ const tools = program.command("tools").description("List and call the MCP server tools directly (Pro)");
1630
+ tools.command("list").description("List the tools with what each one does").action(
1631
+ async (_options, command) => showTools({ context: contextFor({ base, command }) })
1632
+ );
1633
+ tools.command("call").description("Call one tool with JSON arguments and print its answer").argument("<name>", "The tool name, as tools list shows it").argument("[arguments]", "The arguments as a JSON object (default {}), or - for stdin").action(async (name, json, _options, command) => {
1634
+ const context = contextFor({ base, command });
1635
+ const text = json === "-" ? await readStream({ stream: context.io.stdin }) : json ?? "{}";
1636
+ await runTool({ context, name, args: parseArguments({ text }) });
1637
+ });
1638
+ };
1639
+
1640
+ // src/commands/guides.ts
1641
+ var isHelpArticleUrl = ({ url }) => url.includes("/help/");
1642
+ var searchGuides = async ({
1643
+ context,
1644
+ question,
1645
+ limit,
1646
+ full
1647
+ }) => {
1648
+ const { data, meta } = await callPublicApi({
1649
+ config: context.config,
1650
+ path: "/help-search",
1651
+ body: { q: question, ...limit ? { limit } : {} }
1652
+ });
1653
+ if (context.json) return writeJson({ io: context.io, value: { data, meta } });
1654
+ if (data.length === 0) {
1655
+ return writeLine({
1656
+ io: context.io,
1657
+ text: `No guide answers "${question}". Browse them all with grape guides list.`
1658
+ });
1659
+ }
1660
+ const blocks = data.map((hit) => {
1661
+ if (full) return [`# ${hit.title}`, "", hit.body.trim()].join("\n");
1662
+ const lines = [hit.title, ` ${hit.summary}`, ` ${hit.url}`];
1663
+ if (isHelpArticleUrl({ url: hit.url })) {
1664
+ lines.push(` grape guides read ${slugFromUrl({ url: hit.url })}`);
1665
+ }
1666
+ return lines.join("\n");
1667
+ });
1668
+ writeLine({ io: context.io, text: blocks.join("\n\n") });
1669
+ };
1670
+ var listGuides = async ({
1671
+ context,
1672
+ category
1673
+ }) => {
1674
+ const { data } = await callPublicApi({
1675
+ config: context.config,
1676
+ path: "/help-articles",
1677
+ body: category ? { category } : {}
1678
+ });
1679
+ if (context.json) return writeJson({ io: context.io, value: data });
1680
+ writeLine({
1681
+ io: context.io,
1682
+ text: renderTable({
1683
+ headers: ["SLUG", "CATEGORY", "TITLE"],
1684
+ rows: data.map((article) => [
1685
+ article.slug,
1686
+ article.category,
1687
+ truncate({ text: singleLine({ text: article.title }), max: 70 })
1688
+ ])
1689
+ })
1690
+ });
1691
+ };
1692
+ var readGuide = async ({
1693
+ context,
1694
+ slug
1695
+ }) => {
1696
+ const { data } = await callPublicApi({
1697
+ config: context.config,
1698
+ path: `/help-articles/${encodeURIComponent(slug)}`
1699
+ });
1700
+ if (context.json) return writeJson({ io: context.io, value: data });
1701
+ writeLine({
1702
+ io: context.io,
1703
+ text: [`# ${data.title}`, "", (data.body ?? data.description).trim()].join(
1704
+ "\n"
1705
+ )
1706
+ });
1707
+ };
1708
+ var registerGuideCommands = ({
1709
+ program,
1710
+ base
1711
+ }) => {
1712
+ const guides = program.command("guides").description("Search and read the Grape help center, no sign in needed");
1713
+ guides.command("search").description("Answer a how do I question from the help center").argument("<question...>", "The question, in your own words").option("--limit <count>", "How many guides to return, at most 10").option("--full", "Print each guide in full instead of a summary").action(
1714
+ async (words, options, command) => searchGuides({
1715
+ context: contextFor({ base, command }),
1716
+ question: joinWords({ words }),
1717
+ limit: parseLimit({ value: options.limit, max: 10 }),
1718
+ full: Boolean(options.full)
1719
+ })
1720
+ );
1721
+ guides.command("list").description("List every help article").option("--category <slug>", "Only the articles in one category").action(
1722
+ async (options, command) => listGuides({
1723
+ context: contextFor({ base, command }),
1724
+ category: options.category ?? null
1725
+ })
1726
+ );
1727
+ guides.command("read").description("Print one help article as markdown").argument("<slug>", "The article slug, as guides list or search shows it").action(
1728
+ async (slug, _options, command) => readGuide({ context: contextFor({ base, command }), slug })
1729
+ );
1730
+ };
1731
+
1732
+ // src/commands/folders.ts
1733
+ var nameFrom = ({ words }) => {
1734
+ const name = joinWords({ words });
1735
+ if (!name) throw usageError({ message: "The folder needs a name." });
1736
+ return name;
1737
+ };
1738
+ var listFolders = async ({ context }) => {
1739
+ const { structured } = await callTool({
1740
+ context,
1741
+ name: "list_folders"
1742
+ });
1743
+ if (context.json) return writeJson({ io: context.io, value: structured });
1744
+ writeLine({ io: context.io, text: renderFolderTree({ folders: structured.folders }) });
1745
+ };
1746
+ var changeFolder = async ({
1747
+ context,
1748
+ tool,
1749
+ args,
1750
+ verb
1751
+ }) => {
1752
+ const { structured } = await callTool({
1753
+ context,
1754
+ name: tool,
1755
+ args
1756
+ });
1757
+ if (context.json) return writeJson({ io: context.io, value: structured });
1758
+ writeLine({ io: context.io, text: `${verb} ${describeFolder({ folder: structured.folder })}.` });
1759
+ };
1760
+ var deleteFolder = async ({
1761
+ context,
1762
+ localId
1763
+ }) => {
1764
+ const { structured } = await callTool({ context, name: "delete_folder", args: { localId } });
1765
+ if (context.json) return writeJson({ io: context.io, value: structured });
1766
+ writeLine({
1767
+ io: context.io,
1768
+ text: `Deleted ${plural({
1769
+ count: structured.deletedFolderLocalIds.length,
1770
+ one: "folder",
1771
+ many: "folders"
1772
+ })} and moved ${plural({
1773
+ count: structured.movedNoteCount,
1774
+ one: "note",
1775
+ many: "notes"
1776
+ })} to Recently Deleted, where they can be restored for 30 days.`
1777
+ });
1778
+ };
1779
+ var registerFolderCommands = ({
1780
+ program,
1781
+ base
1782
+ }) => {
1783
+ const folders = program.command("folders").description("List and change the folders in your Grape account (Pro)");
1784
+ folders.command("list").description("List every folder with its id and how many notes it holds").action(
1785
+ async (_options, command) => listFolders({ context: contextFor({ base, command }) })
1786
+ );
1787
+ folders.command("create").description("Create a folder").argument("<name...>", "The folder name").option("--parent <id>", "Create it inside this folder").action(
1788
+ async (words, options, command) => changeFolder({
1789
+ context: contextFor({ base, command }),
1790
+ tool: "create_folder",
1791
+ args: { name: nameFrom({ words }), parentLocalId: options.parent ?? null },
1792
+ verb: "Created"
1793
+ })
1794
+ );
1795
+ folders.command("rename").description("Rename a folder").argument("<id>", "The folder id").argument("<name...>", "The new name").action(
1796
+ async (localId, words, _options, command) => changeFolder({
1797
+ context: contextFor({ base, command }),
1798
+ tool: "update_folder",
1799
+ args: { localId, name: nameFrom({ words }), parentLocalId: null },
1800
+ verb: "Renamed"
1801
+ })
1802
+ );
1803
+ folders.command("move").description("Move a folder into another folder, or to the top level with root").argument("<id>", "The folder id").argument("<parent-id>", "The folder to move it into, or root").action(
1804
+ async (localId, parent, _options, command) => changeFolder({
1805
+ context: contextFor({ base, command }),
1806
+ tool: "update_folder",
1807
+ args: { localId, name: null, parentLocalId: parent },
1808
+ verb: "Moved"
1809
+ })
1810
+ );
1811
+ folders.command("delete").description("Delete a folder and its subfolders; their notes go to Recently Deleted").argument("<id>", "The folder id").action(
1812
+ async (localId, _options, command) => deleteFolder({ context: contextFor({ base, command }), localId })
1813
+ );
1814
+ };
1815
+
1816
+ // src/program.ts
1817
+ var EXAMPLES = `
1818
+ Examples:
1819
+ grape login Sign in with your Grape account
1820
+ grape notes list Your newest notes
1821
+ grape notes search "quarterly plan" Notes that mention a phrase
1822
+ grape notes get 4821930571234 One note as markdown
1823
+ echo "# Groceries" | grape notes create
1824
+ grape folders list --json Folders as JSON, for scripts and agents
1825
+ grape guides search "lock a note" Answers from the help center
1826
+
1827
+ Notes and folders need a Grape Pro account. Plans, changelog, downloads and
1828
+ guides work without signing in. Docs: https://grape.cool/docs#cli`;
1829
+ var createProgram = ({ base }) => {
1830
+ const program = new Command();
1831
+ program.name("grape").description(
1832
+ "The Grape command line: your notes and folders, and the Grape help center, from a terminal or an AI agent."
1833
+ ).version(CLI_VERSION, "-v, --version", "Print the CLI version").option("--json", "Print JSON only, for scripts and agents").helpOption("-h, --help", "Show help for a command").helpCommand("help [command]", "Show help for a command").configureHelp({ showGlobalOptions: true }).addHelpText("after", EXAMPLES).showSuggestionAfterError().exitOverride().configureOutput({
1834
+ writeOut: (text) => base.io.stdout(text),
1835
+ writeErr: (text) => base.io.stderr(text)
1836
+ });
1837
+ registerAccountCommands({ program, base });
1838
+ registerNoteCommands({ program, base });
1839
+ registerFolderCommands({ program, base });
1840
+ registerToolCommands({ program, base });
1841
+ registerSiteCommands({ program, base });
1842
+ registerGuideCommands({ program, base });
1843
+ return program;
1844
+ };
1845
+ var reportError = ({
1846
+ io,
1847
+ error,
1848
+ json
1849
+ }) => {
1850
+ if (error instanceof CommanderError) {
1851
+ return error.exitCode === 0 ? EXIT_CODES.ok : EXIT_CODES.usage;
1852
+ }
1853
+ if (isCliError(error)) {
1854
+ if (json) {
1855
+ io.stderr(
1856
+ `${JSON.stringify({
1857
+ error: { code: error.code, message: error.message, hint: error.hint }
1858
+ })}
1859
+ `
1860
+ );
1861
+ } else {
1862
+ writeNotice({
1863
+ io,
1864
+ text: [`Error: ${error.message}`, error.hint].filter(Boolean).join("\n")
1865
+ });
1866
+ }
1867
+ return error.exitCode;
1868
+ }
1869
+ const message = error instanceof Error ? error.message : String(error);
1870
+ if (json) {
1871
+ io.stderr(
1872
+ `${JSON.stringify({ error: { code: "unexpected", message, hint: null } })}
1873
+ `
1874
+ );
1875
+ } else {
1876
+ writeNotice({ io, text: `Error: ${message}` });
1877
+ }
1878
+ if (process.env.GRAPE_DEBUG && error instanceof Error && error.stack) {
1879
+ writeNotice({ io, text: error.stack });
1880
+ }
1881
+ return EXIT_CODES.failure;
1882
+ };
1883
+ var runProgram = async ({
1884
+ argv,
1885
+ base
1886
+ }) => {
1887
+ const program = createProgram({ base });
1888
+ try {
1889
+ await program.parseAsync(argv, { from: "user" });
1890
+ return EXIT_CODES.ok;
1891
+ } catch (error) {
1892
+ return reportError({ io: base.io, error, json: argv.includes("--json") });
1893
+ }
1894
+ };
1895
+
1896
+ // src/cli.ts
1897
+ var main = async () => {
1898
+ const exitCode = await runProgram({
1899
+ argv: process.argv.slice(2),
1900
+ base: {
1901
+ io: processIo(),
1902
+ config: readConfig(),
1903
+ now: () => /* @__PURE__ */ new Date(),
1904
+ openUrl
1905
+ }
1906
+ });
1907
+ process.exitCode = exitCode;
1908
+ };
1909
+ void main();