import { Command } from 'commander'; import chalk from 'chalk'; import ora from 'ora'; import { readFileSync, existsSync } from 'node:fs'; import { IndemnClient } from '../sdk/client.js'; import { FunctionsSDK } from '../sdk/functions.js'; import { formatTable, printJson, printError, printSuccess, confirm } from './utils/output.js'; const REST_API_TYPE = 'rest_api'; const CUSTOM_TOOL_TYPE = 'custom_tool'; const TRANSFER_CALL_TYPE = 'transfer_call'; // Server expects kebab-case auth types (see copilot-server/utils/constants.js // AI_FUNCTIONS_REST_API_HEADERS). Snake_case values pass validation but silently // produce no Authorization header. const VALID_AUTH_TYPES = ['api-key', 'basic-auth', 'bearer-token', 'no-auth'] as const; const VALID_HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] as const; interface ConfigSchemaOptions { config?: string; endpoint?: string; method?: string; payload?: string; payloadFile?: string; responseField?: string; outputInstructions?: string; craftResponse?: boolean; authType?: string; authKey?: string; authValue?: string; authUsername?: string; authPassword?: string; authToken?: string; handoffNumber?: string; } function loadConfigFile(filePath: string): Record { if (!existsSync(filePath)) { throw new Error(`--config file not found: ${filePath}`); } const content = readFileSync(filePath, 'utf-8'); let parsed: unknown; try { parsed = JSON.parse(content); } catch { throw new Error(`--config file is not valid JSON: ${filePath}`); } if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { throw new Error(`--config file must contain a JSON object: ${filePath}`); } const obj = parsed as Record; // Auto-unwrap if the file is a full BotFunction dump rather than just configurationSchema contents. // (e.g. user piped `functions list --json` into a file). Detected by presence of the wrapping key. if (obj.configurationSchema && typeof obj.configurationSchema === 'object' && !Array.isArray(obj.configurationSchema)) { return obj.configurationSchema as Record; } return obj; } function loadPayloadString(opts: ConfigSchemaOptions): string | undefined { if (opts.payload !== undefined && opts.payloadFile !== undefined) { throw new Error('--payload and --payload-file are mutually exclusive'); } if (opts.payload !== undefined) return opts.payload; if (opts.payloadFile !== undefined) { if (!existsSync(opts.payloadFile)) { throw new Error(`--payload-file not found: ${opts.payloadFile}`); } return readFileSync(opts.payloadFile, 'utf-8'); } return undefined; } function buildAuthorization(opts: ConfigSchemaOptions): Record | undefined { if (opts.authType === undefined) return undefined; // Accept snake_case as a user-friendly alias for kebab-case (idiomatic CLI variants). const authType = opts.authType.replace(/_/g, '-'); if (!(VALID_AUTH_TYPES as readonly string[]).includes(authType)) { throw new Error(`--auth-type must be one of: ${VALID_AUTH_TYPES.join(', ')}`); } if (authType === 'no-auth') return { type: 'no-auth' }; if (authType === 'api-key') { if (!opts.authKey || !opts.authValue) { throw new Error('--auth-key and --auth-value are required when --auth-type is api-key'); } return { type: 'api-key', key: opts.authKey, value: opts.authValue }; } if (authType === 'basic-auth') { if (!opts.authUsername || !opts.authPassword) { throw new Error('--auth-username and --auth-password are required when --auth-type is basic-auth'); } return { type: 'basic-auth', username: opts.authUsername, password: opts.authPassword }; } if (authType === 'bearer-token') { if (!opts.authToken) { throw new Error('--auth-token is required when --auth-type is bearer-token'); } return { type: 'bearer-token', token: opts.authToken }; } return undefined; } function validateTypeFlagCompatibility(type: string, opts: ConfigSchemaOptions): void { // REST-only flags must not be used with non-rest_api types — point them at --config instead. const restOnlyFlags: Array<[keyof ConfigSchemaOptions, string]> = [ ['endpoint', '--endpoint'], ['method', '--method'], ['payload', '--payload'], ['payloadFile', '--payload-file'], ['responseField', '--response-field'], ['authType', '--auth-type'], ['authKey', '--auth-key'], ['authValue', '--auth-value'], ['authUsername', '--auth-username'], ['authPassword', '--auth-password'], ['authToken', '--auth-token'], ]; if (type !== REST_API_TYPE) { for (const [opt, flag] of restOnlyFlags) { if (opts[opt] !== undefined) { throw new Error(`${flag} is only valid for --type rest_api. Use --config for other function types.`); } } } if (type !== TRANSFER_CALL_TYPE && opts.handoffNumber !== undefined) { throw new Error('--handoff-number is only valid for --type transfer_call. Use --config for other function types.'); } if (opts.method !== undefined) { const upper = opts.method.toUpperCase(); if (!(VALID_HTTP_METHODS as readonly string[]).includes(upper)) { throw new Error(`--method must be one of: ${VALID_HTTP_METHODS.join(', ')}`); } } } function buildConfigurationSchema(opts: ConfigSchemaOptions, type: string): Record | undefined { // Start from --config file if provided; layer individual flags on top so CLI wins. const result: Record = opts.config ? loadConfigFile(opts.config) : {}; if (type === REST_API_TYPE) { if (opts.endpoint !== undefined) result.endpoint = opts.endpoint; if (opts.method !== undefined) result.method = opts.method.toUpperCase(); const payloadStr = loadPayloadString(opts); if (payloadStr !== undefined) result.payload = payloadStr; if (opts.responseField !== undefined) result.responseField = opts.responseField; const auth = buildAuthorization(opts); if (auth !== undefined) result.authorization = auth; // Server's validateRestApiPayload requires authorization.type. Default to "no-auth" // so callers don't have to pass --auth-type explicitly for unauthenticated APIs. if (result.authorization === undefined) { result.authorization = { type: 'no-auth' }; } } if (type === REST_API_TYPE || type === CUSTOM_TOOL_TYPE) { if (opts.outputInstructions !== undefined) result.outputInstructions = opts.outputInstructions; if (opts.craftResponse !== undefined) result.craftResponse = !!opts.craftResponse; } if (type === TRANSFER_CALL_TYPE) { // Server's validateTransferCallPayload requires `handoffNumber` (the destination // phone number for the call transfer). copilot-server/services/botToolsValidationService.js:52 if (opts.handoffNumber !== undefined) result.handoffNumber = opts.handoffNumber; if (opts.outputInstructions !== undefined) result.outputInstructions = opts.outputInstructions; } return Object.keys(result).length > 0 ? result : undefined; } function applyConfigSchemaOptions(cmd: Command): Command { return cmd .option('--endpoint ', 'REST API endpoint URL') .option('--method ', 'HTTP method (GET|POST|PUT|DELETE|PATCH)') .option('--payload ', 'Request body as JSON string (server JSON.parses at execution)') .option('--payload-file ', 'Path to file containing request body JSON (alt to --payload)') .option('--response-field ', 'Dot-path for extracting from response (e.g. data.result)') .option('--output-instructions ', 'Instructions for how the LLM uses the response') .option('--craft-response', 'Have LLM craft response (use --no-craft-response to return data directly)') .option('--no-craft-response', 'Return data directly without LLM crafting') .option('--auth-type ', 'Auth type: api-key | basic-auth | bearer-token | no-auth') .option('--auth-key ', 'Header name for api-key auth (e.g. X-API-Key)') .option('--auth-value ', 'Header value for api-key auth') .option('--auth-username ', 'Username for basic-auth') .option('--auth-password ', 'Password for basic-auth') .option('--auth-token ', 'Token for bearer-token auth') .option('--handoff-number ', 'Destination phone number for --type transfer_call (E.164, e.g. +15551234567)') .option('--config ', 'JSON file with full configurationSchema (individual flags override file values)'); } export function registerFunctionCommands(program: Command): void { const functions = program.command('functions').description('Manage agent functions/tools'); functions .command('list') .argument('', 'Agent ID') .description('List functions for an agent') .option('--json', 'Output as JSON') .option('--limit ', 'Limit number of results') .option('--page ', 'Page number (use with --limit)') .action(async (agentId: string, options) => { try { const client = new IndemnClient(); const sdk = new FunctionsSDK(client); const spinner = ora('Fetching functions...').start(); let funcs = await sdk.listFunctions(agentId); spinner.stop(); if (options.page && options.limit) { const page = parseInt(options.page, 10); const limit = parseInt(options.limit, 10); funcs = funcs.slice((page - 1) * limit, page * limit); } else if (options.limit) { funcs = funcs.slice(0, parseInt(options.limit, 10)); } if (options.json) { printJson(funcs); return; } if (funcs.length === 0) { console.log('No functions found for this agent.'); return; } const rows = funcs.map((f) => [ f._id, f.name || '', f.type || '', f.description || '', ]); console.log(formatTable(['ID', 'Name', 'Type', 'Description'], rows)); } catch (err) { printError(err); process.exit(1); } }); applyConfigSchemaOptions( functions .command('create') .argument('', 'Agent ID') .description('Create a function for an agent') .requiredOption('--type ', 'Function type') .option('--name ', 'Function name') .option('--description ', 'Function description') ) .action(async (agentId: string, options) => { try { validateTypeFlagCompatibility(options.type, options); const configurationSchema = buildConfigurationSchema(options, options.type); // For rest_api on create, require endpoint+method (unless supplied via --config). if (options.type === REST_API_TYPE) { if (!configurationSchema || !configurationSchema.endpoint) { throw new Error('--endpoint (or --config with endpoint) is required for --type rest_api'); } if (!configurationSchema.method) { throw new Error('--method (or --config with method) is required for --type rest_api'); } } // For transfer_call on create, require handoffNumber (server validation rejects without it). if (options.type === TRANSFER_CALL_TYPE) { if (!configurationSchema || !configurationSchema.handoffNumber) { throw new Error('--handoff-number (or --config with handoffNumber) is required for --type transfer_call'); } } const client = new IndemnClient(); const sdk = new FunctionsSDK(client); const spinner = ora('Creating function...').start(); // Step 1: POST skeleton (server's createBotTool doesn't convert configurationSchema — // only the update path does. So a two-step create is needed for the function to be executable.) const func = await sdk.createFunction(agentId, { type: options.type, name: options.name, description: options.description, }); // Step 2: PUT configurationSchema so the server converts it into executionSchema. let finalFunc = func; if (configurationSchema) { spinner.text = 'Configuring function...'; try { finalFunc = await sdk.updateFunction(agentId, func._id, { type: options.type, description: options.description || '', configurationSchema, }); } catch (err) { spinner.fail(`Function created (${func._id}) but configuration step failed.`); printError(err); console.log(chalk.dim(`Retry: indemn functions update ${func._id} ...`)); console.log(chalk.dim(`Or remove: indemn functions delete ${func._id}`)); process.exit(1); } } spinner.stop(); printSuccess(`Function created: ${finalFunc._id}`); console.log(` Name: ${finalFunc.name || '(none)'}`); console.log(` Type: ${finalFunc.type}`); } catch (err) { printError(err); process.exit(1); } }); applyConfigSchemaOptions( functions .command('update') .argument('', 'Agent ID') .argument('', 'Function ID') .description('Update a function') .option('--name ', 'Function name') .option('--description ', 'Function description') ) .action(async (agentId: string, funcId: string, options) => { try { const client = new IndemnClient(); const sdk = new FunctionsSDK(client); // Fetch current function — required so we can preserve type/description and merge configurationSchema. const fetchSpinner = ora('Fetching current function...').start(); const functions = await sdk.listFunctions(agentId); const current = functions.find((f) => f._id === funcId); fetchSpinner.stop(); if (!current) { printError(`Function ${funcId} not found.`); process.exit(1); } validateTypeFlagCompatibility(current.type, options); const input: Record = { // Server requires type + description on every update. type: current.type, description: options.description || current.description || '', }; if (options.name) input.name = options.name; // Build configurationSchema patch from flags + --config, then shallow-merge over current. // Server replaces executionSchema wholesale on each update, so we must send the full merged schema — // otherwise `update --endpoint X` would wipe everything else. const patch = buildConfigurationSchema(options, current.type); if (patch) { input.configurationSchema = { ...(current.configurationSchema || {}), ...patch, }; } // Anything actually changing? type+description are always set; require at least one explicit option. const hasUserChanges = options.name !== undefined || options.description !== undefined || patch !== undefined; if (!hasUserChanges) { printError('No update options provided. Use --name, --description, --endpoint, --config, etc.'); process.exit(1); } const spinner = ora('Updating function...').start(); await sdk.updateFunction(agentId, funcId, input); spinner.stop(); printSuccess(`Function ${funcId} updated.`); } catch (err) { printError(err); process.exit(1); } }); functions .command('delete') .argument('', 'Agent ID') .argument('', 'Function ID') .description('Delete a function') .action(async (agentId: string, funcId: string) => { try { const confirmed = await confirm(`Delete function ${funcId}? This cannot be undone.`); if (!confirmed) { console.log('Cancelled.'); return; } const client = new IndemnClient(); const sdk = new FunctionsSDK(client); const spinner = ora('Deleting function...').start(); await sdk.deleteFunction(agentId, funcId); spinner.stop(); printSuccess(`Function ${funcId} deleted.`); } catch (err) { printError(err); process.exit(1); } }); functions .command('test') .argument('', 'Agent ID') .argument('', 'Function ID') .description('Test a function') .option('--json', 'Output as JSON') .action(async (agentId: string, funcId: string, options) => { try { const client = new IndemnClient(); const sdk = new FunctionsSDK(client); // Server's /test-rest-api endpoint expects the configurationSchema in the request body // (it re-converts via convertPayloadToExecutionSchema). It does NOT read the stored // schema from the DB. So we fetch the function's current configurationSchema and send it. const spinner = ora('Testing function...').start(); const all = await sdk.listFunctions(agentId); const current = all.find((f) => f._id === funcId); if (!current) { spinner.fail(`Function ${funcId} not found.`); process.exit(1); } const result = await sdk.testFunction(agentId, funcId, current.configurationSchema || {}); spinner.stop(); if (options.json) { printJson(result); } else { printSuccess('Function test completed:'); printJson(result); } } catch (err) { printError(err); process.exit(1); } }); functions .command('export') .argument('', 'Agent ID') .description('Export all functions for an agent') .option('--json', 'Output as JSON') .action(async (agentId: string, options) => { try { const client = new IndemnClient(); const sdk = new FunctionsSDK(client); const spinner = ora('Exporting functions...').start(); const result = await sdk.exportFunctions(agentId); spinner.stop(); if (options.json) { printJson(result); } else { printSuccess('Functions exported:'); printJson(result); } } catch (err) { printError(err); process.exit(1); } }); functions .command('import') .argument('', 'Agent ID') .description('Import functions from a URL') .requiredOption('--url ', 'URL to import from') .action(async (agentId: string, options) => { try { const client = new IndemnClient(); const sdk = new FunctionsSDK(client); const spinner = ora('Importing functions...').start(); const result = await sdk.importFunctions(agentId, options.url); spinner.stop(); printSuccess('Functions imported successfully.'); printJson(result); } catch (err) { printError(err); process.exit(1); } }); const params = functions.command('params').description('Manage function parameters'); params .command('list') .argument('', 'Agent ID') .argument('', 'Function ID') .description('List parameters for a function') .option('--json', 'Output as JSON') .action(async (agentId: string, funcId: string, options) => { try { const client = new IndemnClient(); const sdk = new FunctionsSDK(client); const spinner = ora('Fetching parameters...').start(); const parameters = await sdk.listParameters(agentId, funcId); spinner.stop(); if (options.json) { printJson(parameters); return; } if (parameters.length === 0) { console.log('No parameters found.'); return; } const rows = parameters.map((p) => [p._id || '', p.name, p.type, p.description || '', String(p.is_required || false)]); console.log(formatTable(['ID', 'Name', 'Type', 'Description', 'Required'], rows)); } catch (err) { printError(err); process.exit(1); } }); params .command('add') .argument('', 'Agent ID') .argument('', 'Function ID') .description('Add a parameter to a function') .requiredOption('--name ', 'Parameter name') .requiredOption('--type ', 'Parameter type (e.g. string, number, boolean)') .option('--description ', 'Parameter description') .option('--required', 'Mark as required') .action(async (agentId: string, funcId: string, options) => { try { const client = new IndemnClient(); const sdk = new FunctionsSDK(client); const spinner = ora('Adding parameter...').start(); const result = await sdk.addParameter(agentId, funcId, { name: options.name, type: options.type, description: options.description, required: options.required || false, }); spinner.stop(); // Server returns array of upserted params; extract the first one const param = Array.isArray(result) ? result[0] : result; printSuccess(`Parameter added: ${param._id || param.name}`); } catch (err) { printError(err); process.exit(1); } }); params .command('update') .argument('', 'Agent ID') .argument('', 'Function ID') .argument('', 'Parameter ID') .description('Update a function parameter') .option('--name ', 'Parameter name') .option('--type ', 'Parameter type') .option('--description ', 'Parameter description') .option('--required', 'Mark as required') .action(async (agentId: string, funcId: string, paramId: string, options) => { try { const input: Record = {}; if (options.name) input.name = options.name; if (options.type) input.type = options.type; if (options.description) input.description = options.description; if (options.required !== undefined) input.required = options.required; if (Object.keys(input).length === 0) { printError('No options provided.'); process.exit(1); } const client = new IndemnClient(); const sdk = new FunctionsSDK(client); const spinner = ora('Updating parameter...').start(); await sdk.updateParameter(agentId, funcId, paramId, input); spinner.stop(); printSuccess(`Parameter ${paramId} updated.`); } catch (err) { printError(err); process.exit(1); } }); params .command('delete') .argument('', 'Agent ID') .argument('', 'Function ID') .argument('', 'Parameter ID') .description('Delete a function parameter') .action(async (agentId: string, funcId: string, paramId: string) => { try { const confirmed = await confirm(`Delete parameter ${paramId}?`); if (!confirmed) { console.log('Cancelled.'); return; } const client = new IndemnClient(); const sdk = new FunctionsSDK(client); const spinner = ora('Deleting parameter...').start(); await sdk.deleteParameter(agentId, funcId, paramId); spinner.stop(); printSuccess(`Parameter ${paramId} deleted.`); } catch (err) { printError(err); process.exit(1); } }); // Master functions functions .command('master-list') .description('List available master function templates') .option('--json', 'Output as JSON') .action(async (options) => { try { const client = new IndemnClient(); const sdk = new FunctionsSDK(client); const spinner = ora('Fetching master functions...').start(); const result = await sdk.listMasterFunctions(); spinner.stop(); if (options.json) { printJson(result); return; } printJson(result); } catch (err) { printError(err); process.exit(1); } }); }