@rubicon-caliga/cli 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import { mkdir } from "node:fs/promises";
3
3
  import { dirname } from "node:path";
4
4
  import { RubiconClient } from "@rubicon-caliga/agent-sdk";
5
- import { parseUsdcToAtomic, type ArticleSummary } from "@rubicon-caliga/core";
5
+ import { parseUsdcToAtomic, settlementNetworkInfo, type ArticleSectionSummary, type ArticleSummary, type StreamMode } from "@rubicon-caliga/core";
6
6
  import { parseArgs, booleanFlag, stringFlag, type ParsedArgs } from "./args.js";
7
7
  import { configPath, HOSTED_GATEWAY_URL, readConfig, writeConfig, type RubiconCliConfig } from "./config.js";
8
8
  import { CliError, toCliError } from "./errors.js";
@@ -12,11 +12,17 @@ import {
12
12
  humanArticle,
13
13
  humanNavigation,
14
14
  humanReceipt,
15
+ humanReceiptSummary,
15
16
  printJson,
16
17
  printJsonEvent,
18
+ readReceiptSummaryJson,
19
+ recommendedReadCommandFor,
20
+ receiptSummaryJson,
17
21
  } from "./format.js";
18
22
  import { selectPaymentEngine, type PaymentMode } from "./payments.js";
23
+ import { runDoctor, runQuickstartRead } from "./quickstart.js";
19
24
  import { listReceipts, loadReceipt, saveReceipt } from "./receipts.js";
25
+ import packageJson from "../package.json" with { type: "json" };
20
26
 
21
27
  interface Runtime {
22
28
  parsed: ParsedArgs;
@@ -25,6 +31,7 @@ interface Runtime {
25
31
  gatewayUrl: string;
26
32
  apiKey?: string;
27
33
  paymentMode: PaymentMode;
34
+ circleChain?: string;
28
35
  client: RubiconClient;
29
36
  }
30
37
 
@@ -50,7 +57,7 @@ async function main(): Promise<void> {
50
57
  authorization: apiKey ? `Bearer ${apiKey}` : undefined,
51
58
  paymentEngine: payment.engine,
52
59
  });
53
- await dispatch({ parsed, json, config, gatewayUrl, apiKey, paymentMode: payment.mode, client });
60
+ await dispatch({ parsed, json, config, gatewayUrl, apiKey, paymentMode: payment.mode, circleChain: payment.circleChain, client });
54
61
  } catch (error) {
55
62
  const cliError = toCliError(error);
56
63
  if (json) {
@@ -65,11 +72,24 @@ async function main(): Promise<void> {
65
72
  async function dispatch(runtime: Runtime): Promise<void> {
66
73
  const [command, subcommand, ...rest] = runtime.parsed.positionals;
67
74
 
75
+ if (booleanFlag(runtime.parsed.flags, "version") || command === "version") {
76
+ process.stdout.write(`${packageJson.version}\n`);
77
+ return;
78
+ }
79
+
68
80
  if (!command || command === "help" || booleanFlag(runtime.parsed.flags, "help")) {
69
81
  showHelp(runtime.json);
70
82
  return;
71
83
  }
72
84
 
85
+ if (command === "doctor") {
86
+ printJson(await runDoctor(runtime, { cliVersion: packageJson.version }));
87
+ return;
88
+ }
89
+ if (command === "quickstart-read") {
90
+ printJson(await runQuickstartRead(runtime));
91
+ return;
92
+ }
73
93
  if (command === "repository") {
74
94
  await repository(runtime);
75
95
  return;
@@ -156,8 +176,12 @@ async function articleNavigation(runtime: Runtime, articleId: string | undefined
156
176
  const goal = stringFlag(runtime.parsed.flags, "goal");
157
177
  if (!goal) throw new CliError("MISSING_GOAL", "rubicon article navigation requires --goal.");
158
178
  const response = await runtime.client.getNavigation(articleId, goal);
179
+ const recommendedReadCommand = recommendedReadCommandFor(
180
+ response.article.articleId,
181
+ response.navigation.sellerAgent.recommendedSectionId,
182
+ );
159
183
  if (runtime.json) {
160
- printJson({ success: true, article: articleJson(response.article), navigation: response.navigation });
184
+ printJson({ success: true, article: articleJson(response.article), navigation: response.navigation, recommendedReadCommand });
161
185
  return;
162
186
  }
163
187
  process.stdout.write(`${humanArticle(response.article)}\n\n${humanNavigation(response.navigation)}\n`);
@@ -167,60 +191,129 @@ async function readArticle(runtime: Runtime, articleId: string | undefined): Pro
167
191
  if (!articleId) throw new CliError("MISSING_ARTICLE_ID", "rubicon read requires an article id.");
168
192
  const maxSpendAtomic = parseBudget(runtime.parsed);
169
193
  const goal = stringFlag(runtime.parsed.flags, "goal");
194
+ const sectionId = sectionFlag(runtime.parsed);
195
+ const stopAfterSection = booleanFlag(runtime.parsed.flags, "stop-after-section");
196
+ const summary = booleanFlag(runtime.parsed.flags, "summary") || booleanFlag(runtime.parsed.flags, "receipt-summary");
197
+ const chunkWords = chunkWordsFlag(runtime.parsed);
198
+ const streamMode = streamModeFlag(runtime.parsed);
170
199
  const maxWordsFlag = stringFlag(runtime.parsed.flags, "max-words");
171
200
  const maxWords = maxWordsFlag === undefined ? undefined : Number(maxWordsFlag);
172
201
  if (maxWords !== undefined && (!Number.isInteger(maxWords) || maxWords < 1)) {
173
202
  throw new CliError("INVALID_MAX_WORDS", "--max-words must be a positive integer.");
174
203
  }
204
+ if (stopAfterSection && !sectionId && !goal) {
205
+ throw new CliError("MISSING_SECTION", "--stop-after-section requires --section/--section-id or --goal.");
206
+ }
207
+ if (sectionId) {
208
+ await validateSection(runtime, articleId, sectionId);
209
+ }
175
210
 
176
211
  if (booleanFlag(runtime.parsed.flags, "dry-run")) {
177
- await dryRun(runtime, articleId, maxSpendAtomic, goal, maxWords);
212
+ await dryRun(runtime, articleId, maxSpendAtomic, goal, maxWords, sectionId, stopAfterSection, chunkWords, streamMode);
178
213
  return;
179
214
  }
180
215
 
181
216
  let finalReceipt = undefined;
217
+ let storedReceipt = undefined;
218
+ let currentSessionId: string | undefined;
219
+ let cancelled = false;
220
+ const abortOnSigint = (): void => {
221
+ cancelled = true;
222
+ process.exitCode = 130;
223
+ if (!runtime.json) {
224
+ process.stderr.write("\nCancelling read and aborting the active session...\n");
225
+ }
226
+ if (currentSessionId) {
227
+ void runtime.client.abort(currentSessionId, "agent_cancelled").catch(() => {});
228
+ }
229
+ };
230
+ process.once("SIGINT", abortOnSigint);
231
+
182
232
  const stream = runtime.client.read({
183
233
  articleId,
184
234
  goal,
235
+ sectionId,
185
236
  maxSpendAtomic,
186
237
  maxWords,
238
+ chunkWords,
239
+ streamMode,
240
+ metadata: stopAfterSection ? { stopAfterSection: true } : undefined,
187
241
  });
188
242
 
189
- for await (const event of stream) {
190
- if (runtime.json) {
191
- printJsonEvent("event", { event });
192
- if (event.type === "article.completed") {
193
- finalReceipt = event.receipt;
243
+ try {
244
+ for await (const event of stream) {
245
+ if (event.type === "session.started") {
246
+ currentSessionId = event.session.sessionId;
247
+ }
248
+ if (runtime.json && !summary) {
249
+ printJsonEvent("event", { event });
250
+ if (event.type === "article.completed") {
251
+ finalReceipt = event.receipt;
252
+ }
253
+ continue;
194
254
  }
195
- continue;
196
- }
197
255
 
198
- switch (event.type) {
199
- case "seller.message":
200
- process.stdout.write(`Seller: ${event.content}\n\n`);
201
- break;
202
- case "session.started":
203
- process.stdout.write(`Session: ${event.session.sessionId}\n\n`);
204
- break;
205
- case "article.word":
206
- process.stdout.write(`${event.word} `);
207
- break;
208
- case "article.error":
209
- process.stderr.write(`\nError: ${event.message}\n`);
210
- break;
211
- case "article.completed":
212
- finalReceipt = event.receipt;
213
- process.stdout.write(`\n\n${humanReceipt(event.receipt)}\n`);
214
- break;
215
- case "article.usage":
256
+ if (runtime.json && summary) {
257
+ if (event.type === "article.completed") {
258
+ finalReceipt = event.receipt;
259
+ }
260
+ continue;
261
+ }
262
+
263
+ switch (event.type) {
264
+ case "seller.message":
265
+ if (!summary) process.stdout.write(`Seller: ${event.content}\n\n`);
266
+ break;
267
+ case "session.started":
268
+ if (!summary) process.stdout.write(`Session: ${event.session.sessionId}\n\n`);
269
+ break;
270
+ case "article.word":
271
+ if (!summary) process.stdout.write(`${event.word} `);
272
+ break;
273
+ case "article.bundle":
274
+ case "article.chunk":
275
+ if (!summary) process.stdout.write(`${event.words.map((entry) => entry.word).join(" ")} `);
276
+ break;
277
+ case "article.error":
278
+ process.stderr.write(`\nError: ${event.message}\n`);
279
+ break;
280
+ case "article.completed":
281
+ finalReceipt = event.receipt;
282
+ if (!summary) {
283
+ process.stdout.write(`\n\n${humanReceipt(event.receipt)}\n`);
284
+ }
285
+ break;
286
+ case "article.usage":
287
+ break;
288
+ }
289
+
290
+ if (cancelled) {
216
291
  break;
292
+ }
217
293
  }
294
+ } finally {
295
+ process.removeListener("SIGINT", abortOnSigint);
218
296
  }
219
297
 
220
298
  if (finalReceipt) {
221
- const stored = await saveReceipt(finalReceipt);
299
+ storedReceipt = await saveReceipt(finalReceipt);
300
+ if (runtime.json && !summary) {
301
+ printJson({ type: "receipt.saved", success: true, receiptId: storedReceipt.receiptId, savedAt: storedReceipt.savedAt, receipt: storedReceipt.receipt });
302
+ } else if (!runtime.json && !summary) {
303
+ process.stdout.write(`Receipt ID: ${storedReceipt.receiptId}\n`);
304
+ }
305
+ }
306
+
307
+ if (summary && finalReceipt) {
222
308
  if (runtime.json) {
223
- printJson({ type: "receipt.saved", success: true, receiptId: stored.receiptId, savedAt: stored.savedAt, receipt: stored.receipt });
309
+ printJson({
310
+ success: true,
311
+ receiptId: storedReceipt?.receiptId,
312
+ savedAt: storedReceipt?.savedAt,
313
+ receipt: readReceiptSummaryJson(finalReceipt),
314
+ });
315
+ } else {
316
+ process.stdout.write(`${humanReceiptSummary(finalReceipt, storedReceipt?.receiptId)}\n`);
224
317
  }
225
318
  }
226
319
  }
@@ -231,20 +324,54 @@ async function dryRun(
231
324
  maxSpendAtomic: `${bigint}`,
232
325
  goal: string | undefined,
233
326
  maxWords: number | undefined,
327
+ sectionId: string | undefined,
328
+ stopAfterSection: boolean,
329
+ chunkWords: number | undefined,
330
+ streamMode: StreamMode,
234
331
  ): Promise<void> {
235
332
  const article = await findArticle(runtime, articleId);
333
+ const navigation = goal && !sectionId ? await runtime.client.getNavigation(articleId, goal).catch(() => undefined) : undefined;
334
+ const effectiveSectionId = sectionId ?? navigation?.navigation.sellerAgent.recommendedSectionId;
335
+ const effectiveSection = effectiveSectionId ? findSection(article, effectiveSectionId) : undefined;
336
+ const estimatedWords = Math.min(maxWords ?? Number.MAX_SAFE_INTEGER, effectiveSection?.wordCount ?? article.totalWords);
337
+ const estimatedMaxCostAtomic = BigInt(article.pricePerWordAtomic) * BigInt(estimatedWords);
338
+ const budgetAtomic = BigInt(maxSpendAtomic);
339
+ const networkInfo = settlementNetworkInfo(article.paymentTerms?.network);
340
+ const fundingMethod = article.paymentTerms?.fundingMethod ?? networkInfo.fundingMethod;
341
+ const balanceCheck = {
342
+ checked: false,
343
+ sufficient: undefined,
344
+ reason: "Live Circle Gateway balance is not checked during dry-run.",
345
+ };
346
+ const budgetSufficiency = {
347
+ sufficientForEstimatedMax: budgetAtomic >= estimatedMaxCostAtomic,
348
+ estimatedMaxCostAtomic: `${estimatedMaxCostAtomic}`,
349
+ estimatedMaxCostUsdc: formatAtomic(`${estimatedMaxCostAtomic}`),
350
+ estimatedWords,
351
+ };
236
352
  if (runtime.json) {
237
353
  printJson({
238
354
  success: true,
239
355
  dryRun: true,
240
356
  gatewayUrl: runtime.gatewayUrl,
241
357
  paymentMode: runtime.paymentMode,
358
+ circleChain: article.paymentTerms?.circleChain ?? networkInfo.circleChain ?? runtime.circleChain,
242
359
  budget: {
243
360
  maxSpendAtomic,
244
361
  maxSpendUsdc: formatAtomic(maxSpendAtomic),
245
362
  maxWords,
246
363
  },
247
364
  goal,
365
+ sectionId,
366
+ recommendedSectionId: navigation?.navigation.sellerAgent.recommendedSectionId,
367
+ effectiveSectionId,
368
+ readStartsAt: effectiveSectionId ? `section:${effectiveSectionId}` : "full-article",
369
+ stopAfterSection,
370
+ chunkWords,
371
+ streamMode,
372
+ fundingMethod,
373
+ estimatedMax: budgetSufficiency,
374
+ walletBalance: balanceCheck,
248
375
  article: articleJson(article),
249
376
  });
250
377
  return;
@@ -255,9 +382,22 @@ async function dryRun(
255
382
  "Dry run: no paid read started.",
256
383
  `Gateway: ${runtime.gatewayUrl}`,
257
384
  `Payment mode: ${runtime.paymentMode}`,
385
+ article.paymentTerms?.circleChain ?? networkInfo.circleChain ?? runtime.circleChain
386
+ ? `Circle chain: ${article.paymentTerms?.circleChain ?? networkInfo.circleChain ?? runtime.circleChain}`
387
+ : undefined,
258
388
  `Budget: ${formatAtomic(maxSpendAtomic)} USDC (${maxSpendAtomic} atomic)`,
389
+ `Estimated max for ${effectiveSectionId ? effectiveSectionId : "full article"}: ${formatAtomic(`${estimatedMaxCostAtomic}`)} USDC (${estimatedWords.toLocaleString("en-US")} words)`,
390
+ `Budget covers estimate: ${budgetSufficiency.sufficientForEstimatedMax ? "yes" : "no"}`,
391
+ `Wallet balance check: not checked`,
392
+ fundingMethod ? `Funding: ${fundingMethod}` : undefined,
259
393
  maxWords ? `Max words: ${maxWords}` : undefined,
260
394
  goal ? `Goal: ${goal}` : undefined,
395
+ navigation?.navigation.sellerAgent.recommendedSectionId ? `Recommended section: ${navigation.navigation.sellerAgent.recommendedSectionId}` : undefined,
396
+ `Read starts at: ${effectiveSectionId ? `section ${effectiveSectionId}` : "full article"}`,
397
+ sectionId ? `Section: ${sectionId}` : undefined,
398
+ stopAfterSection ? "Stop after section: yes" : undefined,
399
+ `Stream mode: ${streamMode}`,
400
+ streamMode === "bundled" ? `Bundle words: ${chunkWords ?? 32}` : undefined,
261
401
  "",
262
402
  humanArticle(article),
263
403
  "",
@@ -268,9 +408,11 @@ async function dryRun(
268
408
  }
269
409
 
270
410
  async function receiptsList(runtime: Runtime): Promise<void> {
271
- const receipts = await listReceipts();
411
+ const limit = limitFlag(runtime.parsed);
412
+ const receipts = (await listReceipts()).slice(0, limit);
413
+ const summary = booleanFlag(runtime.parsed.flags, "summary") || booleanFlag(runtime.parsed.flags, "receipt-summary");
272
414
  if (runtime.json) {
273
- printJson({ success: true, receipts });
415
+ printJson({ success: true, receipts: summary ? receipts.map(receiptSummaryJson) : receipts });
274
416
  return;
275
417
  }
276
418
  if (receipts.length === 0) {
@@ -290,8 +432,13 @@ async function receiptsList(runtime: Runtime): Promise<void> {
290
432
  async function receiptsShow(runtime: Runtime, receiptId: string | undefined): Promise<void> {
291
433
  if (!receiptId) throw new CliError("MISSING_RECEIPT_ID", "rubicon receipts show requires a receipt id.");
292
434
  const stored = await loadReceipt(receiptId);
435
+ const summary = booleanFlag(runtime.parsed.flags, "summary") || booleanFlag(runtime.parsed.flags, "receipt-summary");
293
436
  if (runtime.json) {
294
- printJson({ success: true, ...stored });
437
+ printJson({ success: true, ...(summary ? receiptSummaryJson(stored) : stored) });
438
+ return;
439
+ }
440
+ if (summary) {
441
+ process.stdout.write(`${humanReceiptSummary(stored.receipt, stored.receiptId)}\n`);
295
442
  return;
296
443
  }
297
444
  process.stdout.write(`Receipt ID: ${stored.receiptId}\nSaved: ${stored.savedAt}\n${humanReceipt(stored.receipt)}\n`);
@@ -309,6 +456,7 @@ async function configShow(runtime: Runtime): Promise<void> {
309
456
  gatewayUrl: runtime.gatewayUrl,
310
457
  apiKey: runtime.apiKey ? "set" : undefined,
311
458
  paymentMode: runtime.paymentMode,
459
+ circleChain: runtime.circleChain,
312
460
  },
313
461
  };
314
462
  if (runtime.json) {
@@ -381,6 +529,67 @@ function parseBudget(parsed: ParsedArgs): `${bigint}` {
381
529
  }
382
530
  }
383
531
 
532
+ function sectionFlag(parsed: ParsedArgs): string | undefined {
533
+ const section = stringFlag(parsed.flags, "section");
534
+ const sectionId = stringFlag(parsed.flags, "section-id");
535
+ if (section && sectionId && section !== sectionId) {
536
+ throw new CliError("MULTIPLE_SECTIONS", "Use either --section or --section-id, not both.");
537
+ }
538
+ return section ?? sectionId;
539
+ }
540
+
541
+ function limitFlag(parsed: ParsedArgs): number | undefined {
542
+ const rawLimit = stringFlag(parsed.flags, "limit");
543
+ if (rawLimit === undefined) return undefined;
544
+ const limit = Number(rawLimit);
545
+ if (!Number.isInteger(limit) || limit < 1) {
546
+ throw new CliError("INVALID_LIMIT", "--limit must be a positive integer.");
547
+ }
548
+ return limit;
549
+ }
550
+
551
+ function chunkWordsFlag(parsed: ParsedArgs): number | undefined {
552
+ const fast = booleanFlag(parsed.flags, "fast");
553
+ const mode = stringFlag(parsed.flags, "mode");
554
+ if (mode !== undefined && mode !== "batch" && mode !== "word") {
555
+ throw new CliError("INVALID_READ_MODE", "--mode must be batch or word.");
556
+ }
557
+ const rawChunkWords = stringFlag(parsed.flags, "chunk-words");
558
+ if (rawChunkWords === undefined) return fast || mode === "batch" ? 32 : undefined;
559
+ const chunkWords = Number(rawChunkWords);
560
+ if (!Number.isInteger(chunkWords) || chunkWords < 1) {
561
+ throw new CliError("INVALID_CHUNK_WORDS", "--chunk-words must be a positive integer.");
562
+ }
563
+ return Math.min(chunkWords, 256);
564
+ }
565
+
566
+ function streamModeFlag(parsed: ParsedArgs): StreamMode {
567
+ const streamMode = stringFlag(parsed.flags, "stream-mode");
568
+ const legacyMode = stringFlag(parsed.flags, "mode");
569
+ const perWord = booleanFlag(parsed.flags, "per-word");
570
+ if (streamMode !== undefined && streamMode !== "bundled" && streamMode !== "word") {
571
+ throw new CliError("INVALID_STREAM_MODE", "--stream-mode must be bundled or word.");
572
+ }
573
+ if (perWord && streamMode === "bundled") {
574
+ throw new CliError("INVALID_STREAM_MODE", "--per-word cannot be combined with --stream-mode bundled.");
575
+ }
576
+ if ((perWord || legacyMode === "word" || streamMode === "word") && stringFlag(parsed.flags, "chunk-words") !== undefined) {
577
+ throw new CliError("INVALID_STREAM_MODE", "Per-word mode cannot be combined with --chunk-words.");
578
+ }
579
+ return perWord || legacyMode === "word" ? "word" : streamMode ?? "bundled";
580
+ }
581
+
582
+ function findSection(article: ArticleSummary, sectionId: string): ArticleSectionSummary | undefined {
583
+ return article.sections.find((section) => section.sectionId === sectionId);
584
+ }
585
+
586
+ async function validateSection(runtime: Runtime, articleId: string, sectionId: string): Promise<void> {
587
+ const article = await findArticle(runtime, articleId);
588
+ if (!article.sections.some((section) => section.sectionId === sectionId)) {
589
+ throw new CliError("SECTION_NOT_FOUND", `Section not found for ${articleId}: ${sectionId}`);
590
+ }
591
+ }
592
+
384
593
  function matchesQuery(article: ArticleSummary, query: string): boolean {
385
594
  const haystack = [
386
595
  article.articleId,
@@ -402,12 +611,14 @@ function matchesQuery(article: ArticleSummary, query: string): boolean {
402
611
  function showHelp(json: boolean): void {
403
612
  const usage = [
404
613
  "rubicon repository",
614
+ "rubicon doctor --json",
615
+ "rubicon quickstart-read --first --goal \"<goal>\" --max-usdc 0.10 --json",
405
616
  "rubicon search \"<query>\"",
406
617
  "rubicon article show <article-id>",
407
618
  "rubicon article navigation <article-id> --goal \"<goal>\"",
408
- "rubicon read <article-id> --max-usdc 0.10 [--goal \"...\"] [--max-words 50] [--dry-run]",
409
- "rubicon receipts list",
410
- "rubicon receipts show <receipt-id>",
619
+ "rubicon read <article-id> --max-usdc 0.10 [--goal \"...\"] [--section <section-id>] [--stop-after-section] [--stream-mode bundled|word] [--chunk-words 32] [--per-word] [--max-words 50] [--summary] [--dry-run]",
620
+ "rubicon receipts list [--limit 10] [--summary]",
621
+ "rubicon receipts show <receipt-id> [--summary]",
411
622
  "rubicon config show",
412
623
  "rubicon config set gateway-url <url>",
413
624
  "rubicon config set api-key <key>",
package/src/payments.ts CHANGED
@@ -11,6 +11,7 @@ export type PaymentMode = "static" | "circle-cli";
11
11
  export interface PaymentSelection {
12
12
  mode: PaymentMode;
13
13
  engine: AgentPaymentEngine;
14
+ circleChain?: string;
14
15
  }
15
16
 
16
17
  export function selectPaymentEngine(input: {
@@ -25,12 +26,14 @@ export function selectPaymentEngine(input: {
25
26
  return { mode: selectedMode, engine: new StaticPaymentEngine() };
26
27
  }
27
28
 
29
+ const circleChain = process.env.CIRCLE_CLI_CHAIN ?? input.config.circleChain ?? "ARC-TESTNET";
28
30
  return {
29
31
  mode: selectedMode,
30
32
  engine: new CircleCliGatewayPaymentEngine({
31
33
  agentWalletAddress: envAddress("CIRCLE_AGENT_WALLET_ADDRESS") ?? input.config.agentWalletAddress,
32
- chain: process.env.CIRCLE_CLI_CHAIN ?? input.config.circleChain ?? "ARC-TESTNET",
34
+ chain: circleChain,
33
35
  }),
36
+ circleChain,
34
37
  };
35
38
  }
36
39