@kya-os/cli
Version:
CLI for MCP-I setup and management
315 lines ⢠15.7 kB
JavaScript
import chalk from "chalk";
import ora from "ora";
import { existsSync, readFileSync } from "fs";
import { join } from "path";
import { EnvManager } from "../utils/env-manager.js";
import { showSuccess, showError, showInfo } from "../utils/prompts.js";
import contractsRegistry from "@kya-os/contracts/registry";
const { RECEIPT_SCHEMA_ID, CLAIM_TOKEN_TTL_HOURS, } = contractsRegistry;
import { status } from "./status.js";
/**
* Claim an agent with a token or retrieve management URLs
*
* This command serves two purposes:
* 1. With token: Attempts to claim ownership of an agent using a claim token
* 2. Without token: Retrieves management URLs for the current agent
*
* The claim process requires authentication through a web browser as it uses
* NextAuth for user verification. The CLI generates the claim URL which must
* be opened in a browser to complete the claim process.
*
* @param token - Optional claim token from registration
* @param options - Command options
*/
export async function claim(token, options = {}) {
const { json = false, saveReceipt = true, receiptPath = ".mcpi/receipts", } = options;
if (!json) {
console.log(chalk.cyan(token
? "\nđŻ Claiming Agent with Token\n"
: "\nđŻ Agent Management URLs\n"));
}
// Identity Loading Strategy:
// 1. First, load identity from environment variables (highest priority)
// 2. Then, check for .mcpi/identity.json file as fallback
// 3. Only use file values for fields not already set in environment
// This prevents overwriting env vars with potentially stale file data
const envManager = new EnvManager();
let processVars = envManager.getFromProcess();
// Check for identity file as secondary source
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 required fields exist
if (identityFromFile && typeof identityFromFile === 'object') {
const hasRequiredFields = identityFromFile.did &&
identityFromFile.publicKey;
if (!hasRequiredFields) {
console.warn(chalk.yellow('â ď¸ Identity file is missing required fields'));
identityFromFile = null;
}
}
}
catch (error) {
// Log parse error for debugging but continue
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;
}
}
if (!processVars.AGENT_DID) {
const error = 'No XMCP-I identity found. Run "mcpi init" first.';
if (json) {
console.log(JSON.stringify({ success: false, error }, null, 2));
}
else {
showError(error);
}
return;
}
const spinner = json
? null
: ora(token ? "Processing claim token..." : "Retrieving management URLs...").start();
try {
if (token) {
// Token-Based Claim Flow:
// The claim process requires web authentication because:
// 1. KTA uses NextAuth for user authentication
// 2. Claims must be associated with authenticated user accounts
// 3. This ensures only legitimate users can claim agents
//
// Flow:
// 1. Validate token format and TTL locally
// 2. Print browser URL for user authentication
// 3. Poll API to confirm claim completion
// 4. Report success only after server verification
// Step 1: Validate token format
// Token must follow format: claim-token:timestamp_uuid
if (!token?.startsWith("claim-token:")) {
const error = new Error("Invalid claim token format. Expected format: claim-token:timestamp_uuid");
error.code = "XMCP_I_ECLAIM";
throw error;
}
// Step 2: Validate token TTL
// Extract timestamp from token and check if expired
const tsPart = token.split(":")[1]?.split("_")[0];
const issuedMs = Number(tsPart);
if (!Number.isFinite(issuedMs)) {
const error = new Error("Token missing or invalid timestamp");
error.code = "XMCP_I_ECLAIM";
throw error;
}
const maxAgeMs = CLAIM_TOKEN_TTL_HOURS * 60 * 60 * 1000;
const ageMs = Date.now() - issuedMs;
if (ageMs > maxAgeMs) {
const error = new Error(`Claim token expired (${CLAIM_TOKEN_TTL_HOURS}h TTL). Token age: ${Math.floor(ageMs / (60 * 60 * 1000))}h. Please register again to get a new token.`);
error.code = "XMCP_I_ECLAIM";
throw error;
}
// Step 3: Generate claim URL for browser authentication
const profileBaseUrl = process.env.KTA_BASE_URL || "https://knowthat.ai";
const claimPageUrl = new URL(`/claim/by-did/${encodeURIComponent(processVars.AGENT_DID)}`, profileBaseUrl);
claimPageUrl.searchParams.set('token', token);
spinner?.stop();
if (!json) {
console.log(chalk.yellow("\nâ ď¸ Browser authentication required"));
console.log(chalk.gray("\nThe claim process requires you to authenticate in a web browser."));
console.log(chalk.gray("Please visit the URL below to complete the claim:\n"));
console.log(chalk.cyan(` ${claimPageUrl.toString()}\n`));
console.log(chalk.gray("Waiting for claim to complete..."));
}
// Step 4: Poll API to check claim status
const apiBaseUrl = process.env.KTA_API_URL || process.env.KTA_BASE_URL || "https://knowthat.ai";
const statusCheckUrl = new URL("/api/agents/claim", apiBaseUrl);
statusCheckUrl.searchParams.set('did', processVars.AGENT_DID);
const maxAttempts = 60; // 5 minutes (5 second intervals)
const pollInterval = 5000; // 5 seconds
let claimed = false;
let attempts = 0;
spinner?.start("Checking claim status...");
while (!claimed && attempts < maxAttempts) {
try {
const response = await fetch(statusCheckUrl.toString());
if (response.ok) {
const statusData = await response.json();
if (statusData.claimed === true || (statusData.status === 'active' && statusData.isDraft === false)) {
claimed = true;
break;
}
}
else if (response.status === 404) {
// Agent not found - token might be invalid
throw new Error("Agent not found. The claim token may be invalid or expired.");
}
}
catch (fetchError) {
// Log error but continue polling (might be transient network issue)
if (process.env.DEBUG && !json) {
console.warn(chalk.yellow(`Poll attempt ${attempts + 1} failed: ${fetchError instanceof Error ? fetchError.message : 'Unknown error'}`));
}
}
attempts++;
if (!claimed && attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, pollInterval));
}
}
if (!claimed) {
spinner?.fail("Claim verification timeout");
const error = new Error(`Claim not confirmed after ${maxAttempts * pollInterval / 1000} seconds. ` +
`The token may have expired (${CLAIM_TOKEN_TTL_HOURS}h TTL) or you may not have completed authentication in the browser.`);
error.code = "XMCP_I_ECLAIM";
throw error;
}
// Step 5: Fetch agent details after successful claim
let agent;
try {
const agentResponse = await fetch(statusCheckUrl.toString());
if (agentResponse.ok) {
const agentData = await agentResponse.json();
agent = agentData;
}
}
catch (fetchError) {
// Non-fatal - we know claim succeeded
if (process.env.DEBUG && !json) {
console.warn(chalk.yellow('Could not fetch updated agent details'));
}
}
spinner?.succeed("Agent claimed successfully!");
const agentURL = new URL(`/agents/by-did/${encodeURIComponent(processVars.AGENT_DID)}`, profileBaseUrl).toString();
const editURL = new URL(`/agents/by-did/${encodeURIComponent(processVars.AGENT_DID)}/edit`, profileBaseUrl).toString();
if (json) {
const output = {
success: true,
claim: {
agentURL,
editURL,
claimedAt: agent?.claimedAt || new Date().toISOString(),
},
};
console.log(JSON.stringify(output, null, 2));
}
else {
console.log(`\n${chalk.bold("â
Agent Claimed Successfully!")}`);
console.log(` Agent URL: ${chalk.cyan(agentURL)}`);
console.log(` Edit URL: ${chalk.cyan(editURL)}`);
if (agent?.claimedAt) {
console.log(` Claimed At: ${chalk.gray(agent.claimedAt)}`);
}
}
// Re-run status after successful claim (R2.12)
if (!json) {
console.log(`\n${chalk.gray("Running status check...")}`);
try {
await status({ json: false });
}
catch (error) {
console.warn(chalk.yellow(`â ď¸ Status check failed: ${error instanceof Error ? error.message : "Unknown error"}`));
}
}
}
else {
// URL retrieval flow
const baseUrl = process.env.KTA_BASE_URL || "https://knowthat.ai";
const encodedDid = encodeURIComponent(processVars.AGENT_DID);
const editUrl = new URL(`/agents/by-did/${encodedDid}/edit`, baseUrl).toString();
const claimUrl = new URL(`/claim/by-did/${encodedDid}`, baseUrl).toString();
spinner?.succeed("URLs retrieved successfully!");
if (json) {
const output = {
success: true,
urls: {
editUrl,
claimUrl,
profileUrl: new URL(`/agents/by-did/${encodedDid}`, baseUrl).toString(),
},
agent: {
did: processVars.AGENT_DID,
kid: processVars.AGENT_KEY_ID,
},
};
console.log(JSON.stringify(output, null, 2));
}
else {
console.log(`\n${chalk.bold("đ Agent Information:")}`);
console.log(` DID: ${chalk.gray(processVars.AGENT_DID)}`);
console.log(` Key ID: ${chalk.gray(processVars.AGENT_KEY_ID)}`);
console.log(` Profile: ${chalk.cyan(new URL(`/agents/by-did/${encodedDid}`, baseUrl).toString())}`);
if (claimUrl) {
console.log(`\n${chalk.bold("đŻ Claim Your Agent:")}`);
console.log(` ${chalk.cyan(claimUrl)}`);
console.log(`\n ${chalk.gray("Use this link to:")}`);
console.log(` ${chalk.gray("⢠Set up your account")}`);
console.log(` ${chalk.gray("⢠Edit agent details")}`);
console.log(` ${chalk.gray("⢠Add logos and descriptions")}`);
console.log(` ${chalk.gray("⢠Manage directory listings")}`);
console.log(` ${chalk.gray("⢠View analytics")}`);
showInfo("This is a one-time claim link. Once claimed, use the edit URL.");
}
console.log(`\n${chalk.bold("âď¸ Edit Your Agent:")}`);
console.log(` ${chalk.cyan(editUrl)}`);
console.log(` ${chalk.gray("(Requires authentication after claiming)")}`);
// Check if agent is draft
if (processVars.AGENT_DID?.includes("localhost")) {
showInfo("This is a development/draft agent. It won't appear in public directories.");
}
showSuccess("Management URLs ready!");
}
}
}
catch (error) {
spinner?.fail(token ? "Claim failed" : "Failed to retrieve URLs");
const errorMessage = error.message ||
(token
? "Failed to process claim token"
: "Failed to get management URLs");
if (json) {
console.log(JSON.stringify({
success: false,
error: errorMessage,
code: error.code || (token ? "XMCP_I_ECLAIM" : "XMCP_I_EURLS"),
}, null, 2));
}
else {
if (error.code === "XMCP_I_ECLAIM" || error.message?.includes("XMCP_I_ECLAIM")) {
showError(error.message || `Claim failed. Token may be invalid or expired (${CLAIM_TOKEN_TTL_HOURS}h TTL)`);
console.log(`\n${chalk.gray("To get a new claim token:")}`);
console.log(`${chalk.cyan("mcpi register")}`);
}
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);
}
// Provide fallback URL
const fallbackBaseUrl = process.env.KTA_BASE_URL || "https://knowthat.ai";
const fallbackProfileUrl = new URL(`/agents/by-did/${encodeURIComponent(processVars.AGENT_DID)}`, fallbackBaseUrl).toString();
console.log(`\n${chalk.gray("You can try visiting directly:")}`);
console.log(`${chalk.cyan(fallbackProfileUrl)}`);
}
if (token && (error.code === "XMCP_I_ECLAIM" || error.message?.includes("XMCP_I_ECLAIM"))) {
process.exit(24); // XMCP_I_ECLAIM exit code
}
}
}
//# sourceMappingURL=claim.js.map