UNPKG

@kya-os/cli

Version:

CLI for MCP-I setup and management

435 lines โ€ข 21.6 kB
import chalk from "chalk"; import ora from "ora"; import { writeFileSync, existsSync, mkdirSync, readFileSync, chmodSync } from "fs"; import { join } from "path"; import { createHash, randomUUID } from "crypto"; import { EnvManager } from "../utils/env-manager.js"; import { showSuccess, showError, showInfo } from "../utils/prompts.js"; import { formatReceiptTs } from "../utils/time.js"; import contractsRegistry from "@kya-os/contracts/registry"; import { normalizeRegistrationResponse, } from "../utils/api-response.js"; import { writeAgentMetadata } from "../utils/dco/agent-metadata.js"; const { MCP_I_CAPABILITIES, RECEIPT_SCHEMA_ID, } = contractsRegistry; /** * Register agent with Know-That-AI (KTA) registry * * This command registers a new agent with the KTA registry. The registration * process can work in two modes: * * 1. With existing identity: If an XMCP-I identity exists (from `mcpi init`), * it will use the existing public key for registration. * * 2. Without identity: The KTA API will generate a new Ed25519 key pair and * save it to .mcpi/identity.json for future use. * * After successful registration, the agent receives: * - A unique agent ID and URL * - A claim token (valid for 24 hours) to claim ownership * - A verification endpoint for DID resolution * - A receipt for audit purposes * * @param options - Command options including JSON output and receipt settings */ export async function register(options = {}) { const { json = false, saveReceipt = true, receiptPath = ".mcpi/receipts", } = options; if (!json) { console.log(chalk.cyan("\n๐Ÿš€ Registering Agent with Know-That-AI\n")); } // Identity Discovery Process: // 1. Check environment variables first (highest priority) // 2. Fall back to .mcpi/identity.json if no env vars // 3. If no identity found, KTA API will generate new keys // // This allows flexibility: // - Production: Use env vars for secure key management // - Development: Use file-based identity for convenience // - New users: Auto-generate keys on first registration const envManager = new EnvManager(); let processVars = envManager.getFromProcess(); // Check for identity file as fallback let identityFromFile = null; const identityPath = join(process.cwd(), ".mcpi", "identity.json"); if (existsSync(identityPath)) { try { const identityContent = readFileSync(identityPath, "utf-8"); identityFromFile = JSON.parse(identityContent); // Validate that it's a proper identity object if (identityFromFile && typeof identityFromFile === 'object') { const hasValidKeys = identityFromFile.publicKey && typeof identityFromFile.publicKey === 'string'; if (!hasValidKeys) { if (!json && process.env.DEBUG) { console.warn(chalk.yellow('โš ๏ธ Identity file has invalid format - will generate new keys')); } identityFromFile = null; } } } catch (error) { // Log parse error in debug mode but continue // API can generate new keys if needed if (!json && process.env.DEBUG) { console.warn(chalk.yellow(`โš ๏ธ Failed to parse identity file: ${error instanceof Error ? error.message : 'Unknown error'}`)); } identityFromFile = null; } } // Merge identity from file ONLY for missing environment variables // This ensures environment variables take precedence over file if (identityFromFile) { if (!processVars.AGENT_DID && identityFromFile.did) { processVars.AGENT_DID = identityFromFile.did; } if (!processVars.AGENT_KEY_ID && identityFromFile.kid) { processVars.AGENT_KEY_ID = identityFromFile.kid; } if (!processVars.AGENT_PUBLIC_KEY && identityFromFile.publicKey) { processVars.AGENT_PUBLIC_KEY = identityFromFile.publicKey; } } // Note: Identity is optional - API will generate keys if not provided const spinner = json ? null : ora("Submitting registration to KTA...").start(); try { // Prefer explicit endpoint override, else derive from base/api, else default const KTA_API_ENDPOINT = process.env.KTA_REGISTER_ENDPOINT ?? (process.env.KTA_API_URL ? new URL("/api/agents/cli-register", process.env.KTA_API_URL).toString() : "https://knowthat.ai/api/agents/cli-register"); // Get API key from environment const apiKey = process.env.KYA_VOUCHED_API_KEY || process.env.VOUCHED_API_KEY; if (!apiKey) { throw new Error("KYA_VOUCHED_API_KEY environment variable is required for registration. " + "Set it in your .env file or environment."); } // Prepare registration payload const registrationPayload = { name: processVars.AGENT_NAME || "MCP-I Agent", description: processVars.AGENT_DESCRIPTION || "Agent with MCP-I identity features", ...(processVars.AGENT_GITHUB_REPO && { githubRepo: processVars.AGENT_GITHUB_REPO }), ...(processVars.AGENT_PUBLIC_KEY && { publicKey: processVars.AGENT_PUBLIC_KEY }), }; // Call the KTA API const response = await fetch(KTA_API_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`, }, body: JSON.stringify(registrationPayload), }); if (!response.ok) { const contentType = response.headers.get('content-type'); let errorMessage = `API request failed with status ${response.status}`; try { if (contentType?.includes('application/json')) { const errorData = await response.json(); errorMessage = errorData.error || errorData.details || errorMessage; } else { const textResponse = await response.text(); errorMessage = `API returned non-JSON response: ${textResponse.substring(0, 200)}`; } } catch (parseError) { errorMessage = `Failed to parse error response from API (status ${response.status})`; } throw new Error(errorMessage); } // Parse response and normalize to handle both new and legacy API formats const contentType = response.headers.get('content-type'); if (!contentType?.includes('application/json')) { const textResponse = await response.text(); throw new Error(`API returned non-JSON response: ${textResponse.substring(0, 200)}`); } const rawResponse = await response.json(); const apiResponse = normalizeRegistrationResponse(rawResponse); if (!apiResponse.success) { throw new Error(apiResponse.error || "Registration failed"); } const registrationResult = { agentDID: apiResponse.did, agentURL: apiResponse.agent.url, agentId: apiResponse.agent.id, agentSlug: apiResponse.agent.slug, claimURL: apiResponse.claimUrl, verificationEndpoint: `${apiResponse.agent.url}/.well-known/did.json`, conformanceCapabilities: [...MCP_I_CAPABILITIES], mirrorStatus: "pending", mirrorLink: undefined, }; // Store claim token for display later const claimToken = apiResponse.claimToken; // Key Storage for API-Generated Identity: // When the API generates new keys (no existing identity provided), // we save them locally for future use. This ensures: // 1. The agent can sign future requests with the same identity // 2. The user doesn't lose access to their agent // 3. The identity persists across CLI sessions // // SECURITY NOTE: The private key is stored in .mcpi/identity.json // This file should NEVER be committed to version control if (apiResponse.keys) { const identityPath = join(process.cwd(), ".mcpi"); if (!existsSync(identityPath)) { mkdirSync(identityPath, { recursive: true }); } const identityFile = join(identityPath, "identity.json"); // Generate kid from publicKey (matches identity-manager.ts algorithm) // Convert base64 publicKey to Buffer, hash it, take first 8 hex chars const publicKeyBuffer = Buffer.from(apiResponse.keys.publicKey, 'base64'); const kid = `key-${createHash('sha256').update(publicKeyBuffer).digest('hex').slice(0, 8)}`; // Save complete IdentityConfig per contracts schema const now = new Date().toISOString(); writeFileSync(identityFile, JSON.stringify({ version: "1.0", did: apiResponse.did, kid: kid, privateKey: apiResponse.keys.privateKey, publicKey: apiResponse.keys.publicKey, createdAt: now, lastRotated: now, }, null, 2)); // Set restrictive file permissions (owner read/write only) try { chmodSync(identityFile, 0o600); } catch (error) { // On Windows, chmod may not work as expected; log warning but continue if (process.platform !== 'win32' && !json) { console.warn(chalk.yellow(`Warning: Could not set secure file permissions on ${identityFile}`)); } } // Note: apiResponse.keys.warning may contain security reminders } // Persist public agent metadata (no secrets) so later commands like // `mcpi dco setup` know the agent's name, slug, and URL without another // API round trip. Written AFTER identity.json so a failed identity write // never leaves agent.json orphaned without a matching identity. try { writeAgentMetadata(process.cwd(), { id: apiResponse.agent.id, slug: apiResponse.agent.slug, name: registrationPayload.name, url: apiResponse.agent.url, did: apiResponse.did, claimUrl: apiResponse.claimUrl, registeredAt: new Date().toISOString(), }); } catch (error) { // Non-fatal, but never silent: without agent.json a later // `mcpi dco setup` cannot resolve the agent name/slug on its own. // console.warn writes to stderr, so the --json stdout contract holds. console.warn(chalk.yellow(`Warning: could not write .mcpi/agent.json: ${error instanceof Error ? error.message : "Unknown error"}`)); } // Generate Registration Receipt: // Receipts serve as cryptographic proof of registration events // Prefer server-provided receipt fields over client generation const uniqueId = randomUUID(); const timestamp = Date.now(); const timestampSeconds = Math.floor(timestamp / 1000); const receipt = { $schema: RECEIPT_SCHEMA_ID, ...apiResponse.receipt, // Spread server-provided fields first ref: apiResponse.receipt?.ref ?? `reg_${apiResponse.agent.id}_${uniqueId}`, contentHash: apiResponse.receipt?.contentHash ?? `sha256:${createHash('sha256').update(apiResponse.did).digest('hex')}`, action: "issue", ts: apiResponse.receipt?.ts ?? new Date().toISOString(), logIndex: (apiResponse.receipt?.logIndex != null ? Number(apiResponse.receipt.logIndex) : timestampSeconds), // Unix timestamp in seconds (not milliseconds) logRoot: apiResponse.receipt?.logRoot ?? `sha256:${createHash('sha256').update(`${timestamp}-${apiResponse.did}-${uniqueId}`).digest('hex')}`, inclusionProof: apiResponse.receipt?.inclusionProof ?? [], }; spinner?.succeed("Registration submitted successfully!"); if (json) { // JSON output mode for automation - follows contracts schema const output = { success: true, registration: registrationResult, claimToken: claimToken, receipt: { ref: receipt.ref, contentHash: receipt.contentHash, action: receipt.action, timestamp: receipt.ts, }, schemaId: RECEIPT_SCHEMA_ID, }; console.log(JSON.stringify(output, null, 2)); } else { // Human-readable output - display all required fields per R8.7 console.log(`\n${chalk.bold("๐Ÿ“‹ Registration Details:")}`); console.log(` Agent DID: ${chalk.gray(registrationResult.agentDID)}`); console.log(` Agent URL: ${chalk.cyan(registrationResult.agentURL)}`); console.log(` Agent ID/Slug: ${chalk.gray(registrationResult.agentId)}`); console.log(` Verification Endpoint: ${chalk.gray(registrationResult.verificationEndpoint)}`); console.log(`\n${chalk.bold("๐Ÿ”ง Conformance Capabilities:")}`); registrationResult.conformanceCapabilities.forEach((cap) => { console.log(` โ€ข ${chalk.green(cap)}`); }); if (claimToken) { console.log(`\n${chalk.bold("๐ŸŽฏ Claim Your Agent:")}`); console.log(` Token: ${chalk.cyan(claimToken)}`); console.log(` Command: ${chalk.gray(`mcpi claim ${claimToken}`)}`); if (registrationResult.claimURL) { console.log(` Or visit: ${chalk.cyan(registrationResult.claimURL)}`); } showInfo("Use the token to claim ownership and manage your agent (24h TTL)."); } console.log(`\n${chalk.bold("๐Ÿ”„ MCP Registry Mirror:")}`); const statusColor = registrationResult.mirrorStatus === "success" ? chalk.green : registrationResult.mirrorStatus === "error" ? chalk.red : chalk.yellow; console.log(` Status: ${statusColor(registrationResult.mirrorStatus)}`); if (registrationResult.mirrorLink) { console.log(` Status Link: ${chalk.gray(registrationResult.mirrorLink)}`); } console.log(`\n${chalk.bold("๐Ÿงพ Receipt Information:")}`); console.log(` Reference: ${chalk.gray(receipt.ref)}`); console.log(` Content Hash: ${chalk.gray(receipt.contentHash)}`); console.log(` Timestamp: ${chalk.gray(formatReceiptTs(receipt.ts))}`); } // Save receipt if requested if (saveReceipt) { try { // Ensure receipt directory exists if (!existsSync(receiptPath)) { mkdirSync(receiptPath, { recursive: true }); } const receiptFile = join(receiptPath, `registration_${receipt.ref}.json`); writeFileSync(receiptFile, JSON.stringify(receipt, null, 2)); if (!json) { showInfo(`Receipt saved to: ${receiptFile}`); } } catch (error) { const message = `Failed to save receipt: ${error instanceof Error ? error.message : "Unknown error"}`; if (json) { console.error(JSON.stringify({ warning: message })); } else { console.warn(chalk.yellow(`โš ๏ธ ${message}`)); } } } if (!json) { showSuccess("Agent registration completed!"); // Handle mirror status without failing on pending (R8.8) if (registrationResult.mirrorStatus === "pending") { showInfo("MCP Registry mirror is pending - this is normal for new registrations."); } else if (registrationResult.mirrorStatus === "success") { showSuccess("Agent successfully mirrored to MCP Registry!"); } console.log(`\n${chalk.gray("Next steps:")}`); if (claimToken) { console.log(`${chalk.gray("1. Claim your agent with:")} ${chalk.cyan(`mcpi claim ${claimToken}`)} ${chalk.gray("(24h TTL)")}`); } else { console.log(`${chalk.gray("1. Use the claim URL to take ownership of your agent (24h TTL)")}`); } console.log(`${chalk.gray("2. Check mirror status with:")} ${chalk.cyan("mcpi status")}`); console.log(`${chalk.gray("3. Start your agent with:")} ${chalk.cyan("npm run dev")}`); console.log(`${chalk.gray("4. Sign commits and attribute PRs to your agent:")} ${chalk.cyan("mcpi dco setup")}`); // Offer to run the DCO/attribution setup right away. Skipped with // --no-dco, in JSON mode, without a TTY, and under test. const isTestEnvironment = process.env.NODE_ENV === "test" || !!process.env.VITEST; if (options.dco !== false && process.stdin.isTTY && !isTestEnvironment) { const inquirer = (await import("inquirer")).default; const { setupDco } = await inquirer.prompt([ { type: "confirm", name: "setupDco", message: "Set up commit signing and PR attribution for this agent now?", default: true, }, ]); if (setupDco) { const { runDcoSetup } = await import("../utils/dco/setup.js"); const { renderSetupResult } = await import("../utils/dco/render.js"); const dcoResult = await runDcoSetup({ interactive: true }); renderSetupResult(dcoResult); } // register historically exits by event-loop drain; after an inquirer // prompt that only works if stdin is left paused, so pause explicitly // instead of relying on inquirer internals (mirrors init.ts). process.stdin.pause(); process.stdin.removeAllListeners("data"); } } } catch (error) { spinner?.fail("Registration failed"); const errorMessage = error.message || "Failed to register agent"; if (json) { console.log(JSON.stringify({ success: false, error: errorMessage, code: error.code || "XMCP_I_EREGISTER", }, null, 2)); } else { if (error.message?.includes("429")) { showError("Rate limit exceeded. Please try again in a few minutes."); } else if (error.message?.includes("Network")) { showError("Network error. Check your internet connection."); } else { showError(errorMessage); } console.log(`\n${chalk.gray("You can also register manually at:")}`); console.log(`${chalk.cyan("https://knowthat.ai/submit-agent")}`); } if (process.env.NODE_ENV !== "test") { process.exit(1); } } } /** * Retrieve and display stored receipts */ export async function listReceipts(receiptPath = ".mcpi/receipts") { console.log(chalk.cyan("\n๐Ÿ“‹ Stored Receipts\n")); if (!existsSync(receiptPath)) { showInfo("No receipts found. Receipts are saved after registration and claim operations."); return; } try { const fs = await import("fs/promises"); const files = await fs.readdir(receiptPath); const receiptFiles = files.filter((f) => f.endsWith(".json")); if (receiptFiles.length === 0) { showInfo("No receipt files found in the receipts directory."); return; } console.log(`${chalk.bold("Found receipts:")}`); for (const file of receiptFiles) { try { const content = await fs.readFile(join(receiptPath, file), "utf-8"); const receipt = JSON.parse(content); console.log(`\n${chalk.bold("๐Ÿ“„")} ${chalk.cyan(file)}`); console.log(` Reference: ${chalk.gray(receipt.ref)}`); console.log(` Action: ${chalk.green(receipt.action)}`); console.log(` Content Hash: ${chalk.gray(receipt.contentHash)}`); console.log(` Timestamp: ${chalk.gray(formatReceiptTs(receipt.ts))}`); console.log(` Log Index: ${chalk.gray(receipt.logIndex)}`); } catch (error) { console.log(`\n${chalk.bold("๐Ÿ“„")} ${chalk.red(file)} ${chalk.gray("(invalid format)")}`); } } } catch (error) { showError(`Failed to read receipts: ${error instanceof Error ? error.message : "Unknown error"}`); } } //# sourceMappingURL=register.js.map