@grapecool/cli 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +154 -2
- package/dist/cli.js +1909 -0
- 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 = "0.1.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();
|