// ============================================================================= // acp sell init — Scaffold a new offering // acp sell create — Validate + register offering on ACP // acp sell delete — Delist offering from ACP // acp sell list — Show all offerings with status // acp sell inspect — Detailed view of single offering // // acp sell resource init — Scaffold a new resource // acp sell resource create — Validate + register resource on ACP // acp sell resource delete — Delete resource from ACP // acp sell resource list — Show all resources with status // ============================================================================= import * as fs from "fs"; import * as path from "path"; import { fileURLToPath } from "url"; import * as output from "../lib/output.js"; import { createJobOffering, deleteJobOffering, upsertResourceApi, deleteResourceApi, type JobOfferingData, type PriceV2, type Resource, } from "../lib/api.js"; import { getMyAgentInfo } from "../lib/wallet.js"; import { formatPrice, getActiveAgent, sanitizeAgentName, } from "../lib/config.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); /** Offerings base: src/seller/offerings/ */ const OFFERINGS_BASE = path.resolve(__dirname, "..", "seller", "offerings"); /** Offerings root for the current agent: src/seller/offerings// */ function getOfferingsRoot(): string { const agent = getActiveAgent(); if (!agent) { console.error("Error: No active agent. Run `acp setup` first."); process.exit(1); } return path.resolve(OFFERINGS_BASE, sanitizeAgentName(agent.name)); } /** * Detect offerings in the old flat structure (src/seller/offerings//) * instead of the new per-agent structure (src/seller/offerings///). * Warns the user and shows the move command. */ export async function checkForLegacyOfferings(): Promise { if (!fs.existsSync(OFFERINGS_BASE)) return; const agent = getActiveAgent(); if (!agent) return; const agentDir = sanitizeAgentName(agent.name); const entries = fs.readdirSync(OFFERINGS_BASE, { withFileTypes: true }); const legacyOfferings = entries.filter((e) => { if (!e.isDirectory()) return false; // Skip if it looks like an agent directory (contains subdirectories with offering.json) const subPath = path.join(OFFERINGS_BASE, e.name); const hasOfferingJson = fs.existsSync(path.join(subPath, "offering.json")); const hasHandlers = fs.existsSync(path.join(subPath, "handlers.ts")); // It's a legacy offering if it has offering.json + handlers.ts directly return hasOfferingJson && hasHandlers; }); if (legacyOfferings.length === 0) return; const agentInfo = await getMyAgentInfo(); const registeredOfferingNames = new Set( agentInfo.jobs?.map((job) => job.name) || [], ); const agentLegacyOfferings = legacyOfferings.filter((e) => { if (registeredOfferingNames.has(e.name)) { return true; } try { const offeringJsonPath = path.join( OFFERINGS_BASE, e.name, "offering.json", ); if (fs.existsSync(offeringJsonPath)) { const offeringJson: OfferingJson = JSON.parse( fs.readFileSync(offeringJsonPath, "utf-8"), ); return registeredOfferingNames.has(offeringJson.name); } } catch { // If we can't read the file, skip this check } return false; }); // Only show warning if there are legacy offerings that belong to the current agent if (agentLegacyOfferings.length === 0) return; const names = agentLegacyOfferings.map((e) => e.name); output.warn( `Found ${names.length} offering(s) in the legacy directory structure:\n` + names.map((n) => ` - src/seller/offerings/${n}/`).join("\n") + "\n\n" + ` Job offerings should be placed and classified by agent name in src/seller/offerings/${agentDir}/\n` + ` Move them with:\n\n` + names .map( (n) => ` mv src/seller/offerings/${n} src/seller/offerings/${agentDir}/${n}`, ) .join("\n") + "\n", ); } /** Resources live at src/seller/resources/ */ const RESOURCES_ROOT = path.resolve(__dirname, "..", "seller", "resources"); interface OfferingJson { name: string; description: string; jobFee: number; jobFeeType: "fixed" | "percentage"; priceV2?: PriceV2; slaMinutes?: number; requiredFunds: boolean; requirement?: Record; deliverable?: string; } interface ValidationResult { valid: boolean; errors: string[]; warnings: string[]; } function resolveOfferingDir(offeringName: string): string { return path.resolve(getOfferingsRoot(), offeringName); } function validateOfferingJson(filePath: string): ValidationResult { const result: ValidationResult = { valid: true, errors: [], warnings: [] }; if (!fs.existsSync(filePath)) { result.valid = false; result.errors.push(`offering.json not found at ${filePath}`); return result; } let json: any; try { json = JSON.parse(fs.readFileSync(filePath, "utf-8")); } catch (err) { result.valid = false; result.errors.push(`Invalid JSON in offering.json: ${err}`); return result; } if (!json.name || typeof json.name !== "string" || json.name.trim() === "") { result.valid = false; result.errors.push( 'offering.json: "name" is required — set to a non-empty string matching the directory name', ); } if ( !json.description || typeof json.description !== "string" || json.description.trim() === "" ) { result.valid = false; result.errors.push( 'offering.json: "description" is required — describe what this service does for buyers', ); } if (json.jobFee === undefined || json.jobFee === null) { result.valid = false; // Validate jobFee presence, type, and value based on jobFeeType if (json.jobFee === undefined || json.jobFee === null) { result.valid = false; result.errors.push( 'offering.json: "jobFee" is required — set to a number (see "jobFeeType" docs)', ); } else if (typeof json.jobFee !== "number") { result.valid = false; result.errors.push('offering.json: "jobFee" must be a number'); } if (json.jobFeeType === undefined || json.jobFeeType === null) { result.valid = false; result.errors.push( 'offering.json: "jobFeeType" is required ("fixed" or "percentage")', ); } else if ( json.jobFeeType !== "fixed" && json.jobFeeType !== "percentage" ) { result.valid = false; result.errors.push( 'offering.json: "jobFeeType" must be either "fixed" or "percentage"', ); } // Additional validation if both jobFee is a number and jobFeeType is set if (typeof json.jobFee === "number" && json.jobFeeType) { if (json.jobFeeType === "fixed") { if (json.jobFee < 0) { result.valid = false; result.errors.push( 'offering.json: "jobFee" must be a non-negative number (fee in USDC per job) for fixed fee type', ); } if (json.jobFee === 0) { result.warnings.push( 'offering.json: "jobFee" is 0; jobs will pay no fee to seller', ); } } else if (json.jobFeeType === "percentage") { if (json.jobFee < 0.001 || json.jobFee > 0.99) { result.valid = false; result.errors.push( 'offering.json: "jobFee" must be >= 0.001 and <= 0.99 (value in decimals, eg. 50% = 0.5) for percentage fee type', ); } } } } if (json.requiredFunds === undefined || json.requiredFunds === null) { result.valid = false; result.errors.push( 'offering.json: "requiredFunds" is required — set to true if the job needs additional token transfer beyond the fee, false otherwise', ); } else if (typeof json.requiredFunds !== "boolean") { result.valid = false; result.errors.push('offering.json: "requiredFunds" must be true or false'); } return result; } function validateHandlers( filePath: string, requiredFunds?: boolean, ): ValidationResult { const result: ValidationResult = { valid: true, errors: [], warnings: [] }; if (!fs.existsSync(filePath)) { result.valid = false; result.errors.push(`handlers.ts not found at ${filePath}`); return result; } const content = fs.readFileSync(filePath, "utf-8"); const executeJobPatterns = [ /export\s+(async\s+)?function\s+executeJob\s*\(/, /export\s+const\s+executeJob\s*=\s*(async\s*)?\(/, /export\s+const\s+executeJob\s*=\s*(async\s*)?function/, /export\s*\{\s*[^}]*executeJob[^}]*\}/, ]; if (!executeJobPatterns.some((p) => p.test(content))) { result.valid = false; result.errors.push( 'handlers.ts: must export an "executeJob" function — this is the required handler that runs your service logic', ); } const hasValidate = [ /export\s+(async\s+)?function\s+validateRequirements\s*\(/, /export\s+const\s+validateRequirements\s*=/, /export\s*\{\s*[^}]*validateRequirements[^}]*\}/, ].some((p) => p.test(content)); const hasFunds = [ /export\s+(async\s+)?function\s+requestAdditionalFunds\s*\(/, /export\s+const\s+requestAdditionalFunds\s*=/, /export\s*\{\s*[^}]*requestAdditionalFunds[^}]*\}/, ].some((p) => p.test(content)); if (!hasValidate) { result.warnings.push( 'handlers.ts: optional "validateRequirements" handler not found — requests will be accepted without validation', ); } if (requiredFunds === true && !hasFunds) { result.valid = false; result.errors.push( 'handlers.ts: "requiredFunds" is true in offering.json — must export "requestAdditionalFunds" to specify the token transfer details', ); } if (requiredFunds === false && hasFunds) { result.valid = false; result.errors.push( 'handlers.ts: "requiredFunds" is false in offering.json — must NOT export "requestAdditionalFunds" (remove it, or set requiredFunds to true)', ); } return result; } function buildAcpPayload(json: OfferingJson): JobOfferingData { return { name: json.name, description: json.description, priceV2: json.priceV2 ?? { type: json.jobFeeType, value: json.jobFee }, slaMinutes: json.slaMinutes ?? 5, requiredFunds: json.requiredFunds, requirement: json.requirement ?? {}, deliverable: json.deliverable ?? "string", }; } // -- Init: scaffold a new offering -- export async function init(offeringName: string): Promise { await checkForLegacyOfferings(); if (!offeringName) { output.fatal("Usage: acp sell init "); } const dir = resolveOfferingDir(offeringName); if (fs.existsSync(dir)) { output.fatal(`Offering directory already exists: ${dir}`); } fs.mkdirSync(dir, { recursive: true }); const offeringJson: Record = { name: offeringName, description: "", jobFee: null, jobFeeType: null, requiredFunds: null, requirement: {}, }; fs.writeFileSync( path.join(dir, "offering.json"), JSON.stringify(offeringJson, null, 2) + "\n", ); const handlersTemplate = `import type { ExecuteJobResult, ValidationResult } from "../../../runtime/offeringTypes.js"; // Required: implement your service logic here export async function executeJob(request: any): Promise { // TODO: Implement your service return { deliverable: "TODO: Return your result" }; } // Optional: validate incoming requests export function validateRequirements(request: any): ValidationResult { // Return { valid: true } to accept, or { valid: false, reason: "explanation" } to reject return { valid: true }; } // Optional: provide custom payment request message export function requestPayment(request: any): string { // Return a custom message/reason for the payment request return "Request accepted"; } `; fs.writeFileSync(path.join(dir, "handlers.ts"), handlersTemplate); const agent = getActiveAgent(); const agentDir = agent ? sanitizeAgentName(agent.name) : "unknown"; output.output({ created: dir }, () => { output.heading("Offering Scaffolded"); output.log(` Created: src/seller/offerings/${agentDir}/${offeringName}/`); output.log( ` - offering.json (edit name, description, fee, feeType, requirements)`, ); output.log(` - handlers.ts (implement executeJob)`); output.log( `\n Next: edit the files, then run: acp sell create ${offeringName}\n`, ); }); } // -- Create: validate + register -- export async function create(offeringName: string): Promise { await checkForLegacyOfferings(); if (!offeringName) { output.fatal("Usage: acp sell create "); } const dir = resolveOfferingDir(offeringName); if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) { output.fatal( `Offering directory not found: ${dir}\n Create it with: acp sell init ${offeringName}`, ); } output.log(`\nValidating offering: "${offeringName}"\n`); const allErrors: string[] = []; const allWarnings: string[] = []; // Validate offering.json output.log(" Checking offering.json..."); const jsonPath = path.join(dir, "offering.json"); const jsonResult = validateOfferingJson(jsonPath); allErrors.push(...jsonResult.errors); allWarnings.push(...jsonResult.warnings); let parsedOffering: OfferingJson | null = null; if (jsonResult.valid) { parsedOffering = JSON.parse(fs.readFileSync(jsonPath, "utf-8")); output.log(` Valid — Name: "${parsedOffering!.name}"`); output.log(` Fee: ${parsedOffering!.jobFee} USDC`); output.log(` Funds required: ${parsedOffering!.requiredFunds}`); } else { output.log(" Invalid"); } // Validate handlers.ts output.log("\n Checking handlers.ts..."); const handlersPath = path.join(dir, "handlers.ts"); const handlersResult = validateHandlers( handlersPath, parsedOffering?.requiredFunds, ); allErrors.push(...handlersResult.errors); allWarnings.push(...handlersResult.warnings); if (handlersResult.valid) { output.log(" Valid — executeJob handler found"); } else { output.log(" Invalid"); } output.log("\n" + "-".repeat(50)); if (allWarnings.length > 0) { output.log("\n Warnings:"); allWarnings.forEach((w) => output.log(` - ${w}`)); } if (allErrors.length > 0) { output.log("\n Errors:"); allErrors.forEach((e) => output.log(` - ${e}`)); output.fatal("\n Validation failed. Fix the errors above."); } output.log("\n Validation passed!\n"); // Register with ACP const json: OfferingJson = JSON.parse(fs.readFileSync(jsonPath, "utf-8")); const acpPayload = buildAcpPayload(json); output.log(" Registering offering with ACP..."); const result = await createJobOffering(acpPayload); if (result.success) { output.log(" Offering registered successfully.\n"); } else { output.fatal(" Failed to register offering with ACP."); } // Start seller if not running output.log(" Tip: Run `acp serve start` to begin accepting jobs.\n"); } // -- Delete: delist offering -- export async function del(offeringName: string): Promise { if (!offeringName) { output.fatal("Usage: acp sell delete "); } output.log(`\n Delisting offering: "${offeringName}"...\n`); const result = await deleteJobOffering(offeringName); if (result.success) { output.log(" Offering delisted from ACP. Local files remain.\n"); } else { output.fatal(" Failed to delist offering from ACP."); } } // -- List: show all offerings with status -- interface LocalOffering { dirName: string; name: string; description: string; jobFee: number; jobFeeType: "fixed" | "percentage"; requiredFunds: boolean; } function listLocalOfferings(): LocalOffering[] { const offeringsRoot = getOfferingsRoot(); if (!fs.existsSync(offeringsRoot)) return []; return fs .readdirSync(offeringsRoot, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => { const configPath = path.join(offeringsRoot, d.name, "offering.json"); if (!fs.existsSync(configPath)) return null; try { const json = JSON.parse(fs.readFileSync(configPath, "utf-8")); return { dirName: d.name, name: json.name ?? d.name, description: json.description ?? "", jobFee: json.jobFee ?? 0, jobFeeType: json.jobFeeType ?? "fixed", requiredFunds: json.requiredFunds ?? false, }; } catch { return null; } }) .filter((o): o is LocalOffering => o !== null); } interface AcpOffering { name: string; priceV2?: { type: string; value: number }; slaMinutes?: number; requiredFunds?: boolean; } async function fetchAcpOfferings(): Promise { try { const agentInfo = await getMyAgentInfo(); return agentInfo.jobs ?? []; } catch { // API error — can't determine ACP status return []; } } function acpOfferingNames(acpOfferings: AcpOffering[]): Set { return new Set(acpOfferings.map((o) => o.name)); } export async function list(): Promise { await checkForLegacyOfferings(); const acpOfferings = await fetchAcpOfferings(); const acpNames = acpOfferingNames(acpOfferings); const localOfferings = listLocalOfferings(); const localNames = new Set(localOfferings.map((o) => o.name)); const localData = localOfferings.map((o) => ({ ...o, listed: acpNames.has(o.name), acpOnly: false as const, })); // ACP-only offerings: listed on ACP but no local directory const acpOnlyData = acpOfferings .filter((o) => !localNames.has(o.name)) .map((o) => ({ dirName: "", name: o.name, description: "", jobFee: o.priceV2?.value ?? 0, jobFeeType: o.priceV2?.type ?? "fixed", requiredFunds: o.requiredFunds ?? false, listed: true, acpOnly: true as const, })); const data = [...localData, ...acpOnlyData]; output.output(data, (offerings) => { output.heading("Job Offerings"); if (offerings.length === 0) { output.log( " No offerings found. Run `acp sell init ` to create one.\n", ); return; } for (const o of offerings) { const status = o.acpOnly ? "Listed on ACP (no local files)" : o.listed ? "Listed" : "Local only"; output.log(`\n ${o.name}`); if (!o.acpOnly) { output.field(" Description", o.description); } output.field(" Fee", `${formatPrice(o.jobFee, o.jobFeeType)}`); output.field(" Funds required", String(o.requiredFunds)); output.field(" Status", status); if (o.acpOnly) { output.log( " Tip: Run `acp sell delete " + o.name + "` to delist from ACP", ); } } output.log(""); }); } // -- Inspect: detailed view -- function detectHandlers(offeringDir: string): string[] { const handlersPath = path.join( getOfferingsRoot(), offeringDir, "handlers.ts", ); if (!fs.existsSync(handlersPath)) return []; const content = fs.readFileSync(handlersPath, "utf-8"); const found: string[] = []; if (/export\s+(async\s+)?function\s+executeJob\s*\(/.test(content)) { found.push("executeJob"); } if ( /export\s+(async\s+)?function\s+validateRequirements\s*\(/.test(content) ) { found.push("validateRequirements"); } if (/export\s+(async\s+)?function\s+requestPayment\s*\(/.test(content)) { found.push("requestPayment"); } if ( /export\s+(async\s+)?function\s+requestAdditionalFunds\s*\(/.test(content) ) { found.push("requestAdditionalFunds"); } return found; } export async function inspect(offeringName: string): Promise { if (!offeringName) { output.fatal("Usage: acp sell inspect "); } const dir = resolveOfferingDir(offeringName); const configPath = path.join(dir, "offering.json"); if (!fs.existsSync(configPath)) { output.fatal(`Offering not found: ${offeringName}`); } const json = JSON.parse(fs.readFileSync(configPath, "utf-8")); const acpOfferings = await fetchAcpOfferings(); const isListed = acpOfferingNames(acpOfferings).has(json.name); const handlers = detectHandlers(offeringName); const data = { ...json, listed: isListed, handlers, }; output.output(data, (d) => { output.heading(`Offering: ${d.name}`); output.field("Description", d.description); output.field("Fee", `${d.jobFee} USDC`); output.field("Funds required", String(d.requiredFunds)); output.field("Status", d.listed ? "Listed on ACP" : "Local only"); output.field("Handlers", d.handlers.join(", ") || "(none)"); if (d.requirement) { output.log("\n Requirement Schema:"); output.log( JSON.stringify(d.requirement, null, 4) .split("\n") .map((line: string) => ` ${line}`) .join("\n"), ); } output.log(""); }); } // ============================================================================= // Resource Management // ============================================================================= function resolveResourceDir(resourceName: string): string { return path.resolve(RESOURCES_ROOT, resourceName); } interface LocalResource { dirName: string; name: string; description: string; url: string; } async function fetchAcpResourceNames(): Promise { try { const agentInfo = (await getMyAgentInfo()) as { jobs?: unknown[]; resources?: Resource[]; }; return agentInfo.resources ?? []; } catch { return []; } } export async function resourceList(): Promise { const resources = await fetchAcpResourceNames(); output.output(resources, (resources) => { output.heading("Resources"); if (resources.length === 0) { output.log( " No resources found. Run `acp sell resource init ` to create one.\n", ); return; } for (const r of resources) { output.log(`\n ${r.name}`); if (!r.acpOnly) { output.field(" Description", r.description); output.field(" URL", r.url); } } output.log(""); }); } function validateResourceJson(filePath: string): ValidationResult { const result: ValidationResult = { valid: true, errors: [], warnings: [] }; if (!fs.existsSync(filePath)) { result.valid = false; result.errors.push(`resources.json not found at ${filePath}`); return result; } let json: any; try { json = JSON.parse(fs.readFileSync(filePath, "utf-8")); } catch (err) { result.valid = false; result.errors.push(`Invalid JSON in resources.json: ${err}`); return result; } if (!json.name || typeof json.name !== "string" || json.name.trim() === "") { result.valid = false; result.errors.push('"name" field is required (non-empty string)'); } if ( !json.description || typeof json.description !== "string" || json.description.trim() === "" ) { result.valid = false; result.errors.push('"description" field is required (non-empty string)'); } if (!json.url || typeof json.url !== "string" || json.url.trim() === "") { result.valid = false; result.errors.push('"url" field is required (non-empty string)'); } if (json.params !== undefined && json.params !== null) { if (typeof json.params !== "object" || Array.isArray(json.params)) { result.valid = false; result.errors.push('"params" field must be an object if provided'); } } return result; } // -- Resource Init: scaffold a new resource -- export async function resourceInit(resourceName: string): Promise { if (!resourceName) { output.fatal("Usage: acp sell resource init "); } const dir = resolveResourceDir(resourceName); if (fs.existsSync(dir)) { output.fatal(`Resource directory already exists: ${dir}`); } fs.mkdirSync(dir, { recursive: true }); const resourceJson = { name: resourceName, description: "TODO: Describe what this resource provides", url: "https://api.example.com/endpoint", }; fs.writeFileSync( path.join(dir, "resources.json"), JSON.stringify(resourceJson, null, 2) + "\n", ); output.output({ created: dir }, () => { output.heading("Resource Scaffolded"); output.log(` Created: src/seller/resources/${resourceName}/`); output.log(` - resources.json (edit name, description, url, params)`); output.log( `\n Next: edit the file, then run: acp sell resource create ${resourceName}\n`, ); }); } // -- Resource Create: validate + register -- export async function resourceCreate(resourceName: string): Promise { if (!resourceName) { output.fatal("Usage: acp sell resource create "); } const dir = resolveResourceDir(resourceName); if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) { output.fatal( `Resource directory not found: ${dir}\n Create it with: acp sell resource init ${resourceName}`, ); } output.log(`\nValidating resource: "${resourceName}"\n`); const jsonPath = path.join(dir, "resources.json"); const validation = validateResourceJson(jsonPath); if (!validation.valid) { output.log(" Errors:"); validation.errors.forEach((e) => output.log(` - ${e}`)); output.fatal("\n Validation failed. Fix the errors above."); } if (validation.warnings.length > 0) { output.log(" Warnings:"); validation.warnings.forEach((w) => output.log(` - ${w}`)); } output.log(" Validation passed!\n"); // Register with ACP const json: any = JSON.parse(fs.readFileSync(jsonPath, "utf-8")); const resource: Resource = { name: json.name, description: json.description, url: json.url, params: json.params, }; output.log(" Registering resource with ACP..."); const result = await upsertResourceApi(resource); if (result.success) { output.log(" Resource registered successfully.\n"); } else { output.fatal(" Failed to register resource with ACP."); } } // -- Resource Delete: delete resource -- export async function resourceDelete(resourceName: string): Promise { if (!resourceName) { output.fatal("Usage: acp sell resource delete "); } output.log(`\n Deleting resource: "${resourceName}"...\n`); const result = await deleteResourceApi(resourceName); if (result.success) { output.log(" Resource deleted from ACP.\n"); } else { output.fatal(" Failed to delete resource from ACP."); } }