@kya-os/cli
Version:
CLI for MCP-I setup and management
435 lines โข 21.6 kB
JavaScript
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