@acme-skunkworks/agent-skills 1.0.0 → 1.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.
Files changed (43) hide show
  1. package/README.md +5 -4
  2. package/package.json +2 -6
  3. package/skills/changelog/README.md +59 -0
  4. package/skills/changelog/SKILL.md +187 -0
  5. package/skills/changelog/config.example.json +5 -0
  6. package/skills/changelog/config.json +5 -0
  7. package/skills/changelog/package.json +31 -0
  8. package/skills/changelog/references/changelog-contract.md +121 -0
  9. package/skills/changelog/scripts/add-links.mjs +97 -0
  10. package/skills/changelog/scripts/lib/changelog.mjs +46 -0
  11. package/skills/changelog/scripts/lib/config.mjs +53 -0
  12. package/skills/changelog/scripts/lib/derive-packages.mjs +39 -0
  13. package/skills/changelog/scripts/lib/frontmatter.mjs +369 -0
  14. package/skills/changelog/scripts/preflight-changelog-ci.mjs +152 -0
  15. package/skills/changelog/scripts/set-affected-packages.mjs +99 -0
  16. package/skills/changelog/scripts/validate-changelog.mjs +264 -0
  17. package/skills/linear-sync/README.md +47 -0
  18. package/skills/linear-sync/SKILL.md +115 -0
  19. package/skills/linear-sync/config.example.json +4 -0
  20. package/skills/linear-sync/config.json +4 -0
  21. package/skills/linear-sync/package.json +31 -0
  22. package/skills/preflight/README.md +70 -0
  23. package/skills/preflight/SKILL.md +148 -0
  24. package/skills/preflight/config.example.json +6 -0
  25. package/skills/preflight/package.json +33 -0
  26. package/skills/preflight/scripts/classify-lint.mjs +176 -0
  27. package/skills/preflight/scripts/lib/diff-lines.mjs +83 -0
  28. package/skills/preflight/scripts/lib/paths.mjs +26 -0
  29. package/skills/preflight/scripts/lib/scope.mjs +530 -0
  30. package/skills/preflight/scripts/lint-fix.mjs +78 -0
  31. package/skills/preflight/scripts/preflight.mjs +416 -0
  32. package/skills/send-it/README.md +75 -0
  33. package/skills/send-it/SKILL.md +391 -0
  34. package/skills/send-it/config.example.json +5 -0
  35. package/skills/send-it/config.json +5 -0
  36. package/skills/send-it/package.json +33 -0
  37. package/skills/send-it/scripts/derive-bump.mjs +139 -0
  38. package/skills/triage-pr/README.md +56 -0
  39. package/skills/triage-pr/SKILL.md +291 -0
  40. package/skills/triage-pr/config.json +4 -0
  41. package/skills/triage-pr/package.json +32 -0
  42. package/skills/triage-pr/references/review-discipline.md +73 -0
  43. package/skills/triage-pr/scripts/review-threads.mjs +549 -0
@@ -0,0 +1,549 @@
1
+ #!/usr/bin/env node
2
+ // Unresolved-review-feedback fetcher for the triage-pr skill.
3
+ //
4
+ // Fetches a pull request's review feedback via `gh api graphql` and prints
5
+ // minimal JSON, so Phase B can triage findings without pulling whole comment
6
+ // payloads into context. Two shapes of feedback are surfaced separately because
7
+ // they live in different GitHub objects:
8
+ //
9
+ // - unresolvedThreads : inline review threads with `isResolved == false`,
10
+ // raised by a configured review bot. Each is trimmed to
11
+ // { threadId, path, line, isOutdated, author, comments }.
12
+ // - humanThreads : the same, for threads NOT raised by a review bot —
13
+ // surfaced so a human isn't silently dropped, but the
14
+ // skill does not auto-action them.
15
+ // - aiSummaryComments : issue-level comments authored by a review bot (the
16
+ // sticky `track_progress` / `use_sticky_comment` summary).
17
+ // These are NOT review threads and have no `isResolved`,
18
+ // so the reviewThreads query never returns them.
19
+ //
20
+ // The network layer (gh) is kept separate from the pure transform so the
21
+ // transform is unit-tested by `--self-test` with no network access.
22
+ //
23
+ // Usage:
24
+ // node review-threads.mjs <pr-number-or-url> # minimal JSON to stdout
25
+ // node review-threads.mjs <pr> --bots "a[bot],b[bot]" # override review-bot logins
26
+ // node review-threads.mjs <pr> --repo owner/name # set repo explicitly
27
+ // node review-threads.mjs <pr> --include-resolved # keep resolved threads too
28
+ // node review-threads.mjs --self-test # run built-in fixtures
29
+
30
+ import { execFileSync } from "node:child_process";
31
+ import { realpathSync } from "node:fs";
32
+ import { fileURLToPath } from "node:url";
33
+
34
+ // Common AI review-bot logins. GitHub's GraphQL API returns bot logins WITHOUT
35
+ // the `[bot]` suffix (e.g. `claude`, `coderabbitai`), whereas the REST API and
36
+ // many docs show the suffixed form (`claude[bot]`). `botMatches` normalises both
37
+ // sides, so a consumer's config can use either form.
38
+ const DEFAULT_BOTS = ["claude", "cursor", "coderabbitai", "github-actions"];
39
+
40
+ // ---- pure transform (no network) ----------------------------------------
41
+
42
+ /** Strip a trailing `[bot]` suffix so a login compares equal in either form. */
43
+ function normaliseBot(login) {
44
+ return String(login ?? "").replace(/\[bot\]$/, "");
45
+ }
46
+
47
+ /** Build a suffix-insensitive predicate matching a login against the bot list. */
48
+ function makeBotMatcher(bots) {
49
+ const set = new Set(bots.map(normaliseBot));
50
+ return (login) => set.has(normaliseBot(login));
51
+ }
52
+
53
+ /** Reduce raw GraphQL comment nodes to the minimal `{ author, body }`. */
54
+ function trimComments(commentNodes) {
55
+ return (commentNodes ?? []).map((c) => ({
56
+ author: c.author?.login ?? "unknown",
57
+ body: c.body ?? "",
58
+ }));
59
+ }
60
+
61
+ /** Reduce a raw review-thread node to its minimal shape for the report. */
62
+ function shapeThread(node) {
63
+ const comments = trimComments(node.comments?.nodes);
64
+ return {
65
+ threadId: node.id,
66
+ path: node.path ?? null,
67
+ line: node.line ?? node.originalLine ?? null,
68
+ isOutdated: Boolean(node.isOutdated),
69
+ author: comments[0]?.author ?? "unknown",
70
+ comments,
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Build the minimal result from raw GraphQL nodes. Splitting bot threads from
76
+ * human threads honours the skill's "AI bots only" contract while still
77
+ * surfacing human threads for the report.
78
+ */
79
+ export function buildResult({
80
+ number,
81
+ isDraft,
82
+ threadNodes,
83
+ commentNodes,
84
+ bots,
85
+ includeResolved = false,
86
+ }) {
87
+ const isBot = makeBotMatcher(bots);
88
+ const unresolvedThreads = [];
89
+ const humanThreads = [];
90
+
91
+ for (const node of threadNodes ?? []) {
92
+ if (!includeResolved && node.isResolved) continue;
93
+ const thread = shapeThread(node);
94
+ if (isBot(thread.author)) unresolvedThreads.push(thread);
95
+ else humanThreads.push(thread);
96
+ }
97
+
98
+ const aiSummaryComments = (commentNodes ?? [])
99
+ .filter((c) => isBot(c.author?.login))
100
+ .map((c) => ({
101
+ commentId: c.id,
102
+ author: c.author?.login ?? "unknown",
103
+ body: c.body ?? "",
104
+ }));
105
+
106
+ return {
107
+ pr: number,
108
+ isDraft: Boolean(isDraft),
109
+ unresolvedThreads,
110
+ humanThreads,
111
+ aiSummaryComments,
112
+ };
113
+ }
114
+
115
+ // ---- argument parsing ----------------------------------------------------
116
+
117
+ /** Parse argv into `{ pr, bots, repo, includeResolved }`; throws on a flag missing its value, an unknown `--flag`, or a malformed `--repo`. */
118
+ export function parseArgs(argv) {
119
+ const opts = { bots: DEFAULT_BOTS, repo: null, includeResolved: false, pr: null };
120
+ for (let i = 0; i < argv.length; i += 1) {
121
+ const arg = argv[i];
122
+ if (arg === "--include-resolved") opts.includeResolved = true;
123
+ else if (arg === "--bots") {
124
+ const value = argv[++i];
125
+ if (!value || value.startsWith("--")) {
126
+ throw new Error("--bots requires a comma-separated list of bot logins");
127
+ }
128
+ opts.bots = value
129
+ .split(",")
130
+ .map((s) => s.trim())
131
+ .filter(Boolean);
132
+ if (opts.bots.length === 0) {
133
+ throw new Error("--bots requires at least one non-empty bot login");
134
+ }
135
+ } else if (arg === "--repo") {
136
+ const value = argv[++i];
137
+ if (!value || value.startsWith("--")) {
138
+ throw new Error("--repo requires an owner/name value");
139
+ }
140
+ if (!/^[^/\s]+\/[^/\s]+$/.test(value)) {
141
+ throw new Error("--repo must be exactly owner/name");
142
+ }
143
+ opts.repo = value;
144
+ } else if (!arg.startsWith("--") && opts.pr === null) opts.pr = arg;
145
+ else if (!arg.startsWith("--")) throw new Error(`unexpected argument: ${arg}`);
146
+ else throw new Error(`unknown option: ${arg}`);
147
+ }
148
+ return opts;
149
+ }
150
+
151
+ /** Accept a bare number or a full PR URL; return `{ number, repo }`. */
152
+ export function resolvePr(prArg, repoArg) {
153
+ if (prArg == null) throw new Error("no PR number or URL given");
154
+ const urlMatch = String(prArg).match(
155
+ /github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/,
156
+ );
157
+ if (urlMatch) {
158
+ return { number: Number(urlMatch[3]), repo: `${urlMatch[1]}/${urlMatch[2]}` };
159
+ }
160
+ const number = Number(prArg);
161
+ if (!Number.isInteger(number) || number <= 0) {
162
+ throw new Error(`not a PR number or URL: ${prArg}`);
163
+ }
164
+ return { number, repo: repoArg };
165
+ }
166
+
167
+ // ---- network layer (gh) --------------------------------------------------
168
+
169
+ /** Run a `gh` command and return stdout; 30s timeout so a stalled call can't hang. */
170
+ function gh(args) {
171
+ return execFileSync("gh", args, {
172
+ encoding: "utf8",
173
+ maxBuffer: 64 * 1024 * 1024,
174
+ timeout: 30_000, // don't hang forever if a gh call stalls
175
+ });
176
+ }
177
+
178
+ /** Run a GraphQL query via `gh api graphql`, typing each variable as -f/-F. */
179
+ function ghGraphQL(query, variables) {
180
+ const args = ["api", "graphql", "-f", `query=${query}`];
181
+ for (const [key, value] of Object.entries(variables)) {
182
+ if (value === null || value === undefined) continue;
183
+ if (typeof value === "number" || typeof value === "boolean") {
184
+ args.push("-F", `${key}=${value}`);
185
+ } else {
186
+ args.push("-f", `${key}=${value}`);
187
+ }
188
+ }
189
+ return JSON.parse(gh(args));
190
+ }
191
+
192
+ /** Return the current repository as `owner/name`. */
193
+ function detectRepo() {
194
+ return gh(["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]).trim();
195
+ }
196
+
197
+ const THREADS_QUERY = `query($owner:String!,$name:String!,$number:Int!,$cursor:String){
198
+ repository(owner:$owner,name:$name){
199
+ pullRequest(number:$number){
200
+ isDraft
201
+ reviewThreads(first:100, after:$cursor){
202
+ pageInfo{ hasNextPage endCursor }
203
+ nodes{
204
+ id isResolved isOutdated path line originalLine
205
+ comments(first:100){ nodes{ author{ login } body } }
206
+ }
207
+ }
208
+ }
209
+ }
210
+ }`;
211
+
212
+ const COMMENTS_QUERY = `query($owner:String!,$name:String!,$number:Int!,$cursor:String){
213
+ repository(owner:$owner,name:$name){
214
+ pullRequest(number:$number){
215
+ comments(first:100, after:$cursor){
216
+ pageInfo{ hasNextPage endCursor }
217
+ nodes{ id author{ login } body }
218
+ }
219
+ }
220
+ }
221
+ }`;
222
+
223
+ /**
224
+ * Page through a PR sub-connection, collecting every node. Also returns
225
+ * `isDraft`, which is meaningful only for queries that select it (the threads
226
+ * query) and `undefined` otherwise — callers read it from the threads call alone.
227
+ */
228
+ function fetchAll(query, owner, name, number, pick) {
229
+ const nodes = [];
230
+ let cursor = null;
231
+ let isDraft;
232
+ do {
233
+ const data = ghGraphQL(query, { owner, name, number, cursor });
234
+ const pr = data.data.repository.pullRequest;
235
+ if (pr.isDraft !== undefined) isDraft = pr.isDraft;
236
+ const conn = pick(pr);
237
+ nodes.push(...conn.nodes);
238
+ cursor = conn.pageInfo.hasNextPage ? conn.pageInfo.endCursor : null;
239
+ } while (cursor);
240
+ return { nodes, isDraft };
241
+ }
242
+
243
+ /** Fetch a PR's review threads and issue comments from GitHub. */
244
+ function fetchFromGitHub(number, repo) {
245
+ const nameWithOwner = repo ?? detectRepo();
246
+ const parts = nameWithOwner.split("/");
247
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
248
+ throw new Error(`could not resolve repo: ${nameWithOwner}`);
249
+ }
250
+ const [owner, name] = parts;
251
+ const threads = fetchAll(THREADS_QUERY, owner, name, number, (pr) => pr.reviewThreads);
252
+ const comments = fetchAll(COMMENTS_QUERY, owner, name, number, (pr) => pr.comments);
253
+ return { isDraft: threads.isDraft, threadNodes: threads.nodes, commentNodes: comments.nodes };
254
+ }
255
+
256
+ // ---- self-test -----------------------------------------------------------
257
+
258
+ /** Run the built-in fixtures (no network) and exit non-zero on any failure. */
259
+ function selfTest() {
260
+ // GraphQL returns bot logins WITHOUT the `[bot]` suffix (e.g. `cursor`,
261
+ // `claude`, `coderabbitai`), so the fixtures use the bare form.
262
+ const threadNodes = [
263
+ {
264
+ id: "T_bot_unresolved",
265
+ isResolved: false,
266
+ isOutdated: false,
267
+ path: "skills/x/SKILL.md",
268
+ line: 42,
269
+ comments: { nodes: [{ author: { login: "cursor" }, body: "nit: typo" }] },
270
+ },
271
+ {
272
+ id: "T_bot_resolved",
273
+ isResolved: true,
274
+ isOutdated: false,
275
+ path: "a.ts",
276
+ line: 1,
277
+ comments: { nodes: [{ author: { login: "claude" }, body: "done" }] },
278
+ },
279
+ {
280
+ id: "T_bot_outdated",
281
+ isResolved: false,
282
+ isOutdated: true,
283
+ path: "b.ts",
284
+ line: null,
285
+ originalLine: 9,
286
+ comments: { nodes: [{ author: { login: "claude" }, body: "moved" }] },
287
+ },
288
+ {
289
+ id: "T_human",
290
+ isResolved: false,
291
+ isOutdated: false,
292
+ path: "c.ts",
293
+ line: 3,
294
+ comments: { nodes: [{ author: { login: "alice" }, body: "please rename" }] },
295
+ },
296
+ ];
297
+ const commentNodes = [
298
+ { id: "IC_summary", author: { login: "coderabbitai" }, body: "## Review summary" },
299
+ { id: "IC_human", author: { login: "bob" }, body: "lgtm" },
300
+ ];
301
+ const bots = DEFAULT_BOTS;
302
+
303
+ const result = buildResult({
304
+ number: 7,
305
+ isDraft: false,
306
+ threadNodes,
307
+ commentNodes,
308
+ bots,
309
+ });
310
+ const withResolved = buildResult({
311
+ number: 7,
312
+ isDraft: false,
313
+ threadNodes,
314
+ commentNodes,
315
+ bots,
316
+ includeResolved: true,
317
+ });
318
+
319
+ const ids = (arr) => arr.map((t) => t.threadId);
320
+ const cases = [
321
+ {
322
+ name: "unresolved bot thread is included",
323
+ ok: ids(result.unresolvedThreads).includes("T_bot_unresolved"),
324
+ },
325
+ {
326
+ name: "resolved bot thread is excluded by default",
327
+ ok: !ids(result.unresolvedThreads).includes("T_bot_resolved"),
328
+ },
329
+ {
330
+ name: "--include-resolved keeps the resolved bot thread",
331
+ ok: ids(withResolved.unresolvedThreads).includes("T_bot_resolved"),
332
+ },
333
+ {
334
+ name: "outdated flag and originalLine fallback are preserved",
335
+ ok:
336
+ result.unresolvedThreads.find((t) => t.threadId === "T_bot_outdated")
337
+ ?.isOutdated === true &&
338
+ result.unresolvedThreads.find((t) => t.threadId === "T_bot_outdated")
339
+ ?.line === 9,
340
+ },
341
+ {
342
+ name: "human thread goes to humanThreads, not unresolvedThreads",
343
+ ok:
344
+ ids(result.humanThreads).includes("T_human") &&
345
+ !ids(result.unresolvedThreads).includes("T_human"),
346
+ },
347
+ {
348
+ name: "comments are trimmed to author + body only",
349
+ ok: result.unresolvedThreads.every((t) =>
350
+ t.comments.every(
351
+ (c) => Object.keys(c).sort().join(",") === "author,body",
352
+ ),
353
+ ),
354
+ },
355
+ {
356
+ name: "thread author is taken from the first comment",
357
+ ok:
358
+ result.unresolvedThreads.find((t) => t.threadId === "T_bot_unresolved")
359
+ ?.author === "cursor",
360
+ },
361
+ {
362
+ name: "sticky AI summary comment is picked up",
363
+ ok: result.aiSummaryComments.some((c) => c.commentId === "IC_summary"),
364
+ },
365
+ {
366
+ name: "human issue comment is not treated as an AI summary",
367
+ ok: !result.aiSummaryComments.some((c) => c.commentId === "IC_human"),
368
+ },
369
+ ];
370
+
371
+ // A config entry written with the `[bot]` suffix must still match the bare
372
+ // login GraphQL returns (and vice versa).
373
+ const normalised = buildResult({
374
+ number: 7,
375
+ isDraft: false,
376
+ threadNodes: [
377
+ {
378
+ id: "T_norm",
379
+ isResolved: false,
380
+ isOutdated: false,
381
+ path: "d.ts",
382
+ line: 1,
383
+ comments: { nodes: [{ author: { login: "claude" }, body: "x" }] },
384
+ },
385
+ ],
386
+ commentNodes: [],
387
+ bots: ["claude[bot]"],
388
+ });
389
+ cases.push({
390
+ name: "config '[bot]' suffix matches a bare GraphQL login",
391
+ ok: ids(normalised.unresolvedThreads).includes("T_norm"),
392
+ });
393
+
394
+ // argument + PR-resolution parsing
395
+ const parsed = parseArgs(["123", "--bots", "x[bot], y[bot]", "--include-resolved"]);
396
+ cases.push({
397
+ name: "parseArgs reads pr, bots (trimmed), and --include-resolved",
398
+ ok:
399
+ parsed.pr === "123" &&
400
+ parsed.includeResolved === true &&
401
+ parsed.bots.join(",") === "x[bot],y[bot]",
402
+ });
403
+ const fromUrl = resolvePr("https://github.com/acme/widgets/pull/88", null);
404
+ cases.push({
405
+ name: "resolvePr parses owner/repo/number from a PR URL",
406
+ ok: fromUrl.number === 88 && fromUrl.repo === "acme/widgets",
407
+ });
408
+ const fromNumber = resolvePr("12", "acme/widgets");
409
+ cases.push({
410
+ name: "resolvePr accepts a bare number with --repo",
411
+ ok: fromNumber.number === 12 && fromNumber.repo === "acme/widgets",
412
+ });
413
+ cases.push({
414
+ name: "resolvePr throws on a non-number, non-URL string",
415
+ ok: (() => {
416
+ try {
417
+ resolvePr("abc", null);
418
+ return false;
419
+ } catch {
420
+ return true;
421
+ }
422
+ })(),
423
+ });
424
+ cases.push({
425
+ name: "parseArgs throws when --bots has no value",
426
+ ok: (() => {
427
+ try {
428
+ parseArgs(["123", "--bots"]);
429
+ return false;
430
+ } catch {
431
+ return true;
432
+ }
433
+ })(),
434
+ });
435
+ cases.push({
436
+ name: "parseArgs throws when --repo has no value",
437
+ ok: (() => {
438
+ try {
439
+ parseArgs(["123", "--repo"]);
440
+ return false;
441
+ } catch {
442
+ return true;
443
+ }
444
+ })(),
445
+ });
446
+ cases.push({
447
+ name: "parseArgs throws on an unknown --flag",
448
+ ok: (() => {
449
+ try {
450
+ parseArgs(["123", "--nope"]);
451
+ return false;
452
+ } catch {
453
+ return true;
454
+ }
455
+ })(),
456
+ });
457
+ cases.push({
458
+ name: "parseArgs throws on a malformed --repo (extra segments)",
459
+ ok: (() => {
460
+ try {
461
+ parseArgs(["123", "--repo", "acme/widgets/extra"]);
462
+ return false;
463
+ } catch {
464
+ return true;
465
+ }
466
+ })(),
467
+ });
468
+ cases.push({
469
+ name: "parseArgs throws on an extra positional argument",
470
+ ok: (() => {
471
+ try {
472
+ parseArgs(["123", "456"]);
473
+ return false;
474
+ } catch {
475
+ return true;
476
+ }
477
+ })(),
478
+ });
479
+
480
+ let failed = 0;
481
+ for (const { name, ok } of cases) {
482
+ if (ok) {
483
+ console.log(` ok ${name}`);
484
+ } else {
485
+ failed += 1;
486
+ console.log(` FAIL ${name}`);
487
+ }
488
+ }
489
+ console.log(`\n${cases.length - failed}/${cases.length} passed`);
490
+ process.exit(failed === 0 ? 0 : 1);
491
+ }
492
+
493
+ // ---- main ----------------------------------------------------------------
494
+
495
+ /** CLI entry: parse args, fetch from GitHub, and print the minimal JSON. */
496
+ function main() {
497
+ const argv = process.argv.slice(2);
498
+ if (argv.includes("--self-test")) {
499
+ selfTest();
500
+ return;
501
+ }
502
+ let opts;
503
+ let pr;
504
+ try {
505
+ opts = parseArgs(argv);
506
+ pr = resolvePr(opts.pr, opts.repo);
507
+ } catch (e) {
508
+ console.error(`review-threads: ${e.message}`);
509
+ process.exit(2);
510
+ }
511
+ try {
512
+ const { isDraft, threadNodes, commentNodes } = fetchFromGitHub(pr.number, pr.repo);
513
+ const result = buildResult({
514
+ number: pr.number,
515
+ isDraft,
516
+ threadNodes,
517
+ commentNodes,
518
+ bots: opts.bots,
519
+ includeResolved: opts.includeResolved,
520
+ });
521
+ console.log(JSON.stringify(result, null, 2));
522
+ } catch (e) {
523
+ // Non-zero exit so the skill can tell "couldn't fetch" from "no findings".
524
+ console.error(`review-threads: failed to fetch from GitHub — ${e.message}`);
525
+ process.exit(1);
526
+ }
527
+ }
528
+
529
+ // Detect "run directly as a CLI" vs "imported as a module". A raw
530
+ // `import.meta.url === file://${argv[1]}` string compare breaks two ways:
531
+ // `import.meta.url` percent-encodes characters such as spaces, and the ESM
532
+ // loader symlink-resolves it whereas `process.argv[1]` is left untouched (e.g.
533
+ // macOS /var → /private/var, pnpm's symlinked store). Normalise both sides
534
+ // through realpath before comparing.
535
+ function isCliEntry() {
536
+ if (!process.argv[1]) return false;
537
+ try {
538
+ return (
539
+ realpathSync(fileURLToPath(import.meta.url)) ===
540
+ realpathSync(process.argv[1])
541
+ );
542
+ } catch {
543
+ return false;
544
+ }
545
+ }
546
+
547
+ if (isCliEntry()) {
548
+ main();
549
+ }