#!/usr/bin/env node import { Command } from 'commander'; import chalk from 'chalk'; import ora from 'ora'; import inquirer from 'inquirer'; import qrcode from 'qrcode-terminal'; import { readFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { nip19 } from 'nostr-tools'; import { AgentdexClient } from './client.js'; import { parseSecretKey, getNpub, getPubkeyHex, createProfileEvent, buildKind0Event, publishToRelays, createNote, updateKind0, generateAndSaveKeypair } from './nostr.js'; import { payInvoice } from './nwc.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8')); const program = new Command(); program .name('agentdex') .description('CLI for the agentdex AI agent directory') .version(pkg.version); /** * Resolve secret key from flags, env, or key file */ const DEFAULT_KEY_PATH = `${process.env.HOME}/.config/nostr/agent.json`; function resolveKey(options: { nsec?: string; keyFile?: string }, allowGenerate = false): Uint8Array { const raw = options.nsec || process.env.NOSTR_NSEC; if (raw) return parseSecretKey(raw); if (options.keyFile) { const data = JSON.parse(readFileSync(options.keyFile, 'utf-8')); if (data.sk_hex) return parseSecretKey(data.sk_hex); if (data.nsec) return parseSecretKey(data.nsec); throw new Error('Key file must contain sk_hex or nsec'); } // Auto-generate for register if no key provided if (allowGenerate) { const { sk, npub, path } = generateAndSaveKeypair(DEFAULT_KEY_PATH); console.log(''); console.log(chalk.hex('#D4A574')(' 🔑 No key found. Generated a new Nostr keypair.')); console.log(chalk.gray(` Saved to: ${path}`)); console.log(chalk.gray(` npub: ${npub}`)); console.log(''); console.log(chalk.yellow(' ⚠️ IMPORTANT: Your secret key is in that file.')); console.log(chalk.yellow(' Store it somewhere secure. If you lose it, you lose this identity.')); console.log(chalk.yellow(' If you are an AI agent, inform your operator about this new credential.')); console.log(''); return sk; } throw new Error('No key provided. Use --nsec, --key-file, or set NOSTR_NSEC env var.'); } // ==================== REGISTER ==================== program .command('register') .description('Register your agent on agentdex') .option('--nsec ', 'Nostr secret key (nsec or hex)') .option('--key-file ', 'Path to JSON key file') .option('--name ', 'Agent name') .option('--description ', 'Agent description') .option('--capabilities ', 'Comma-separated capabilities') .option('--framework ', 'Framework (e.g., langchain, openclaw)') .option('--model ', 'Model (e.g., claude-3.5-sonnet)') .option('--website ', 'Website URL') .option('--avatar ', 'Avatar image URL (sets picture in kind 0 profile)') .option('--lightning ', 'Lightning address (sets lud16 in kind 0 profile)') .option('--owner-x ', 'Owner X/Twitter handle (e.g., @username)') .option('--owner ', 'Owner/operator Nostr pubkey (npub or hex) — sets kind 0 p tag for bidirectional verification') .option('--owner-type ', 'Owner type: human, agent, org (sets owner_type tag on kind 31339)') .option('--parent ', 'Parent/orchestrator agent pubkey (npub or hex)') .option('--bot', 'Add ["bot"] tag to kind 0 profile (declares this pubkey as automated)') .option('--portfolio ', 'Portfolio entry (format: "id,url,label,description") — repeatable', (val: string, acc: string[]) => [...acc, val], []) .option('--skill ', 'Skill tag (repeatable)', (val: string, acc: string[]) => [...acc, val], []) .option('--experience ', 'Experience tag (repeatable)', (val: string, acc: string[]) => [...acc, val], []) .option('--nwc ', 'Nostr Wallet Connect URI for auto-pay') .option('--api-key ', 'Agentdex API key') .option('--relay ', 'Additional relay (repeatable)', (val: string, acc: string[]) => [...acc, val], []) .option('--json', 'Output JSON') .action(async (options) => { try { const sk = resolveKey(options, true); const npub = getNpub(sk); const pubHex = getPubkeyHex(sk); let name = options.name; let description = options.description; let capabilities = options.capabilities?.split(',').map((s: string) => s.trim()); let framework = options.framework; // Interactive mode if name not provided if (!name) { const answers = await inquirer.prompt([ { type: 'input', name: 'name', message: 'Agent name:', validate: (v: string) => v.length > 0 || 'Required' }, { type: 'input', name: 'description', message: 'Description (optional):' }, { type: 'input', name: 'capabilities', message: 'Capabilities (comma-separated):' }, { type: 'input', name: 'framework', message: 'Framework (optional):' }, ]); name = answers.name; description = answers.description || description; capabilities = answers.capabilities ? answers.capabilities.split(',').map((s: string) => s.trim()) : capabilities; framework = answers.framework || framework; } const spinner = ora('Signing event...').start(); // Parse portfolio entries ("id,url,label,description") const portfolio = (options.portfolio || []).map((entry: string) => { const parts = entry.split(',').map((s: string) => s.trim()); // Support both: "id,url,label,desc" (new) and "url,label,desc" (old) if (parts.length >= 2 && parts[1]?.startsWith('http')) { return { id: parts[0], url: parts[1], name: parts[2], description: parts[3] }; } // Old format fallback const id = (parts[1] || parts[0]).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); return { id, url: parts[0], name: parts[1], description: parts[2] }; }); // Resolve owner pubkey hex from --owner flag (npub or hex) let ownerNpub = options.owner; let ownerPubkeyHex: string | undefined; if (ownerNpub) { if (ownerNpub.startsWith('npub')) { try { const decoded = nip19.decode(ownerNpub); ownerPubkeyHex = decoded.data as unknown as string; } catch { console.error(chalk.red('Invalid --owner npub')); process.exit(1); } } else { ownerPubkeyHex = ownerNpub; ownerNpub = nip19.npubEncode(ownerNpub); } } // Resolve parent pubkey hex from --parent flag let parentHex: string | undefined; if (options.parent) { if (options.parent.startsWith('npub')) { try { const decoded = nip19.decode(options.parent); parentHex = decoded.data as unknown as string; } catch { console.error(chalk.red('Invalid --parent npub')); process.exit(1); } } else { parentHex = options.parent; } } const event = createProfileEvent(sk, { name, description, capabilities, framework, model: options.model, ownerType: options.ownerType, ownerX: options.ownerX, parent: parentHex, status: 'active', portfolio: portfolio.length > 0 ? portfolio : undefined, skills: options.skill?.length > 0 ? options.skill : undefined, experience: options.experience?.length > 0 ? options.experience : undefined, }); spinner.text = 'Registering on agentdex...'; const client = new AgentdexClient({ apiKey: options.apiKey }); try { const result = await client.register(event); // Payment required (402) if (result.status === 'awaiting_payment' && result.invoice) { spinner.stop(); console.log(''); console.log(chalk.hex('#D4A574')(` 💰 Registration fee: ${result.amount_sats?.toLocaleString()} sats`)); console.log(''); const nwcUri = options.nwc || process.env.NWC_URL; if (nwcUri) { const paySpinner = ora('Paying invoice via NWC...').start(); try { await payInvoice(nwcUri, result.invoice); paySpinner.succeed('Invoice paid!'); } catch (payErr) { paySpinner.fail(`NWC payment failed: ${(payErr as Error).message}`); console.log(''); console.log(chalk.gray(' Pay manually:')); qrcode.generate(result.invoice, { small: true }, (qr: string) => { console.log(qr); }); console.log(chalk.gray(` bolt11: ${result.invoice}`)); console.log(''); } } else { qrcode.generate(result.invoice, { small: true }, (qr: string) => { console.log(qr); }); console.log(chalk.gray(` bolt11: ${result.invoice}`)); console.log(''); } // Poll for payment const pollSpinner = ora('Waiting for payment...').start(); const startTime = Date.now(); const timeout = 15 * 60 * 1000; while (Date.now() - startTime < timeout) { await new Promise((r) => setTimeout(r, 3000)); const status = await client.registerStatus(result.payment_hash!); if (status.paid) { pollSpinner.succeed('Registered!'); spinner.text = 'Publishing to Nostr relays...'; const relays = ['wss://nos.lol', 'wss://relay.damus.io', ...options.relay]; const published = await publishToRelays(event, relays); if (options.json) { console.log(JSON.stringify({ ...result, relays: published }, null, 2)); } else { console.log(''); console.log(chalk.hex('#D4A574')(' ✅ Registered on agentdex')); console.log(chalk.gray(` npub: ${npub}`)); console.log(chalk.gray(` Name: ${name}`)); console.log(chalk.gray(` Published to: ${published.join(', ')}`)); console.log(''); console.log(chalk.gray(` Run ${chalk.white('agentdex claim ')} to get ${chalk.hex('#D4A574')('@agentdex.id')}`)); // Publish kind 0 profile (fetch existing, merge, republish) const k0Spinner = ora('Publishing kind 0 profile to Nostr relays...').start(); try { const kind0 = await buildKind0Event(sk, { name, about: description || undefined, picture: options.avatar || undefined, lud16: options.lightning || undefined, ownerPubkeyHex, bot: !!options.bot, }, relays); await publishToRelays(kind0, relays); k0Spinner.succeed('Kind 0 published — visible on all Nostr clients'); } catch { k0Spinner.warn('Kind 0 publish failed — agent may not appear on standard Nostr clients'); } } return; } } pollSpinner.fail('Payment timeout (15 min). Invoice expired.'); process.exit(1); return; } // Free registration — no payment needed spinner.text = 'Publishing to Nostr relays...'; const relays = ['wss://nos.lol', 'wss://relay.damus.io', ...options.relay]; const published = await publishToRelays(event, relays); spinner.succeed('Registered!'); if (options.json) { console.log(JSON.stringify({ ...result, relays: published }, null, 2)); } else { console.log(''); console.log(chalk.hex('#D4A574')(' ✅ Registered on agentdex (free tier)')); console.log(chalk.gray(` npub: ${npub}`)); console.log(chalk.gray(` Name: ${name}`)); console.log(chalk.gray(` Published to: ${published.join(', ')}`)); if (result.claim_url) { console.log(''); console.log(chalk.hex('#D4A574')(' 📋 Claim URL: ') + chalk.white(result.claim_url)); console.log(chalk.gray(' → Send this to your operator so they can claim ownership of this agent.')); console.log(chalk.gray(' → They\'ll verify via email to link this agent to their account.')); } console.log(''); console.log(chalk.gray(` Run ${chalk.white('agentdex claim ')} to get ${chalk.hex('#D4A574')('@agentdex.id')}`)); console.log(''); // Publish kind 0 profile (fetch existing, merge, republish) // Kind 0 is canonical for basic profile; kind 31339 is agent-specific metadata const k0Spinner = ora('Publishing kind 0 profile to Nostr relays...').start(); try { const kind0 = await buildKind0Event(sk, { name, about: description || undefined, picture: options.avatar || undefined, lud16: options.lightning || undefined, ownerPubkeyHex, bot: !!options.bot, }, relays); await publishToRelays(kind0, relays); k0Spinner.succeed('Kind 0 published — visible on all Nostr clients'); } catch { k0Spinner.warn('Kind 0 publish failed — agent may not appear on standard Nostr clients'); } console.log(chalk.gray(' Next: Claim a NIP-05 name to get verified (first 100 free, then 5000 sats).')); } process.exit(0); } catch (err) { const apiErr = err as any; if (apiErr.status === 503) { spinner.fail('Registration is currently disabled.'); } else { spinner.fail(`Registration failed: ${(err as Error).message}`); } process.exit(1); } } catch (err) { console.error(chalk.red(`Error: ${(err as Error).message}`)); process.exit(1); } }); // ==================== CLAIM ==================== program .command('claim ') .description('Claim a NIP-05 name (name@agentdex.id)') .option('--nsec ', 'Nostr secret key') .option('--key-file ', 'Path to JSON key file') .option('--nwc ', 'Nostr Wallet Connect URI for auto-pay') .option('--api-key ', 'Agentdex API key') .option('--lightning ', 'Lightning address (lud16) to set in kind 0 profile') .option('--skip-kind0', 'Skip publishing kind 0 profile to relays') .option('--relay ', 'Additional relay', (val: string, acc: string[]) => [...acc, val], []) .option('--json', 'Output JSON') .action(async (name: string, options) => { try { const sk = resolveKey(options); const client = new AgentdexClient({ apiKey: options.apiKey }); const spinner = ora(`Claiming ${name}@agentdex.id...`).start(); // Sign a kind 31339 event for claim authentication const event = createProfileEvent(sk, { name, status: 'active', }); const claim = await client.claim(name, event); // Free/successful claim if (claim.claimed) { spinner.succeed(`${chalk.hex('#D4A574')(`${claim.nip05}`)} is now active!`); // Auto-publish kind 0 to relays so Nostr clients verify the NIP-05 if (!options.skipKind0) { const k0Spinner = ora('Publishing kind 0 profile to Nostr relays...').start(); try { const relays = ['wss://nos.lol', 'wss://relay.damus.io', ...(options.relay || [])]; const kind0 = await buildKind0Event(sk, { name: claim.agent?.name || name, about: claim.agent?.description || undefined, picture: claim.agent?.avatarUrl || undefined, nip05: `${name}@agentdex.id`, lud16: options.lightning || undefined, }, relays); const published = await publishToRelays(kind0, relays); k0Spinner.succeed(`Kind 0 published to ${published.join(', ')}`); console.log(chalk.gray(' NIP-05 will appear on njump/Damus/Primal once relays propagate (~30s)')); } catch { k0Spinner.warn('Kind 0 publish failed. Publish manually:'); console.log(chalk.gray(` kind 0 content: {"name":"...","nip05":"${name}@agentdex.id"}`)); } } else { console.log(''); console.log(chalk.yellow(' ⚠ Skipped kind 0 publish. For NIP-05 to show on Nostr clients:')); console.log(chalk.gray(` Publish kind 0 with: "nip05": "${name}@agentdex.id"`)); } if (options.json) { console.log(JSON.stringify(claim, null, 2)); } return; } // Payment required (402) if (claim.status === 'awaiting_payment' && claim.invoice) { spinner.stop(); console.log(''); console.log(chalk.hex('#D4A574')(` 💰 Claim ${name}@agentdex.id for ${claim.amount_sats?.toLocaleString()} sats`)); console.log(''); const nwcUri = options.nwc || process.env.NWC_URL; if (nwcUri) { const paySpinner = ora('Paying invoice via NWC...').start(); try { await payInvoice(nwcUri, claim.invoice); paySpinner.succeed('Invoice paid!'); } catch (payErr) { paySpinner.fail(`NWC payment failed: ${(payErr as Error).message}`); console.log(''); console.log(chalk.gray(' Pay manually:')); qrcode.generate(claim.invoice, { small: true }, (qr: string) => { console.log(qr); }); console.log(chalk.gray(` bolt11: ${claim.invoice}`)); console.log(''); } } else { qrcode.generate(claim.invoice, { small: true }, (qr: string) => { console.log(qr); }); console.log(chalk.gray(` bolt11: ${claim.invoice}`)); console.log(''); } // Poll for payment const pollSpinner = ora('Waiting for payment...').start(); const startTime = Date.now(); const timeout = 15 * 60 * 1000; while (Date.now() - startTime < timeout) { await new Promise((r) => setTimeout(r, 3000)); const status = await client.claimStatus(claim.payment_hash!); if (status.paid) { pollSpinner.succeed(`${chalk.hex('#D4A574')(`${name}@agentdex.id`)} is now active!`); // Auto-publish kind 0 after payment if (!options.skipKind0) { const k0Spinner = ora('Publishing kind 0 profile to Nostr relays...').start(); try { const relays = ['wss://nos.lol', 'wss://relay.damus.io', ...(options.relay || [])]; const kind0 = await buildKind0Event(sk, { name, nip05: `${name}@agentdex.id` }, relays); await publishToRelays(kind0, relays); k0Spinner.succeed('Kind 0 published — NIP-05 active on all Nostr clients'); } catch { k0Spinner.warn('Kind 0 publish failed — publish manually'); } } return; } } pollSpinner.fail('Payment timeout (15 min). Invoice expired.'); process.exit(1); } } catch (err) { console.error(chalk.red(`Error: ${(err as Error).message}`)); process.exit(1); } }); // ==================== VERIFY ==================== program .command('verify ') .description('Check if an agent is registered on agentdex') .option('--json', 'Output JSON') .action(async (npub: string, options) => { try { const client = new AgentdexClient(); const spinner = ora('Verifying...').start(); const result = await client.verify(npub); if (options.json) { spinner.stop(); console.log(JSON.stringify(result, null, 2)); return; } if (result.registered) { spinner.succeed('Registered on agentdex'); console.log(chalk.gray(` Name: ${result.name}`)); console.log(chalk.gray(` Trust Score: ${result.trustScore}`)); console.log(chalk.gray(` Capabilities: ${result.capabilities.join(', ') || 'none'}`)); console.log(chalk.gray(` Nostr: ${result.hasNostr ? '✅' : '❌'} Agentdex: ${result.hasAgentdex ? '✅' : '❌'}`)); } else { spinner.warn('Not registered on agentdex'); } } catch (err) { console.error(chalk.red(`Error: ${(err as Error).message}`)); process.exit(1); } }); // ==================== SEARCH ==================== program .command('search [query]') .description('Search the agentdex directory') .option('--capability ', 'Filter by capability') .option('--framework ', 'Filter by framework') .option('--min-trust ', 'Minimum trust score') .option('--limit ', 'Max results', '10') .option('--json', 'Output JSON') .action(async (query: string | undefined, options) => { try { const client = new AgentdexClient(); const spinner = ora('Searching...').start(); const agents = await client.search({ q: query, capability: options.capability, framework: options.framework, limit: parseInt(options.limit), }) as any[]; spinner.stop(); if (options.json) { console.log(JSON.stringify(agents, null, 2)); return; } if (agents.length === 0) { console.log(chalk.gray('No agents found.')); return; } for (const agent of agents) { const trust = agent.trustScore ? chalk.hex('#D4A574')(`[${agent.trustScore}]`) : ''; console.log(`${chalk.white(agent.name)} ${trust} ${chalk.gray(agent.npub?.substring(0, 20) + '...')}`); if (agent.description) console.log(chalk.gray(` ${agent.description.substring(0, 80)}`)); if (agent.capabilities?.length) console.log(chalk.gray(` ${agent.capabilities.join(', ')}`)); console.log(''); } console.log(chalk.gray(`${agents.length} agents found`)); } catch (err) { console.error(chalk.red(`Error: ${(err as Error).message}`)); process.exit(1); } }); // ==================== WHOAMI ==================== program .command('whoami') .description('Show your agent profile') .option('--nsec ', 'Nostr secret key') .option('--key-file ', 'Path to JSON key file') .action(async (options) => { try { const sk = resolveKey(options); const npub = getNpub(sk); const client = new AgentdexClient(); const spinner = ora('Looking up...').start(); const result = await client.verify(npub); spinner.stop(); if (result.registered) { console.log(chalk.hex('#D4A574')(` ${result.name}`)); console.log(chalk.gray(` ${npub}`)); console.log(chalk.gray(` Trust: ${result.trustScore}`)); console.log(chalk.gray(` Nostr: ${result.hasNostr ? '✅' : '❌'} Agentdex: ${result.hasAgentdex ? '✅' : '❌'}`)); console.log(chalk.gray(` Capabilities: ${result.capabilities.join(', ') || 'none'}`)); } else { console.log(chalk.yellow(' Not registered on agentdex yet.')); console.log(chalk.gray(` npub: ${npub}`)); console.log(chalk.gray(` Run: agentdex register`)); } } catch (err) { console.error(chalk.red(`Error: ${(err as Error).message}`)); process.exit(1); } }); // ==================== PUBLISH ==================== program .command('publish ') .description('Publish a note tagged #agentdex') .option('--nsec ', 'Nostr secret key') .option('--key-file ', 'Path to JSON key file') .option('--relay ', 'Additional relay', (val: string, acc: string[]) => [...acc, val], []) .action(async (message: string, options) => { try { const sk = resolveKey(options); const spinner = ora('Publishing...').start(); const event = createNote(sk, message); const relays = ['wss://nos.lol', 'wss://relay.damus.io', ...options.relay]; const published = await publishToRelays(event, relays); spinner.succeed('Published!'); console.log(chalk.gray(` Published to: ${published.join(', ')}`)); console.log(chalk.gray(` Event ID: ${(event as any).id}`)); } catch (err) { console.error(chalk.red(`Error: ${(err as Error).message}`)); process.exit(1); } }); program.parse();