@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/circle.ts ADDED
@@ -0,0 +1,232 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ const execFileAsync = promisify(execFile);
5
+
6
+ export type CircleRunner = (command: string, args: string[]) => Promise<string>;
7
+
8
+ export interface CircleErrorInfo {
9
+ code: "network_unavailable" | "otp_expired" | "not_logged_in" | "missing_cli" | "command_failed";
10
+ message: string;
11
+ guidance: string;
12
+ }
13
+
14
+ export interface CircleWalletInfo {
15
+ address: `0x${string}`;
16
+ raw?: unknown;
17
+ }
18
+
19
+ export interface CircleBalanceInfo {
20
+ balanceAtomic: `${bigint}`;
21
+ backingEOA?: `0x${string}`;
22
+ raw?: unknown;
23
+ }
24
+
25
+ export function defaultCircleCommand(): string {
26
+ return process.env.CIRCLE_CLI_COMMAND ?? "circle";
27
+ }
28
+
29
+ export async function runCircleCli(command: string, args: string[]): Promise<string> {
30
+ try {
31
+ const { stdout } = await execFileAsync(command, args, { maxBuffer: 1024 * 1024 });
32
+ return stdout;
33
+ } catch (error) {
34
+ throw classifyCircleError(error);
35
+ }
36
+ }
37
+
38
+ export function classifyCircleError(error: unknown): Error & { circle?: CircleErrorInfo } {
39
+ const message = error instanceof Error ? error.message : String(error);
40
+ const output = [
41
+ message,
42
+ isRecord(error) && typeof error.stdout === "string" ? error.stdout : undefined,
43
+ isRecord(error) && typeof error.stderr === "string" ? error.stderr : undefined,
44
+ ]
45
+ .filter(Boolean)
46
+ .join("\n");
47
+ const lower = output.toLowerCase();
48
+ let info: CircleErrorInfo;
49
+ if (lower.includes("fetch failed") || lower.includes("network") || lower.includes("enotfound") || lower.includes("econnrefused")) {
50
+ info = {
51
+ code: "network_unavailable",
52
+ message: "Circle CLI network request failed.",
53
+ guidance: "Retry this command in a network-capable shell or agent context. Restricted sandboxes often block Circle auth and Gateway calls.",
54
+ };
55
+ } else if (lower.includes("otp") && (lower.includes("expired") || lower.includes("invalid") || lower.includes("request"))) {
56
+ info = {
57
+ code: "otp_expired",
58
+ message: "Circle OTP request id is invalid or expired.",
59
+ guidance: "Start a fresh Circle auth OTP flow, then rerun the Rubicon command after login completes.",
60
+ };
61
+ } else if (lower.includes("login") || lower.includes("logged in") || lower.includes("unauthorized") || lower.includes("auth")) {
62
+ info = {
63
+ code: "not_logged_in",
64
+ message: "Circle CLI is not logged in.",
65
+ guidance: "Run Circle CLI login/auth again, then rerun Rubicon. If an OTP flow was interrupted, start a fresh OTP init.",
66
+ };
67
+ } else if (lower.includes("enoent") || lower.includes("not found")) {
68
+ info = {
69
+ code: "missing_cli",
70
+ message: "Circle CLI was not found.",
71
+ guidance: "Install Circle CLI and make sure the `circle` binary is on PATH.",
72
+ };
73
+ } else {
74
+ info = {
75
+ code: "command_failed",
76
+ message: "Circle CLI command failed.",
77
+ guidance: "Inspect the Circle CLI output, confirm login, wallet, selected chain, and network access, then retry.",
78
+ };
79
+ }
80
+ const wrapped = new Error(`${info.message} ${output}`.trim()) as Error & { circle?: CircleErrorInfo };
81
+ wrapped.circle = info;
82
+ return wrapped;
83
+ }
84
+
85
+ export function circleGuidance(error: unknown): CircleErrorInfo | undefined {
86
+ if (isRecord(error) && isRecord(error.circle)) {
87
+ return error.circle as unknown as CircleErrorInfo;
88
+ }
89
+ return undefined;
90
+ }
91
+
92
+ export async function circleVersion(input: { command?: string; runner?: CircleRunner } = {}): Promise<string> {
93
+ const command = input.command ?? defaultCircleCommand();
94
+ const runner = input.runner ?? runCircleCli;
95
+ return (await runner(command, ["--version"])).trim();
96
+ }
97
+
98
+ export async function circleAuthStatus(input: { command?: string; runner?: CircleRunner } = {}): Promise<unknown> {
99
+ const command = input.command ?? defaultCircleCommand();
100
+ const runner = input.runner ?? runCircleCli;
101
+ const output = await runner(command, ["auth", "status", "--output", "json"]);
102
+ return parseMaybeJson(output);
103
+ }
104
+
105
+ export async function circleAgentWallet(input: {
106
+ chain: string;
107
+ configuredAddress?: `0x${string}`;
108
+ command?: string;
109
+ runner?: CircleRunner;
110
+ }): Promise<CircleWalletInfo> {
111
+ if (input.configuredAddress) return { address: input.configuredAddress };
112
+ const command = input.command ?? defaultCircleCommand();
113
+ const runner = input.runner ?? runCircleCli;
114
+ const output = await runner(command, ["wallet", "list", "--chain", input.chain, "--type", "agent", "--output", "json"]);
115
+ const raw = parseMaybeJson(output);
116
+ const address = parseWalletAddress(raw);
117
+ return { address, raw };
118
+ }
119
+
120
+ export async function circleGatewayBalance(input: {
121
+ address: `0x${string}`;
122
+ chain: string;
123
+ command?: string;
124
+ runner?: CircleRunner;
125
+ }): Promise<CircleBalanceInfo> {
126
+ const command = input.command ?? defaultCircleCommand();
127
+ const runner = input.runner ?? runCircleCli;
128
+ const output = await runner(command, ["gateway", "balance", "--address", input.address, "--chain", input.chain, "--output", "json"]);
129
+ const raw = parseMaybeJson(output);
130
+ return {
131
+ balanceAtomic: parseBalanceAtomic(raw),
132
+ backingEOA: parseBackingEOA(raw),
133
+ raw,
134
+ };
135
+ }
136
+
137
+ export async function circleGatewayFaucet(input: {
138
+ address: `0x${string}`;
139
+ chain: string;
140
+ command?: string;
141
+ runner?: CircleRunner;
142
+ }): Promise<unknown> {
143
+ const command = input.command ?? defaultCircleCommand();
144
+ const runner = input.runner ?? runCircleCli;
145
+ const output = await runner(command, ["gateway", "faucet", "--address", input.address, "--chain", input.chain, "--output", "json"]);
146
+ return parseMaybeJson(output);
147
+ }
148
+
149
+ function parseWalletAddress(value: unknown): `0x${string}` {
150
+ const wallets = collectRecords(value);
151
+ const addresses = wallets
152
+ .map((wallet) => findString(wallet, ["address", "walletAddress", "blockchainAddress"]))
153
+ .filter((address): address is `0x${string}` => Boolean(address && isAddress(address)));
154
+ const unique = [...new Set(addresses.map((address) => address.toLowerCase()))];
155
+ if (unique.length === 1) return addresses.find((address) => address.toLowerCase() === unique[0])!;
156
+ if (unique.length === 0) throw new Error("Circle CLI did not return an Agent Wallet address.");
157
+ throw new Error("Multiple Circle Agent Wallets found; configure agent-wallet-address.");
158
+ }
159
+
160
+ function parseBalanceAtomic(value: unknown): `${bigint}` {
161
+ const direct =
162
+ findString(value, [
163
+ "data.balanceAtomic",
164
+ "data.availableAtomic",
165
+ "data.usdc.balanceAtomic",
166
+ "balanceAtomic",
167
+ "availableAtomic",
168
+ "usdc.balanceAtomic",
169
+ ]) ?? findNumber(value, ["data.balance", "data.available", "balance", "available"]);
170
+ if (typeof direct === "number") return `${BigInt(Math.trunc(direct * 1_000_000))}`;
171
+ if (direct && /^\d+$/.test(direct)) return direct as `${bigint}`;
172
+ if (direct && /^\d+(\.\d+)?$/.test(direct)) return `${decimalUsdcToAtomic(direct)}`;
173
+ return "0";
174
+ }
175
+
176
+ function parseBackingEOA(value: unknown): `0x${string}` | undefined {
177
+ const address = findString(value, ["data.backingEOA", "backingEOA", "data.backingEoa", "backingEoa"]);
178
+ return address && isAddress(address) ? address : undefined;
179
+ }
180
+
181
+ function parseMaybeJson(output: string): unknown {
182
+ const trimmed = output.trim();
183
+ if (!trimmed) return {};
184
+ try {
185
+ return JSON.parse(trimmed);
186
+ } catch {
187
+ return trimmed;
188
+ }
189
+ }
190
+
191
+ function decimalUsdcToAtomic(value: string): bigint {
192
+ const [whole = "0", fraction = ""] = value.split(".");
193
+ const padded = `${fraction}000000`.slice(0, 6);
194
+ return BigInt(whole) * 1_000_000n + BigInt(padded);
195
+ }
196
+
197
+ function collectRecords(value: unknown): Record<string, unknown>[] {
198
+ if (Array.isArray(value)) return value.filter(isRecord);
199
+ if (!isRecord(value)) return [];
200
+ const records: Record<string, unknown>[] = [value];
201
+ for (const key of ["wallets", "items", "data"]) {
202
+ const nested = value[key];
203
+ if (Array.isArray(nested)) records.push(...nested.filter(isRecord));
204
+ if (isRecord(nested)) records.push(...collectRecords(nested));
205
+ }
206
+ return records;
207
+ }
208
+
209
+ function findString(value: unknown, paths: string[]): string | undefined {
210
+ for (const path of paths) {
211
+ const found = path.split(".").reduce<unknown>((current, part) => (isRecord(current) ? current[part] : undefined), value);
212
+ if (typeof found === "string") return found;
213
+ }
214
+ return undefined;
215
+ }
216
+
217
+ function findNumber(value: unknown, paths: string[]): number | undefined {
218
+ for (const path of paths) {
219
+ const found = path.split(".").reduce<unknown>((current, part) => (isRecord(current) ? current[part] : undefined), value);
220
+ if (typeof found === "number") return found;
221
+ if (typeof found === "string" && /^\d+(\.\d+)?$/.test(found)) return Number(found);
222
+ }
223
+ return undefined;
224
+ }
225
+
226
+ function isAddress(value: string): value is `0x${string}` {
227
+ return /^0x[a-fA-F0-9]{40}$/.test(value);
228
+ }
229
+
230
+ function isRecord(value: unknown): value is Record<string, unknown> {
231
+ return typeof value === "object" && value !== null;
232
+ }
package/src/errors.ts CHANGED
@@ -10,6 +10,21 @@ export class CliError extends Error {
10
10
 
11
11
  export function toCliError(error: unknown): CliError {
12
12
  if (error instanceof CliError) return error;
13
- if (error instanceof Error) return new CliError("COMMAND_FAILED", error.message);
13
+ if (error instanceof Error) {
14
+ const lower = error.message.toLowerCase();
15
+ if (lower.includes("fetch failed") || lower.includes("enotfound") || lower.includes("econnrefused")) {
16
+ return new CliError(
17
+ "CIRCLE_NETWORK_UNAVAILABLE",
18
+ `${error.message} Retry in a network-capable shell or agent context; restricted sandboxes often block Circle auth and Gateway calls.`,
19
+ );
20
+ }
21
+ if (lower.includes("otp") && (lower.includes("expired") || lower.includes("invalid") || lower.includes("request"))) {
22
+ return new CliError(
23
+ "CIRCLE_OTP_EXPIRED",
24
+ `${error.message} Start a fresh Circle auth OTP flow, then rerun the Rubicon command after login completes.`,
25
+ );
26
+ }
27
+ return new CliError("COMMAND_FAILED", error.message);
28
+ }
14
29
  return new CliError("COMMAND_FAILED", String(error));
15
30
  }
package/src/format.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { formatAtomicUsdc } from "@rubicon-caliga/core";
1
+ import { formatAtomicUsdc, settlementNetworkInfo } from "@rubicon-caliga/core";
2
2
  import type { ArticleSummary, ArticleNavigation, SellerPaymentTerms } from "@rubicon-caliga/core";
3
3
  import type { ReadReceipt } from "@rubicon-caliga/agent-sdk";
4
+ import type { StoredReceipt } from "./receipts.js";
4
5
 
5
6
  export function printJson(value: unknown): void {
6
7
  process.stdout.write(`${JSON.stringify(value)}\n`);
@@ -11,6 +12,7 @@ export function printJsonEvent(type: string, value: unknown): void {
11
12
  }
12
13
 
13
14
  export function articleJson(article: ArticleSummary): Record<string, unknown> {
15
+ const paymentTerms = article.paymentTerms ? enrichedPaymentTerms(article.paymentTerms) : undefined;
14
16
  return {
15
17
  articleId: article.articleId,
16
18
  title: article.title,
@@ -21,7 +23,7 @@ export function articleJson(article: ArticleSummary): Record<string, unknown> {
21
23
  totalWords: article.totalWords,
22
24
  pricePerWordAtomic: article.pricePerWordAtomic,
23
25
  maxArticlePriceAtomic: article.maxArticlePriceAtomic,
24
- paymentTerms: article.paymentTerms,
26
+ paymentTerms,
25
27
  sections: article.sections,
26
28
  };
27
29
  }
@@ -49,21 +51,27 @@ export function humanArticle(article: ArticleSummary): string {
49
51
  }
50
52
 
51
53
  export function humanPaymentTerms(terms: SellerPaymentTerms): string[] {
54
+ const enriched = enrichedPaymentTerms(terms);
52
55
  return [
53
56
  "Payment terms:",
54
- `- Asset: ${terms.asset}`,
55
- `- Network: ${terms.networkLabel ?? terms.network}`,
57
+ `- Asset: ${enriched.asset}`,
58
+ `- Network: ${enriched.networkLabel ?? enriched.network}`,
59
+ enriched.circleChain ? `- Circle chain: ${enriched.circleChain}` : undefined,
60
+ enriched.environment ? `- Environment: ${enriched.environment}` : undefined,
61
+ enriched.fundingMethod ? `- Funding: ${enriched.fundingMethod}` : undefined,
56
62
  `- Pay to: ${terms.payTo}`,
57
63
  `- Price/word: ${formatAtomic(terms.pricePerWordAtomic)} USDC`,
58
- ];
64
+ ].filter((line): line is string => line !== undefined);
59
65
  }
60
66
 
61
67
  export function humanNavigation(navigation: ArticleNavigation): string {
62
68
  const seller = navigation.sellerAgent;
69
+ const recommendedReadCommand = recommendedReadCommandFor(navigation.articleId, seller.recommendedSectionId);
63
70
  const lines = [
64
71
  `Recommended section: ${seller.recommendedSectionId}`,
65
72
  `Alternatives: ${seller.alternativeSectionIds.length ? seller.alternativeSectionIds.join(", ") : "none"}`,
66
73
  `Rationale: ${seller.rationale}`,
74
+ `Recommended read: ${recommendedReadCommand}`,
67
75
  ];
68
76
  if (seller.safeHints.length > 0) {
69
77
  lines.push("", "Safe hints:", ...seller.safeHints.map((hint) => `- ${hint}`));
@@ -81,6 +89,7 @@ export function humanNavigation(navigation: ArticleNavigation): string {
81
89
  }
82
90
 
83
91
  export function humanReceipt(receipt: ReadReceipt): string {
92
+ const networkInfo = settlementNetworkInfo(receipt.network);
84
93
  const lines = [
85
94
  "Receipt:",
86
95
  `- Session: ${receipt.sessionId}`,
@@ -102,15 +111,87 @@ export function humanReceipt(receipt: ReadReceipt): string {
102
111
  lines.push(`- Seller pay to: ${receipt.sellerPayTo}`);
103
112
  }
104
113
  if (receipt.network) {
105
- lines.push(`- Network: ${receipt.network}`);
114
+ lines.push(`- Network: ${networkInfo.networkLabel} (${receipt.network})`);
115
+ }
116
+ if (networkInfo.circleChain) {
117
+ lines.push(`- Circle chain: ${networkInfo.circleChain}`);
118
+ }
119
+ if (receipt.buyerWalletAddress && networkInfo.buyerWalletExplanation) {
120
+ lines.push(`- Buyer wallet note: ${networkInfo.buyerWalletExplanation}`);
106
121
  }
107
122
  return lines.join("\n");
108
123
  }
109
124
 
125
+ export function receiptSummaryJson(stored: StoredReceipt): Record<string, unknown> {
126
+ return {
127
+ receiptId: stored.receiptId,
128
+ savedAt: stored.savedAt,
129
+ ...readReceiptSummaryJson(stored.receipt),
130
+ };
131
+ }
132
+
133
+ export function readReceiptSummaryJson(receipt: ReadReceipt): Record<string, unknown> {
134
+ const networkInfo = settlementNetworkInfo(receipt.network);
135
+ return {
136
+ articleId: receipt.articleId,
137
+ sessionId: receipt.sessionId,
138
+ wordsRead: receipt.wordsRead,
139
+ amountPaidAtomic: receipt.amountPaidAtomic,
140
+ amountPaidUsdc: formatAtomic(receipt.amountPaidAtomic),
141
+ stopReason: receipt.stopReason,
142
+ completed: receipt.completed,
143
+ buyerWalletAddress: receipt.buyerWalletAddress,
144
+ sellerPayTo: receipt.sellerPayTo,
145
+ network: receipt.network,
146
+ circleChain: networkInfo.circleChain,
147
+ buyerWalletExplanation: receipt.buyerWalletAddress ? networkInfo.buyerWalletExplanation : undefined,
148
+ settlementIds: receipt.settlementIds,
149
+ transactionHashes: receipt.transactionHashes,
150
+ text: receipt.text,
151
+ };
152
+ }
153
+
154
+ export function humanReceiptSummary(receipt: ReadReceipt, receiptId?: string): string {
155
+ const networkInfo = settlementNetworkInfo(receipt.network);
156
+ const lines = [
157
+ "Summary:",
158
+ receiptId ? `- Receipt ID: ${receiptId}` : undefined,
159
+ `- Article: ${receipt.articleId}`,
160
+ `- Words read: ${receipt.wordsRead.toLocaleString("en-US")}`,
161
+ `- Amount paid: ${formatAtomic(receipt.amountPaidAtomic)} USDC`,
162
+ `- Stop reason: ${receipt.stopReason}`,
163
+ receipt.buyerWalletAddress ? `- Buyer wallet: ${receipt.buyerWalletAddress}` : undefined,
164
+ receipt.sellerPayTo ? `- Seller pay to: ${receipt.sellerPayTo}` : undefined,
165
+ receipt.network ? `- Network: ${networkInfo.networkLabel} (${receipt.network})` : undefined,
166
+ networkInfo.circleChain ? `- Circle chain: ${networkInfo.circleChain}` : undefined,
167
+ receipt.buyerWalletAddress && networkInfo.buyerWalletExplanation
168
+ ? `- Buyer wallet note: ${networkInfo.buyerWalletExplanation}`
169
+ : undefined,
170
+ "",
171
+ receipt.text,
172
+ ];
173
+ return lines.filter((line): line is string => line !== undefined).join("\n");
174
+ }
175
+
110
176
  export function formatAtomic(value: string | bigint): string {
111
177
  return formatAtomicUsdc(typeof value === "bigint" ? value : BigInt(value));
112
178
  }
113
179
 
180
+ export function recommendedReadCommandFor(articleId: string, sectionId: string): string {
181
+ return `rubicon read ${articleId} --section ${sectionId} --stop-after-section --max-usdc <amount>`;
182
+ }
183
+
184
+ function enrichedPaymentTerms(terms: SellerPaymentTerms): SellerPaymentTerms {
185
+ const networkInfo = settlementNetworkInfo(terms.network);
186
+ return {
187
+ ...terms,
188
+ networkLabel: terms.networkLabel ?? networkInfo.networkLabel,
189
+ circleChain: terms.circleChain ?? networkInfo.circleChain,
190
+ environment: terms.environment ?? networkInfo.environment,
191
+ fundingMethod: terms.fundingMethod ?? networkInfo.fundingMethod,
192
+ };
193
+ }
194
+
114
195
  function asRecord(value: unknown): Record<string, unknown> {
115
196
  return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : { value };
116
197
  }