spendos 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.
Files changed (90) hide show
  1. package/.dockerignore +4 -0
  2. package/.env.example +30 -0
  3. package/AGENTS.md +212 -0
  4. package/BOOTSTRAP.md +55 -0
  5. package/Dockerfile +52 -0
  6. package/HEARTBEAT.md +7 -0
  7. package/IDENTITY.md +23 -0
  8. package/LICENSE +21 -0
  9. package/README.md +162 -0
  10. package/SOUL.md +202 -0
  11. package/SUBMISSION.md +128 -0
  12. package/TOOLS.md +40 -0
  13. package/USER.md +17 -0
  14. package/acp-seller/bin/acp.ts +807 -0
  15. package/acp-seller/config.json +34 -0
  16. package/acp-seller/package.json +55 -0
  17. package/acp-seller/src/commands/agent.ts +328 -0
  18. package/acp-seller/src/commands/bounty.ts +1189 -0
  19. package/acp-seller/src/commands/deploy.ts +414 -0
  20. package/acp-seller/src/commands/job.ts +217 -0
  21. package/acp-seller/src/commands/profile.ts +71 -0
  22. package/acp-seller/src/commands/resource.ts +91 -0
  23. package/acp-seller/src/commands/search.ts +327 -0
  24. package/acp-seller/src/commands/sell.ts +883 -0
  25. package/acp-seller/src/commands/serve.ts +258 -0
  26. package/acp-seller/src/commands/setup.ts +399 -0
  27. package/acp-seller/src/commands/token.ts +88 -0
  28. package/acp-seller/src/commands/wallet.ts +123 -0
  29. package/acp-seller/src/lib/api.ts +118 -0
  30. package/acp-seller/src/lib/auth.ts +291 -0
  31. package/acp-seller/src/lib/bounty.ts +257 -0
  32. package/acp-seller/src/lib/client.ts +42 -0
  33. package/acp-seller/src/lib/config.ts +240 -0
  34. package/acp-seller/src/lib/open.ts +41 -0
  35. package/acp-seller/src/lib/openclawCron.ts +138 -0
  36. package/acp-seller/src/lib/output.ts +104 -0
  37. package/acp-seller/src/lib/wallet.ts +81 -0
  38. package/acp-seller/src/seller/offerings/_shared/preTransactionScan.ts +127 -0
  39. package/acp-seller/src/seller/offerings/canonical-catalog.ts +221 -0
  40. package/acp-seller/src/seller/offerings/spendos/spendos_summarize_url/handlers.ts +20 -0
  41. package/acp-seller/src/seller/offerings/spendos/spendos_summarize_url/offering.json +18 -0
  42. package/acp-seller/src/seller/offerings/spendos/spendos_translate/handlers.ts +21 -0
  43. package/acp-seller/src/seller/offerings/spendos/spendos_translate/offering.json +22 -0
  44. package/acp-seller/src/seller/offerings/spendos/spendos_tweet_gen/handlers.ts +20 -0
  45. package/acp-seller/src/seller/offerings/spendos/spendos_tweet_gen/offering.json +18 -0
  46. package/acp-seller/src/seller/runtime/acpSocket.ts +413 -0
  47. package/acp-seller/src/seller/runtime/logger.ts +36 -0
  48. package/acp-seller/src/seller/runtime/offeringTypes.ts +52 -0
  49. package/acp-seller/src/seller/runtime/offerings.ts +277 -0
  50. package/acp-seller/src/seller/runtime/paymentVerification.test.ts +207 -0
  51. package/acp-seller/src/seller/runtime/paymentVerification.ts +363 -0
  52. package/acp-seller/src/seller/runtime/seller.onchain.test.ts +220 -0
  53. package/acp-seller/src/seller/runtime/seller.test.ts +823 -0
  54. package/acp-seller/src/seller/runtime/seller.ts +1041 -0
  55. package/acp-seller/src/seller/runtime/sellerApi.ts +71 -0
  56. package/acp-seller/src/seller/runtime/startup.ts +270 -0
  57. package/acp-seller/src/seller/runtime/types.ts +62 -0
  58. package/acp-seller/tsconfig.json +20 -0
  59. package/bin/spendos.js +23 -0
  60. package/contracts/SpendOSAudit.sol +29 -0
  61. package/dist/mcp-server.mjs +153 -0
  62. package/jobs/translate.json +7 -0
  63. package/jobs/tweet-gen.json +7 -0
  64. package/openclaw.json +41 -0
  65. package/package.json +49 -0
  66. package/plugins/spendos-events/index.ts +78 -0
  67. package/plugins/spendos-events/package.json +14 -0
  68. package/policies/enforce-bounds.mjs +71 -0
  69. package/public/index.html +509 -0
  70. package/public/landing.html +241 -0
  71. package/railway.json +12 -0
  72. package/railway.toml +12 -0
  73. package/scripts/deploy.ts +48 -0
  74. package/scripts/test-x402-mainnet.ts +30 -0
  75. package/scripts/xmtp-listener.ts +61 -0
  76. package/setup.sh +278 -0
  77. package/skills/spendos/skill.md +26 -0
  78. package/src/agent.ts +152 -0
  79. package/src/audit.ts +166 -0
  80. package/src/governance.ts +367 -0
  81. package/src/job-registry.ts +306 -0
  82. package/src/mcp-public.ts +145 -0
  83. package/src/mcp-server.ts +171 -0
  84. package/src/opportunity-scanner.ts +138 -0
  85. package/src/server.ts +870 -0
  86. package/src/venice-x402.ts +234 -0
  87. package/src/xmtp.ts +109 -0
  88. package/src/zerion.ts +58 -0
  89. package/start.sh +168 -0
  90. package/tsconfig.json +14 -0
@@ -0,0 +1,883 @@
1
+ // =============================================================================
2
+ // acp sell init <name> — Scaffold a new offering
3
+ // acp sell create <name> — Validate + register offering on ACP
4
+ // acp sell delete <name> — Delist offering from ACP
5
+ // acp sell list — Show all offerings with status
6
+ // acp sell inspect <name> — Detailed view of single offering
7
+ //
8
+ // acp sell resource init <name> — Scaffold a new resource
9
+ // acp sell resource create <name> — Validate + register resource on ACP
10
+ // acp sell resource delete <name> — Delete resource from ACP
11
+ // acp sell resource list — Show all resources with status
12
+ // =============================================================================
13
+
14
+ import * as fs from "fs";
15
+ import * as path from "path";
16
+ import { fileURLToPath } from "url";
17
+ import * as output from "../lib/output.js";
18
+ import {
19
+ createJobOffering,
20
+ deleteJobOffering,
21
+ upsertResourceApi,
22
+ deleteResourceApi,
23
+ type JobOfferingData,
24
+ type PriceV2,
25
+ type Resource,
26
+ } from "../lib/api.js";
27
+ import { getMyAgentInfo } from "../lib/wallet.js";
28
+ import {
29
+ formatPrice,
30
+ getActiveAgent,
31
+ sanitizeAgentName,
32
+ } from "../lib/config.js";
33
+
34
+ const __filename = fileURLToPath(import.meta.url);
35
+ const __dirname = path.dirname(__filename);
36
+
37
+ /** Offerings base: src/seller/offerings/ */
38
+ const OFFERINGS_BASE = path.resolve(__dirname, "..", "seller", "offerings");
39
+
40
+ /** Offerings root for the current agent: src/seller/offerings/<agent-name>/ */
41
+ function getOfferingsRoot(): string {
42
+ const agent = getActiveAgent();
43
+ if (!agent) {
44
+ console.error("Error: No active agent. Run `acp setup` first.");
45
+ process.exit(1);
46
+ }
47
+ return path.resolve(OFFERINGS_BASE, sanitizeAgentName(agent.name));
48
+ }
49
+
50
+ /**
51
+ * Detect offerings in the old flat structure (src/seller/offerings/<offering>/)
52
+ * instead of the new per-agent structure (src/seller/offerings/<agent>/<offering>/).
53
+ * Warns the user and shows the move command.
54
+ */
55
+ export async function checkForLegacyOfferings(): Promise<void> {
56
+ if (!fs.existsSync(OFFERINGS_BASE)) return;
57
+
58
+ const agent = getActiveAgent();
59
+ if (!agent) return;
60
+ const agentDir = sanitizeAgentName(agent.name);
61
+
62
+ const entries = fs.readdirSync(OFFERINGS_BASE, { withFileTypes: true });
63
+ const legacyOfferings = entries.filter((e) => {
64
+ if (!e.isDirectory()) return false;
65
+ // Skip if it looks like an agent directory (contains subdirectories with offering.json)
66
+ const subPath = path.join(OFFERINGS_BASE, e.name);
67
+ const hasOfferingJson = fs.existsSync(path.join(subPath, "offering.json"));
68
+ const hasHandlers = fs.existsSync(path.join(subPath, "handlers.ts"));
69
+ // It's a legacy offering if it has offering.json + handlers.ts directly
70
+ return hasOfferingJson && hasHandlers;
71
+ });
72
+
73
+ if (legacyOfferings.length === 0) return;
74
+
75
+ const agentInfo = await getMyAgentInfo();
76
+ const registeredOfferingNames = new Set(
77
+ agentInfo.jobs?.map((job) => job.name) || [],
78
+ );
79
+
80
+ const agentLegacyOfferings = legacyOfferings.filter((e) => {
81
+ if (registeredOfferingNames.has(e.name)) {
82
+ return true;
83
+ }
84
+ try {
85
+ const offeringJsonPath = path.join(
86
+ OFFERINGS_BASE,
87
+ e.name,
88
+ "offering.json",
89
+ );
90
+ if (fs.existsSync(offeringJsonPath)) {
91
+ const offeringJson: OfferingJson = JSON.parse(
92
+ fs.readFileSync(offeringJsonPath, "utf-8"),
93
+ );
94
+ return registeredOfferingNames.has(offeringJson.name);
95
+ }
96
+ } catch {
97
+ // If we can't read the file, skip this check
98
+ }
99
+ return false;
100
+ });
101
+
102
+ // Only show warning if there are legacy offerings that belong to the current agent
103
+ if (agentLegacyOfferings.length === 0) return;
104
+
105
+ const names = agentLegacyOfferings.map((e) => e.name);
106
+ output.warn(
107
+ `Found ${names.length} offering(s) in the legacy directory structure:\n` +
108
+ names.map((n) => ` - src/seller/offerings/${n}/`).join("\n") +
109
+ "\n\n" +
110
+ ` Job offerings should be placed and classified by agent name in src/seller/offerings/${agentDir}/\n` +
111
+ ` Move them with:\n\n` +
112
+ names
113
+ .map(
114
+ (n) =>
115
+ ` mv src/seller/offerings/${n} src/seller/offerings/${agentDir}/${n}`,
116
+ )
117
+ .join("\n") +
118
+ "\n",
119
+ );
120
+ }
121
+
122
+ /** Resources live at src/seller/resources/ */
123
+ const RESOURCES_ROOT = path.resolve(__dirname, "..", "seller", "resources");
124
+
125
+ interface OfferingJson {
126
+ name: string;
127
+ description: string;
128
+ jobFee: number;
129
+ jobFeeType: "fixed" | "percentage";
130
+ priceV2?: PriceV2;
131
+ slaMinutes?: number;
132
+ requiredFunds: boolean;
133
+ requirement?: Record<string, any>;
134
+ deliverable?: string;
135
+ }
136
+
137
+ interface ValidationResult {
138
+ valid: boolean;
139
+ errors: string[];
140
+ warnings: string[];
141
+ }
142
+
143
+ function resolveOfferingDir(offeringName: string): string {
144
+ return path.resolve(getOfferingsRoot(), offeringName);
145
+ }
146
+
147
+ function validateOfferingJson(filePath: string): ValidationResult {
148
+ const result: ValidationResult = { valid: true, errors: [], warnings: [] };
149
+
150
+ if (!fs.existsSync(filePath)) {
151
+ result.valid = false;
152
+ result.errors.push(`offering.json not found at ${filePath}`);
153
+ return result;
154
+ }
155
+
156
+ let json: any;
157
+ try {
158
+ json = JSON.parse(fs.readFileSync(filePath, "utf-8"));
159
+ } catch (err) {
160
+ result.valid = false;
161
+ result.errors.push(`Invalid JSON in offering.json: ${err}`);
162
+ return result;
163
+ }
164
+
165
+ if (!json.name || typeof json.name !== "string" || json.name.trim() === "") {
166
+ result.valid = false;
167
+ result.errors.push(
168
+ 'offering.json: "name" is required — set to a non-empty string matching the directory name',
169
+ );
170
+ }
171
+ if (
172
+ !json.description ||
173
+ typeof json.description !== "string" ||
174
+ json.description.trim() === ""
175
+ ) {
176
+ result.valid = false;
177
+ result.errors.push(
178
+ 'offering.json: "description" is required — describe what this service does for buyers',
179
+ );
180
+ }
181
+ if (json.jobFee === undefined || json.jobFee === null) {
182
+ result.valid = false;
183
+ // Validate jobFee presence, type, and value based on jobFeeType
184
+ if (json.jobFee === undefined || json.jobFee === null) {
185
+ result.valid = false;
186
+ result.errors.push(
187
+ 'offering.json: "jobFee" is required — set to a number (see "jobFeeType" docs)',
188
+ );
189
+ } else if (typeof json.jobFee !== "number") {
190
+ result.valid = false;
191
+ result.errors.push('offering.json: "jobFee" must be a number');
192
+ }
193
+
194
+ if (json.jobFeeType === undefined || json.jobFeeType === null) {
195
+ result.valid = false;
196
+ result.errors.push(
197
+ 'offering.json: "jobFeeType" is required ("fixed" or "percentage")',
198
+ );
199
+ } else if (
200
+ json.jobFeeType !== "fixed" &&
201
+ json.jobFeeType !== "percentage"
202
+ ) {
203
+ result.valid = false;
204
+ result.errors.push(
205
+ 'offering.json: "jobFeeType" must be either "fixed" or "percentage"',
206
+ );
207
+ }
208
+
209
+ // Additional validation if both jobFee is a number and jobFeeType is set
210
+ if (typeof json.jobFee === "number" && json.jobFeeType) {
211
+ if (json.jobFeeType === "fixed") {
212
+ if (json.jobFee < 0) {
213
+ result.valid = false;
214
+ result.errors.push(
215
+ 'offering.json: "jobFee" must be a non-negative number (fee in USDC per job) for fixed fee type',
216
+ );
217
+ }
218
+ if (json.jobFee === 0) {
219
+ result.warnings.push(
220
+ 'offering.json: "jobFee" is 0; jobs will pay no fee to seller',
221
+ );
222
+ }
223
+ } else if (json.jobFeeType === "percentage") {
224
+ if (json.jobFee < 0.001 || json.jobFee > 0.99) {
225
+ result.valid = false;
226
+ result.errors.push(
227
+ 'offering.json: "jobFee" must be >= 0.001 and <= 0.99 (value in decimals, eg. 50% = 0.5) for percentage fee type',
228
+ );
229
+ }
230
+ }
231
+ }
232
+ }
233
+ if (json.requiredFunds === undefined || json.requiredFunds === null) {
234
+ result.valid = false;
235
+ result.errors.push(
236
+ 'offering.json: "requiredFunds" is required — set to true if the job needs additional token transfer beyond the fee, false otherwise',
237
+ );
238
+ } else if (typeof json.requiredFunds !== "boolean") {
239
+ result.valid = false;
240
+ result.errors.push('offering.json: "requiredFunds" must be true or false');
241
+ }
242
+
243
+ return result;
244
+ }
245
+
246
+ function validateHandlers(
247
+ filePath: string,
248
+ requiredFunds?: boolean,
249
+ ): ValidationResult {
250
+ const result: ValidationResult = { valid: true, errors: [], warnings: [] };
251
+
252
+ if (!fs.existsSync(filePath)) {
253
+ result.valid = false;
254
+ result.errors.push(`handlers.ts not found at ${filePath}`);
255
+ return result;
256
+ }
257
+
258
+ const content = fs.readFileSync(filePath, "utf-8");
259
+
260
+ const executeJobPatterns = [
261
+ /export\s+(async\s+)?function\s+executeJob\s*\(/,
262
+ /export\s+const\s+executeJob\s*=\s*(async\s*)?\(/,
263
+ /export\s+const\s+executeJob\s*=\s*(async\s*)?function/,
264
+ /export\s*\{\s*[^}]*executeJob[^}]*\}/,
265
+ ];
266
+
267
+ if (!executeJobPatterns.some((p) => p.test(content))) {
268
+ result.valid = false;
269
+ result.errors.push(
270
+ 'handlers.ts: must export an "executeJob" function — this is the required handler that runs your service logic',
271
+ );
272
+ }
273
+
274
+ const hasValidate = [
275
+ /export\s+(async\s+)?function\s+validateRequirements\s*\(/,
276
+ /export\s+const\s+validateRequirements\s*=/,
277
+ /export\s*\{\s*[^}]*validateRequirements[^}]*\}/,
278
+ ].some((p) => p.test(content));
279
+
280
+ const hasFunds = [
281
+ /export\s+(async\s+)?function\s+requestAdditionalFunds\s*\(/,
282
+ /export\s+const\s+requestAdditionalFunds\s*=/,
283
+ /export\s*\{\s*[^}]*requestAdditionalFunds[^}]*\}/,
284
+ ].some((p) => p.test(content));
285
+
286
+ if (!hasValidate) {
287
+ result.warnings.push(
288
+ 'handlers.ts: optional "validateRequirements" handler not found — requests will be accepted without validation',
289
+ );
290
+ }
291
+ if (requiredFunds === true && !hasFunds) {
292
+ result.valid = false;
293
+ result.errors.push(
294
+ 'handlers.ts: "requiredFunds" is true in offering.json — must export "requestAdditionalFunds" to specify the token transfer details',
295
+ );
296
+ }
297
+ if (requiredFunds === false && hasFunds) {
298
+ result.valid = false;
299
+ result.errors.push(
300
+ 'handlers.ts: "requiredFunds" is false in offering.json — must NOT export "requestAdditionalFunds" (remove it, or set requiredFunds to true)',
301
+ );
302
+ }
303
+
304
+ return result;
305
+ }
306
+
307
+ function buildAcpPayload(json: OfferingJson): JobOfferingData {
308
+ return {
309
+ name: json.name,
310
+ description: json.description,
311
+ priceV2: json.priceV2 ?? { type: json.jobFeeType, value: json.jobFee },
312
+ slaMinutes: json.slaMinutes ?? 5,
313
+ requiredFunds: json.requiredFunds,
314
+ requirement: json.requirement ?? {},
315
+ deliverable: json.deliverable ?? "string",
316
+ };
317
+ }
318
+
319
+ // -- Init: scaffold a new offering --
320
+
321
+ export async function init(offeringName: string): Promise<void> {
322
+ await checkForLegacyOfferings();
323
+ if (!offeringName) {
324
+ output.fatal("Usage: acp sell init <offering_name>");
325
+ }
326
+
327
+ const dir = resolveOfferingDir(offeringName);
328
+ if (fs.existsSync(dir)) {
329
+ output.fatal(`Offering directory already exists: ${dir}`);
330
+ }
331
+
332
+ fs.mkdirSync(dir, { recursive: true });
333
+
334
+ const offeringJson: Record<string, unknown> = {
335
+ name: offeringName,
336
+ description: "",
337
+ jobFee: null,
338
+ jobFeeType: null,
339
+ requiredFunds: null,
340
+ requirement: {},
341
+ };
342
+
343
+ fs.writeFileSync(
344
+ path.join(dir, "offering.json"),
345
+ JSON.stringify(offeringJson, null, 2) + "\n",
346
+ );
347
+
348
+ const handlersTemplate = `import type { ExecuteJobResult, ValidationResult } from "../../../runtime/offeringTypes.js";
349
+
350
+ // Required: implement your service logic here
351
+ export async function executeJob(request: any): Promise<ExecuteJobResult> {
352
+ // TODO: Implement your service
353
+ return { deliverable: "TODO: Return your result" };
354
+ }
355
+
356
+ // Optional: validate incoming requests
357
+ export function validateRequirements(request: any): ValidationResult {
358
+ // Return { valid: true } to accept, or { valid: false, reason: "explanation" } to reject
359
+ return { valid: true };
360
+ }
361
+
362
+ // Optional: provide custom payment request message
363
+ export function requestPayment(request: any): string {
364
+ // Return a custom message/reason for the payment request
365
+ return "Request accepted";
366
+ }
367
+ `;
368
+
369
+ fs.writeFileSync(path.join(dir, "handlers.ts"), handlersTemplate);
370
+
371
+ const agent = getActiveAgent();
372
+ const agentDir = agent ? sanitizeAgentName(agent.name) : "unknown";
373
+ output.output({ created: dir }, () => {
374
+ output.heading("Offering Scaffolded");
375
+ output.log(` Created: src/seller/offerings/${agentDir}/${offeringName}/`);
376
+ output.log(
377
+ ` - offering.json (edit name, description, fee, feeType, requirements)`,
378
+ );
379
+ output.log(` - handlers.ts (implement executeJob)`);
380
+ output.log(
381
+ `\n Next: edit the files, then run: acp sell create ${offeringName}\n`,
382
+ );
383
+ });
384
+ }
385
+
386
+ // -- Create: validate + register --
387
+
388
+ export async function create(offeringName: string): Promise<void> {
389
+ await checkForLegacyOfferings();
390
+ if (!offeringName) {
391
+ output.fatal("Usage: acp sell create <offering_name>");
392
+ }
393
+
394
+ const dir = resolveOfferingDir(offeringName);
395
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
396
+ output.fatal(
397
+ `Offering directory not found: ${dir}\n Create it with: acp sell init ${offeringName}`,
398
+ );
399
+ }
400
+
401
+ output.log(`\nValidating offering: "${offeringName}"\n`);
402
+
403
+ const allErrors: string[] = [];
404
+ const allWarnings: string[] = [];
405
+
406
+ // Validate offering.json
407
+ output.log(" Checking offering.json...");
408
+ const jsonPath = path.join(dir, "offering.json");
409
+ const jsonResult = validateOfferingJson(jsonPath);
410
+ allErrors.push(...jsonResult.errors);
411
+ allWarnings.push(...jsonResult.warnings);
412
+
413
+ let parsedOffering: OfferingJson | null = null;
414
+ if (jsonResult.valid) {
415
+ parsedOffering = JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
416
+ output.log(` Valid — Name: "${parsedOffering!.name}"`);
417
+ output.log(` Fee: ${parsedOffering!.jobFee} USDC`);
418
+ output.log(` Funds required: ${parsedOffering!.requiredFunds}`);
419
+ } else {
420
+ output.log(" Invalid");
421
+ }
422
+
423
+ // Validate handlers.ts
424
+ output.log("\n Checking handlers.ts...");
425
+ const handlersPath = path.join(dir, "handlers.ts");
426
+ const handlersResult = validateHandlers(
427
+ handlersPath,
428
+ parsedOffering?.requiredFunds,
429
+ );
430
+ allErrors.push(...handlersResult.errors);
431
+ allWarnings.push(...handlersResult.warnings);
432
+
433
+ if (handlersResult.valid) {
434
+ output.log(" Valid — executeJob handler found");
435
+ } else {
436
+ output.log(" Invalid");
437
+ }
438
+
439
+ output.log("\n" + "-".repeat(50));
440
+
441
+ if (allWarnings.length > 0) {
442
+ output.log("\n Warnings:");
443
+ allWarnings.forEach((w) => output.log(` - ${w}`));
444
+ }
445
+
446
+ if (allErrors.length > 0) {
447
+ output.log("\n Errors:");
448
+ allErrors.forEach((e) => output.log(` - ${e}`));
449
+ output.fatal("\n Validation failed. Fix the errors above.");
450
+ }
451
+
452
+ output.log("\n Validation passed!\n");
453
+
454
+ // Register with ACP
455
+ const json: OfferingJson = JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
456
+ const acpPayload = buildAcpPayload(json);
457
+
458
+ output.log(" Registering offering with ACP...");
459
+ const result = await createJobOffering(acpPayload);
460
+
461
+ if (result.success) {
462
+ output.log(" Offering registered successfully.\n");
463
+ } else {
464
+ output.fatal(" Failed to register offering with ACP.");
465
+ }
466
+
467
+ // Start seller if not running
468
+ output.log(" Tip: Run `acp serve start` to begin accepting jobs.\n");
469
+ }
470
+
471
+ // -- Delete: delist offering --
472
+
473
+ export async function del(offeringName: string): Promise<void> {
474
+ if (!offeringName) {
475
+ output.fatal("Usage: acp sell delete <offering_name>");
476
+ }
477
+
478
+ output.log(`\n Delisting offering: "${offeringName}"...\n`);
479
+
480
+ const result = await deleteJobOffering(offeringName);
481
+
482
+ if (result.success) {
483
+ output.log(" Offering delisted from ACP. Local files remain.\n");
484
+ } else {
485
+ output.fatal(" Failed to delist offering from ACP.");
486
+ }
487
+ }
488
+
489
+ // -- List: show all offerings with status --
490
+
491
+ interface LocalOffering {
492
+ dirName: string;
493
+ name: string;
494
+ description: string;
495
+ jobFee: number;
496
+ jobFeeType: "fixed" | "percentage";
497
+ requiredFunds: boolean;
498
+ }
499
+
500
+ function listLocalOfferings(): LocalOffering[] {
501
+ const offeringsRoot = getOfferingsRoot();
502
+ if (!fs.existsSync(offeringsRoot)) return [];
503
+
504
+ return fs
505
+ .readdirSync(offeringsRoot, { withFileTypes: true })
506
+ .filter((d) => d.isDirectory())
507
+ .map((d) => {
508
+ const configPath = path.join(offeringsRoot, d.name, "offering.json");
509
+ if (!fs.existsSync(configPath)) return null;
510
+ try {
511
+ const json = JSON.parse(fs.readFileSync(configPath, "utf-8"));
512
+ return {
513
+ dirName: d.name,
514
+ name: json.name ?? d.name,
515
+ description: json.description ?? "",
516
+ jobFee: json.jobFee ?? 0,
517
+ jobFeeType: json.jobFeeType ?? "fixed",
518
+ requiredFunds: json.requiredFunds ?? false,
519
+ };
520
+ } catch {
521
+ return null;
522
+ }
523
+ })
524
+ .filter((o): o is LocalOffering => o !== null);
525
+ }
526
+
527
+ interface AcpOffering {
528
+ name: string;
529
+ priceV2?: { type: string; value: number };
530
+ slaMinutes?: number;
531
+ requiredFunds?: boolean;
532
+ }
533
+
534
+ async function fetchAcpOfferings(): Promise<AcpOffering[]> {
535
+ try {
536
+ const agentInfo = await getMyAgentInfo();
537
+ return agentInfo.jobs ?? [];
538
+ } catch {
539
+ // API error — can't determine ACP status
540
+ return [];
541
+ }
542
+ }
543
+
544
+ function acpOfferingNames(acpOfferings: AcpOffering[]): Set<string> {
545
+ return new Set(acpOfferings.map((o) => o.name));
546
+ }
547
+
548
+ export async function list(): Promise<void> {
549
+ await checkForLegacyOfferings();
550
+ const acpOfferings = await fetchAcpOfferings();
551
+ const acpNames = acpOfferingNames(acpOfferings);
552
+ const localOfferings = listLocalOfferings();
553
+ const localNames = new Set(localOfferings.map((o) => o.name));
554
+
555
+ const localData = localOfferings.map((o) => ({
556
+ ...o,
557
+ listed: acpNames.has(o.name),
558
+ acpOnly: false as const,
559
+ }));
560
+
561
+ // ACP-only offerings: listed on ACP but no local directory
562
+ const acpOnlyData = acpOfferings
563
+ .filter((o) => !localNames.has(o.name))
564
+ .map((o) => ({
565
+ dirName: "",
566
+ name: o.name,
567
+ description: "",
568
+ jobFee: o.priceV2?.value ?? 0,
569
+ jobFeeType: o.priceV2?.type ?? "fixed",
570
+ requiredFunds: o.requiredFunds ?? false,
571
+ listed: true,
572
+ acpOnly: true as const,
573
+ }));
574
+
575
+ const data = [...localData, ...acpOnlyData];
576
+
577
+ output.output(data, (offerings) => {
578
+ output.heading("Job Offerings");
579
+
580
+ if (offerings.length === 0) {
581
+ output.log(
582
+ " No offerings found. Run `acp sell init <name>` to create one.\n",
583
+ );
584
+ return;
585
+ }
586
+
587
+ for (const o of offerings) {
588
+ const status = o.acpOnly
589
+ ? "Listed on ACP (no local files)"
590
+ : o.listed
591
+ ? "Listed"
592
+ : "Local only";
593
+ output.log(`\n ${o.name}`);
594
+ if (!o.acpOnly) {
595
+ output.field(" Description", o.description);
596
+ }
597
+ output.field(" Fee", `${formatPrice(o.jobFee, o.jobFeeType)}`);
598
+ output.field(" Funds required", String(o.requiredFunds));
599
+ output.field(" Status", status);
600
+ if (o.acpOnly) {
601
+ output.log(
602
+ " Tip: Run `acp sell delete " + o.name + "` to delist from ACP",
603
+ );
604
+ }
605
+ }
606
+ output.log("");
607
+ });
608
+ }
609
+
610
+ // -- Inspect: detailed view --
611
+
612
+ function detectHandlers(offeringDir: string): string[] {
613
+ const handlersPath = path.join(
614
+ getOfferingsRoot(),
615
+ offeringDir,
616
+ "handlers.ts",
617
+ );
618
+ if (!fs.existsSync(handlersPath)) return [];
619
+
620
+ const content = fs.readFileSync(handlersPath, "utf-8");
621
+ const found: string[] = [];
622
+
623
+ if (/export\s+(async\s+)?function\s+executeJob\s*\(/.test(content)) {
624
+ found.push("executeJob");
625
+ }
626
+ if (
627
+ /export\s+(async\s+)?function\s+validateRequirements\s*\(/.test(content)
628
+ ) {
629
+ found.push("validateRequirements");
630
+ }
631
+ if (/export\s+(async\s+)?function\s+requestPayment\s*\(/.test(content)) {
632
+ found.push("requestPayment");
633
+ }
634
+ if (
635
+ /export\s+(async\s+)?function\s+requestAdditionalFunds\s*\(/.test(content)
636
+ ) {
637
+ found.push("requestAdditionalFunds");
638
+ }
639
+
640
+ return found;
641
+ }
642
+
643
+ export async function inspect(offeringName: string): Promise<void> {
644
+ if (!offeringName) {
645
+ output.fatal("Usage: acp sell inspect <offering_name>");
646
+ }
647
+
648
+ const dir = resolveOfferingDir(offeringName);
649
+ const configPath = path.join(dir, "offering.json");
650
+
651
+ if (!fs.existsSync(configPath)) {
652
+ output.fatal(`Offering not found: ${offeringName}`);
653
+ }
654
+
655
+ const json = JSON.parse(fs.readFileSync(configPath, "utf-8"));
656
+ const acpOfferings = await fetchAcpOfferings();
657
+ const isListed = acpOfferingNames(acpOfferings).has(json.name);
658
+ const handlers = detectHandlers(offeringName);
659
+
660
+ const data = {
661
+ ...json,
662
+ listed: isListed,
663
+ handlers,
664
+ };
665
+
666
+ output.output(data, (d) => {
667
+ output.heading(`Offering: ${d.name}`);
668
+ output.field("Description", d.description);
669
+ output.field("Fee", `${d.jobFee} USDC`);
670
+ output.field("Funds required", String(d.requiredFunds));
671
+ output.field("Status", d.listed ? "Listed on ACP" : "Local only");
672
+ output.field("Handlers", d.handlers.join(", ") || "(none)");
673
+ if (d.requirement) {
674
+ output.log("\n Requirement Schema:");
675
+ output.log(
676
+ JSON.stringify(d.requirement, null, 4)
677
+ .split("\n")
678
+ .map((line: string) => ` ${line}`)
679
+ .join("\n"),
680
+ );
681
+ }
682
+ output.log("");
683
+ });
684
+ }
685
+
686
+ // =============================================================================
687
+ // Resource Management
688
+ // =============================================================================
689
+
690
+ function resolveResourceDir(resourceName: string): string {
691
+ return path.resolve(RESOURCES_ROOT, resourceName);
692
+ }
693
+
694
+ interface LocalResource {
695
+ dirName: string;
696
+ name: string;
697
+ description: string;
698
+ url: string;
699
+ }
700
+
701
+ async function fetchAcpResourceNames(): Promise<Resource[]> {
702
+ try {
703
+ const agentInfo = (await getMyAgentInfo()) as {
704
+ jobs?: unknown[];
705
+ resources?: Resource[];
706
+ };
707
+ return agentInfo.resources ?? [];
708
+ } catch {
709
+ return [];
710
+ }
711
+ }
712
+
713
+ export async function resourceList(): Promise<void> {
714
+ const resources = await fetchAcpResourceNames();
715
+
716
+ output.output(resources, (resources) => {
717
+ output.heading("Resources");
718
+
719
+ if (resources.length === 0) {
720
+ output.log(
721
+ " No resources found. Run `acp sell resource init <name>` to create one.\n",
722
+ );
723
+ return;
724
+ }
725
+
726
+ for (const r of resources) {
727
+ output.log(`\n ${r.name}`);
728
+ if (!r.acpOnly) {
729
+ output.field(" Description", r.description);
730
+ output.field(" URL", r.url);
731
+ }
732
+ }
733
+ output.log("");
734
+ });
735
+ }
736
+
737
+ function validateResourceJson(filePath: string): ValidationResult {
738
+ const result: ValidationResult = { valid: true, errors: [], warnings: [] };
739
+
740
+ if (!fs.existsSync(filePath)) {
741
+ result.valid = false;
742
+ result.errors.push(`resources.json not found at ${filePath}`);
743
+ return result;
744
+ }
745
+
746
+ let json: any;
747
+ try {
748
+ json = JSON.parse(fs.readFileSync(filePath, "utf-8"));
749
+ } catch (err) {
750
+ result.valid = false;
751
+ result.errors.push(`Invalid JSON in resources.json: ${err}`);
752
+ return result;
753
+ }
754
+
755
+ if (!json.name || typeof json.name !== "string" || json.name.trim() === "") {
756
+ result.valid = false;
757
+ result.errors.push('"name" field is required (non-empty string)');
758
+ }
759
+ if (
760
+ !json.description ||
761
+ typeof json.description !== "string" ||
762
+ json.description.trim() === ""
763
+ ) {
764
+ result.valid = false;
765
+ result.errors.push('"description" field is required (non-empty string)');
766
+ }
767
+ if (!json.url || typeof json.url !== "string" || json.url.trim() === "") {
768
+ result.valid = false;
769
+ result.errors.push('"url" field is required (non-empty string)');
770
+ }
771
+ if (json.params !== undefined && json.params !== null) {
772
+ if (typeof json.params !== "object" || Array.isArray(json.params)) {
773
+ result.valid = false;
774
+ result.errors.push('"params" field must be an object if provided');
775
+ }
776
+ }
777
+
778
+ return result;
779
+ }
780
+
781
+ // -- Resource Init: scaffold a new resource --
782
+
783
+ export async function resourceInit(resourceName: string): Promise<void> {
784
+ if (!resourceName) {
785
+ output.fatal("Usage: acp sell resource init <resource_name>");
786
+ }
787
+
788
+ const dir = resolveResourceDir(resourceName);
789
+ if (fs.existsSync(dir)) {
790
+ output.fatal(`Resource directory already exists: ${dir}`);
791
+ }
792
+
793
+ fs.mkdirSync(dir, { recursive: true });
794
+
795
+ const resourceJson = {
796
+ name: resourceName,
797
+ description: "TODO: Describe what this resource provides",
798
+ url: "https://api.example.com/endpoint",
799
+ };
800
+
801
+ fs.writeFileSync(
802
+ path.join(dir, "resources.json"),
803
+ JSON.stringify(resourceJson, null, 2) + "\n",
804
+ );
805
+
806
+ output.output({ created: dir }, () => {
807
+ output.heading("Resource Scaffolded");
808
+ output.log(` Created: src/seller/resources/${resourceName}/`);
809
+ output.log(` - resources.json (edit name, description, url, params)`);
810
+ output.log(
811
+ `\n Next: edit the file, then run: acp sell resource create ${resourceName}\n`,
812
+ );
813
+ });
814
+ }
815
+
816
+ // -- Resource Create: validate + register --
817
+
818
+ export async function resourceCreate(resourceName: string): Promise<void> {
819
+ if (!resourceName) {
820
+ output.fatal("Usage: acp sell resource create <resource_name>");
821
+ }
822
+
823
+ const dir = resolveResourceDir(resourceName);
824
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
825
+ output.fatal(
826
+ `Resource directory not found: ${dir}\n Create it with: acp sell resource init ${resourceName}`,
827
+ );
828
+ }
829
+
830
+ output.log(`\nValidating resource: "${resourceName}"\n`);
831
+
832
+ const jsonPath = path.join(dir, "resources.json");
833
+ const validation = validateResourceJson(jsonPath);
834
+
835
+ if (!validation.valid) {
836
+ output.log(" Errors:");
837
+ validation.errors.forEach((e) => output.log(` - ${e}`));
838
+ output.fatal("\n Validation failed. Fix the errors above.");
839
+ }
840
+
841
+ if (validation.warnings.length > 0) {
842
+ output.log(" Warnings:");
843
+ validation.warnings.forEach((w) => output.log(` - ${w}`));
844
+ }
845
+
846
+ output.log(" Validation passed!\n");
847
+
848
+ // Register with ACP
849
+ const json: any = JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
850
+ const resource: Resource = {
851
+ name: json.name,
852
+ description: json.description,
853
+ url: json.url,
854
+ params: json.params,
855
+ };
856
+
857
+ output.log(" Registering resource with ACP...");
858
+ const result = await upsertResourceApi(resource);
859
+
860
+ if (result.success) {
861
+ output.log(" Resource registered successfully.\n");
862
+ } else {
863
+ output.fatal(" Failed to register resource with ACP.");
864
+ }
865
+ }
866
+
867
+ // -- Resource Delete: delete resource --
868
+
869
+ export async function resourceDelete(resourceName: string): Promise<void> {
870
+ if (!resourceName) {
871
+ output.fatal("Usage: acp sell resource delete <resource_name>");
872
+ }
873
+
874
+ output.log(`\n Deleting resource: "${resourceName}"...\n`);
875
+
876
+ const result = await deleteResourceApi(resourceName);
877
+
878
+ if (result.success) {
879
+ output.log(" Resource deleted from ACP.\n");
880
+ } else {
881
+ output.fatal(" Failed to delete resource from ACP.");
882
+ }
883
+ }