Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 30 additions & 35 deletions src/commands/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,22 @@ import chalk from 'chalk';
import { ChatCompletionsService, ApiError } from '../generated/index';
import { ConfigManager } from '../config/manager';
import { configureOpenAPI } from '../utils/openapi-config';
import { OptionResolver } from '../utils/option-resolver';

interface ChatOptions {
replicaName?: string;
message?: string;
silent?: boolean;
nonInteractive?: boolean;
save?: boolean;
verbose?: boolean;
veryVerbose?: boolean;
}

export async function chatCommand(folderPath?: string, options: ChatOptions = {}): Promise<void> {
const targetPath = folderPath || '.';

try {
// Load configurations following standard priority pattern
const { projectConfig } = await ConfigManager.getMergedConfig(targetPath);
const effectiveConfig = await ConfigManager.getEffectiveConfig(targetPath);

if (!effectiveConfig.apiKey) {
Expand All @@ -31,36 +32,25 @@ export async function chatCommand(folderPath?: string, options: ChatOptions = {}
}

// Configure the OpenAPI client
configureOpenAPI({
...effectiveConfig,
verbose: options.verbose,
veryVerbose: options.veryVerbose
configureOpenAPI({
...effectiveConfig,
verbose: options.verbose,
veryVerbose: options.veryVerbose
});

// Get replica name following standard priority pattern
let { replicaName, message } = options;

if (!replicaName) {
if (options.nonInteractive) {
// Try project config, then fail
replicaName = projectConfig.replicaName;
if (!replicaName) {
console.error(chalk.red('❌ Missing --replica-name parameter in non-interactive mode'));
console.error(chalk.red(' Either provide --replica-name or set replicaName in sensay.config.json'));
process.exit(1);
}
} else {
// Interactive prompt with default from config
const { replica } = await inquirer.prompt({
type: 'input',
name: 'replica',
message: 'Replica name:',
default: projectConfig.replicaName,
validate: (input: string) => input.trim().length > 0 || 'Replica name is required'
});
replicaName = replica;
}
}
// Resolve options using OptionResolver
const resolver = new OptionResolver(targetPath, { silent: options.silent, nonInteractive: options.nonInteractive, save: options.save });

const replicaName = await resolver.resolve({
key: 'replicaName',
cliValue: options.replicaName,
promptMessage: 'Replica name:',
isRequired: true,
});

await resolver.saveResolvedValues();

const message = options.message;

// Find replica UUID by name
const { ReplicasService } = await import('../generated/index');
Expand All @@ -80,10 +70,10 @@ export async function chatCommand(folderPath?: string, options: ChatOptions = {}
process.exit(1);
}

if (options.nonInteractive) {
// Non-interactive mode: single message
if (options.nonInteractive || options.silent) {
// Non-interactive/silent mode: single message
if (!message) {
console.error(chalk.red('❌ Missing --message parameter in non-interactive mode'));
console.error(chalk.red('❌ Missing --message parameter in non-interactive/silent mode'));
process.exit(1);
}

Expand Down Expand Up @@ -188,7 +178,12 @@ export function setupChatCommand(program: Command): void {
.option('-m, --message <text>', 'single message for non-interactive mode')
.action((folderPath, options) => {
const globalOptions = program.opts();
return chatCommand(folderPath, { ...options, nonInteractive: globalOptions.nonInteractive });
return chatCommand(folderPath, {
...options,
silent: globalOptions.silent,
nonInteractive: globalOptions.nonInteractive,
save: globalOptions.save,
});
});

// Configure help in wget style for this command
Expand Down
9 changes: 5 additions & 4 deletions src/commands/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,10 +281,11 @@ export function setupConversationsCommand(program: Command): void {
.option('-l, --limit <number>', 'limit number of results (default: 50)', parseInt)
.action((options) => {
const globalOptions = program.opts();
return conversationsCommand({
...options,
verbose: globalOptions.verbose,
veryVerbose: globalOptions.veryVerbose
return conversationsCommand({
...options,
nonInteractive: globalOptions.nonInteractive,
verbose: globalOptions.verbose,
veryVerbose: globalOptions.veryVerbose
});
});

Expand Down
11 changes: 7 additions & 4 deletions src/commands/retrain-failed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ interface RetrainFailedOptions {
apiKey?: string;
userId?: string;
silent?: boolean;
nonInteractive?: boolean;
save?: boolean;
verbose?: boolean;
veryVerbose?: boolean;
Expand Down Expand Up @@ -267,10 +268,12 @@ export function setupRetrainFailedCommand(program: Command) {
.option('--save', 'Save configuration options to project')
.action(async (options) => {
const globalOptions = program.opts();
await retrainFailedCommand(process.cwd(), {
...options,
verbose: globalOptions.verbose,
veryVerbose: globalOptions.veryVerbose
await retrainFailedCommand(process.cwd(), {
...options,
silent: options.silent || globalOptions.silent,
nonInteractive: globalOptions.nonInteractive,
verbose: globalOptions.verbose,
veryVerbose: globalOptions.veryVerbose
});
});
}
Expand Down
79 changes: 29 additions & 50 deletions src/commands/simple-organization-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,25 @@ import inquirer from 'inquirer';
import chalk from 'chalk';
import * as path from 'path';
import * as fs from 'fs-extra';
import {
ApiError,
UsersService,
import {
ApiError,
UsersService,
ReplicasService
} from '../generated/index';
import { ConfigManager } from '../config/manager';
import { FileProcessor } from '../utils/files';
import { ProgressManager } from '../utils/progress';
import { configureOpenAPI } from '../utils/openapi-config';
import { OptionResolver } from '../utils/option-resolver';

interface SetupOptions {
folderPath?: string;
userName?: string;
userEmail?: string;
replicaName?: string;
silent?: boolean;
nonInteractive?: boolean;
save?: boolean;
force?: boolean;
verbose?: boolean;
veryVerbose?: boolean;
Expand Down Expand Up @@ -114,54 +117,28 @@ export async function simpleOrganizationSetupCommand(folderPath?: string, option
veryVerbose: options.veryVerbose
});

// Get or prompt for configuration values
let { userName, userEmail } = options;
// Resolve configuration values using OptionResolver
const resolver = new OptionResolver(targetPath, { silent: options.silent, nonInteractive: options.nonInteractive, save: options.save });

if (!userName || !userEmail) {
const currentConfig = {
userName: userName || projectConfig.userName,
userEmail: userEmail || projectConfig.userEmail,
};

if (options.nonInteractive) {
// In non-interactive mode, use existing config or fail
if (!currentConfig.userName || !currentConfig.userEmail) {
console.error(chalk.red('❌ Missing required configuration. In non-interactive mode, you must either:'));
console.error(chalk.red(' 1. Provide command line options: --user-name, --user-email'));
console.error(chalk.red(' 2. Or have these values in your project config file (sensay.config.json)'));
process.exit(1);
}
userName = currentConfig.userName;
userEmail = currentConfig.userEmail;
} else {
const questions = [
{
type: 'input',
name: 'userName',
message: 'User name:',
default: currentConfig.userName,
when: !currentConfig.userName,
validate: (input: string) => input.trim().length > 0 || 'User name is required'
},
{
type: 'input',
name: 'userEmail',
message: 'User email:',
default: currentConfig.userEmail,
when: !currentConfig.userEmail,
validate: (input: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(input) || 'Please enter a valid email address';
}
}
];
const userName = await resolver.resolve({
key: 'userName',
cliValue: options.userName,
promptMessage: 'User name:',
isRequired: true,
});

const answers = await inquirer.prompt(questions);

userName = userName || currentConfig.userName || answers.userName;
userEmail = userEmail || currentConfig.userEmail || answers.userEmail;
const userEmail = await resolver.resolve({
key: 'userEmail',
cliValue: options.userEmail,
promptMessage: 'User email:',
isRequired: true,
validator: (input: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(input) || 'Please enter a valid email address';
}
}
});

await resolver.saveResolvedValues();

// Save project configuration (without replicaName since we have multiple)
await ConfigManager.saveProjectConfig({
Expand Down Expand Up @@ -426,9 +403,11 @@ export function setupSimpleOrganizationSetupCommand(program: Command): void {
.option('-f, --force', 'skip confirmation before deleting existing training data')
.action((folderPath, options) => {
const globalOptions = program.opts();
return simpleOrganizationSetupCommand(folderPath, {
...options,
return simpleOrganizationSetupCommand(folderPath, {
...options,
silent: globalOptions.silent,
nonInteractive: globalOptions.nonInteractive,
save: globalOptions.save,
verbose: globalOptions.verbose,
veryVerbose: globalOptions.veryVerbose
});
Expand Down
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ program
.option('-v, --verbose', 'Enable verbose output (shows API requests METHOD, URL, and response status)')
.option('-vv, --very-verbose', 'Enable very verbose output (shows full request/response including headers and body)')
.option('--no-color', 'Disable colored output')
.option('--non-interactive', 'Disable interactive mode (for scripts/CI)');
.option('-s, --silent', 'Skip interactive prompts, use config defaults (error if required value missing)')
.option('--non-interactive', 'Disable interactive mode (for scripts/CI)')
.option('--save', 'Save interactively resolved options to project config');

// Setup commands
setupClaimKeyCommand(program);
Expand Down
103 changes: 103 additions & 0 deletions src/utils/option-resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import inquirer from 'inquirer';
import { ConfigManager, ProjectConfig } from '../config/manager';
import { SensayConfig } from '../types/api';

export interface ResolveParams {
key: string;
cliValue?: string;
envVar?: string;
promptMessage: string;
isRequired?: boolean;
validator?: (input: string) => boolean | string;
defaultValue?: string;
}

export class OptionResolver {
private folderPath: string;
private silent: boolean;
private save: boolean;
private projectConfig: ProjectConfig | null = null;
private userConfig: SensayConfig | null = null;
private configLoaded = false;
private resolvedValues: Record<string, string> = {};

constructor(folderPath: string = '.', options: { silent?: boolean; nonInteractive?: boolean; save?: boolean } = {}) {
this.folderPath = folderPath;
this.silent = !!(options.silent || options.nonInteractive);
this.save = !!options.save;
}

private async loadConfig(): Promise<void> {
if (this.configLoaded) return;
const { userConfig, projectConfig } = await ConfigManager.getMergedConfig(this.folderPath);
this.userConfig = userConfig;
this.projectConfig = projectConfig;
this.configLoaded = true;
}

async resolve(params: ResolveParams): Promise<string | undefined> {
await this.loadConfig();

const { key, cliValue, envVar, promptMessage, isRequired, validator, defaultValue } = params;

// Priority 1: CLI argument
if (cliValue !== undefined && cliValue !== null) return cliValue;

// Priority 2: Environment variable
if (envVar) {
const envValue = process.env[envVar];
if (envValue !== undefined) return envValue;
}

// Priority 3/4: Project config, then user config
const projectValue = this.projectConfig ? (this.projectConfig as any)[key] : undefined;
const userValue = this.userConfig ? (this.userConfig as any)[key] : undefined;
const configValue = projectValue ?? userValue;

// In silent mode: use config value or default, error if required and missing
if (this.silent) {
const value = configValue ?? defaultValue;
if (isRequired && (value === undefined || value === null || value === '')) {
throw new Error(`Missing required option: ${key}. Provide it via CLI argument, environment variable, or config file.`);
}
return value;
}

// Interactive mode: prompt with config value as default
const promptDefault = configValue ?? defaultValue;
const { value } = await inquirer.prompt({
type: 'input',
name: 'value',
message: promptMessage,
default: promptDefault,
validate: (input: string) => {
if (isRequired && input.trim().length === 0) {
return `${key} is required`;
}
if (validator) return validator(input);
return true;
}
});

// Track interactively resolved values that aren't already in project config
if (this.save && value !== undefined && value !== projectValue) {
this.resolvedValues[key] = value;
}

return value;
}

async saveResolvedValues(): Promise<void> {
if (!this.save || Object.keys(this.resolvedValues).length === 0) return;

const currentConfig = await ConfigManager.getProjectConfig(this.folderPath);
for (const key in this.resolvedValues) {
(currentConfig as any)[key] = this.resolvedValues[key];
}
await ConfigManager.saveProjectConfig(currentConfig, this.folderPath);
}

getProjectConfig(): ProjectConfig | null {
return this.projectConfig;
}
}